diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 01c88fedd1..0412c6eacf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,6 +18,9 @@ android { jniLibs { useLegacyPackaging = true } + resources.excludes.add("META-INF/DEPENDENCIES") + resources.excludes.add("META-INF/LICENSE.md") + resources.excludes.add("META-INF/NOTICE.md") } } diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt new file mode 100644 index 0000000000..a7baa714a4 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt @@ -0,0 +1,8 @@ +package com.tangem.common.extensions + +import io.github.kakaocup.compose.node.element.KNode + +fun KNode.clickWithAssertion() { + assertIsDisplayed() + performClick() +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/OpenMainScreenScenario.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/OpenMainScreenScenario.kt new file mode 100644 index 0000000000..0c6b1790ea --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/OpenMainScreenScenario.kt @@ -0,0 +1,38 @@ +package com.tangem.scenarios + +import androidx.compose.ui.test.junit4.ComposeTestRule +import com.kaspersky.kaspresso.testcases.api.scenario.Scenario +import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.domain.models.scan.ProductType +import com.tangem.screens.DisclaimerTestScreen +import com.tangem.screens.MainTestScreen +import com.tangem.screens.StoriesTestScreen +import com.tangem.tap.domain.sdk.mocks.MockProvider +import io.github.kakaocup.compose.node.element.ComposeScreen + +class OpenMainScreenScenario( + private val testRule: ComposeTestRule, + private val productType: ProductType? = null, +) : Scenario() { + override val steps: TestContext.() -> Unit = { + if (productType != null) { + MockProvider.setMocks(productType) + } + ComposeScreen.onComposeScreen(testRule) { + step("Click on \"Accept\" button") { + acceptButton.clickWithAssertion() + } + } + ComposeScreen.onComposeScreen(testRule) { + step("Click on \"Scan\" button") { + scanButton.clickWithAssertion() + } + } + ComposeScreen.onComposeScreen(testRule) { + step("Make sure wallet screen is visible") { + assertIsDisplayed() + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DetailsTestScreen.kt b/app/src/androidTest/kotlin/com/tangem/screens/DetailsTestScreen.kt new file mode 100644 index 0000000000..528989a4e3 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/DetailsTestScreen.kt @@ -0,0 +1,51 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.core.ui.test.TestTags +import com.tangem.wallet.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class DetailsTestScreen(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(TestTags.DETAILS_SCREEN) } + ) { + + val walletConnectButton: KNode = child { + hasTestTag(TestTags.DETAILS_SCREEN_ITEM) + hasText(getResourceString(R.string.wallet_connect_title)) + } + + private val walletBlock: KNode = child { + hasTestTag(TestTags.DETAILS_SCREEN_ITEM) + } + + val walletNameButton: KNode = walletBlock.child { + hasClickAction() + hasPosition(0) + } + + val scanCardButton: KNode = walletBlock.child { + hasText(getResourceString(R.string.scan_card_settings_button)) + } + + val buyTangemButton: KNode = child { + hasTestTag(TestTags.DETAILS_SCREEN_ITEM) + hasText(getResourceString(R.string.details_buy_wallet)) + } + + val appSettingsButton: KNode = child { + hasTestTag(TestTags.DETAILS_SCREEN_ITEM) + hasText(getResourceString(R.string.app_settings_title)) + } + val contactSupportButton: KNode = child { + hasTestTag(TestTags.DETAILS_SCREEN_ITEM) + hasText(getResourceString(R.string.details_row_title_contact_to_support)) + } + val toSButton: KNode = child { + hasTestTag(TestTags.DETAILS_SCREEN_ITEM) + hasText(getResourceString(R.string.disclaimer_title)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DisclaimerScreen.kt b/app/src/androidTest/kotlin/com/tangem/screens/DisclaimerScreen.kt deleted file mode 100644 index 51b94ab791..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/screens/DisclaimerScreen.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.screens - -import com.kaspersky.kaspresso.screens.KScreen -import com.tangem.tap.features.disclaimer.ui.DisclaimerFragment -import com.tangem.wallet.R -import io.github.kakaocup.kakao.text.KButton - -object DisclaimerScreen : KScreen(){ - - override val layoutId = R.layout.fragment_disclaimer - - override val viewClass = DisclaimerFragment::class.java - - val acceptButton: KButton = KButton { - withId(R.id.btn_accept) - } -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DisclaimerTestScreen.kt b/app/src/androidTest/kotlin/com/tangem/screens/DisclaimerTestScreen.kt new file mode 100644 index 0000000000..4af7f33d51 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/DisclaimerTestScreen.kt @@ -0,0 +1,17 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.core.ui.test.TestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.KNode + +class DisclaimerTestScreen(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(TestTags.DISCLAIMER_SCREEN_CONTAINER) } + ) { + + val acceptButton: KNode = child { + hasTestTag(TestTags.DISCLAIMER_SCREEN_ACCEPT_BUTTON) + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletScreen.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainTestScreen.kt similarity index 58% rename from app/src/androidTest/kotlin/com/tangem/screens/WalletScreen.kt rename to app/src/androidTest/kotlin/com/tangem/screens/MainTestScreen.kt index 0988625537..a342754478 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WalletScreen.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainTestScreen.kt @@ -4,8 +4,8 @@ import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.core.ui.test.TestTags import io.github.kakaocup.compose.node.element.ComposeScreen -class WalletScreen(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen( +class MainTestScreen(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( semanticsProvider = semanticsProvider, - viewBuilderAction = { hasTestTag(TestTags.WALLET_SCREEN) } + viewBuilderAction = { hasTestTag(TestTags.MAIN_SCREEN) } ) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/StoriesScreen.kt b/app/src/androidTest/kotlin/com/tangem/screens/StoriesTestScreen.kt similarity index 87% rename from app/src/androidTest/kotlin/com/tangem/screens/StoriesScreen.kt rename to app/src/androidTest/kotlin/com/tangem/screens/StoriesTestScreen.kt index cda17d33c2..9dd1a415e9 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/StoriesScreen.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/StoriesTestScreen.kt @@ -8,8 +8,8 @@ import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.views.KView import io.github.kakaocup.kakao.text.KButton -class StoriesScreen(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen( +class StoriesTestScreen(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( semanticsProvider = semanticsProvider, viewBuilderAction = { hasTestTag(TestTags.STORIES_SCREEN) } ) { diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TestTopBar.kt b/app/src/androidTest/kotlin/com/tangem/screens/TestTopBar.kt new file mode 100644 index 0000000000..83d2e8a6a9 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TestTopBar.kt @@ -0,0 +1,16 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.core.ui.test.TestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.KNode + +class TestTopBar(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(TestTags.MAIN_SCREEN_TOP_BAR) } + ) { + val moreButton: KNode = child { + hasTestTag(TestTags.MAIN_SCREEN_MORE_BUTTON) + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsTestScreen.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsTestScreen.kt new file mode 100644 index 0000000000..1b31bd509c --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsTestScreen.kt @@ -0,0 +1,31 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.core.ui.test.TestTags +import com.tangem.wallet.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class WalletSettingsTestScreen(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(TestTags.WALLET_SETTINGS_SCREEN) } + ) { + private val walletSettingsItem: KNode = child { + hasTestTag(TestTags.WALLET_SETTINGS_SCREEN_ITEM) + } + + val linkMoreCardsButton: KNode = walletSettingsItem.child { + hasText(getResourceString(R.string.details_row_title_create_backup)) + } + val cardSettingsButton: KNode = walletSettingsItem.child { + hasText(getResourceString(R.string.card_settings_title)) + } + val referralProgramButton: KNode = walletSettingsItem.child { + hasText(getResourceString(R.string.details_referral_title)) + } + val forgetWalletButton: KNode = walletSettingsItem.child { + hasText(getResourceString(R.string.settings_forget_wallet)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/DetailsScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/DetailsScreenTest.kt new file mode 100644 index 0000000000..8c4bd6d3c9 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/DetailsScreenTest.kt @@ -0,0 +1,160 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.domain.models.scan.ProductType +import com.tangem.scenarios.OpenMainScreenScenario +import com.tangem.screens.DetailsTestScreen +import com.tangem.screens.TestTopBar +import com.tangem.screens.WalletSettingsTestScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.compose.node.element.ComposeScreen +import org.junit.Test + +@HiltAndroidTest +class DetailsScreenTest : BaseTestCase() { + + @Test + fun walletWithoutBackupDetails() = + setupHooks().run { + scenario(OpenMainScreenScenario(composeTestRule)) + ComposeScreen.onComposeScreen(composeTestRule) { + step("Open wallet details") { + moreButton.clickWithAssertion() + } + } + ComposeScreen.onComposeScreen(composeTestRule) { + step("Assert wallet connect button is visible") { + walletConnectButton.assertIsDisplayed() + } + step("Assert scan card button is visible") { + scanCardButton.assertIsDisplayed() + } + step("Assert buy Tangem card button is visible") { + buyTangemButton.assertIsDisplayed() + } + step("Assert app settings button is visible") { + appSettingsButton.assertIsDisplayed() + } + step("Assert contact support button is visible") { + contactSupportButton.assertIsDisplayed() + } + step("Assert terms or service button is visible") { + toSButton.assertIsDisplayed() + } + step("Open wallet settings screen") { + walletNameButton.clickWithAssertion() + } + } + ComposeScreen.onComposeScreen(composeTestRule) { + step("Assert Link more cards button is visible") { + linkMoreCardsButton.assertIsDisplayed() + } + step("Assert Card Settings button is visible") { + cardSettingsButton.assertIsDisplayed() + } + step("Assert Referral program button is visible") { + referralProgramButton.assertIsDisplayed() + } + step("Assert Forget wallet button is visible") { + forgetWalletButton.assertIsDisplayed() + } + } + } + + @Test + fun wallet2Details() = + setupHooks().run { + scenario(OpenMainScreenScenario(composeTestRule, ProductType.Wallet2)) + ComposeScreen.onComposeScreen(composeTestRule) { + step("Open wallet details") { + moreButton.clickWithAssertion() + } + } + ComposeScreen.onComposeScreen(composeTestRule) { + step("Assert wallet connect button is visible") { + walletConnectButton.assertIsDisplayed() + } + step("Assert scan card button is visible") { + scanCardButton.assertIsDisplayed() + } + step("Assert buy Tangem card button is visible") { + buyTangemButton.assertIsDisplayed() + } + step("Assert app settings button is visible") { + appSettingsButton.assertIsDisplayed() + } + step("Assert contact support button is visible") { + contactSupportButton.assertIsDisplayed() + } + step("Assert terms or service button is visible") { + toSButton.assertIsDisplayed() + } + step("Open wallet settings screen") { + walletNameButton.clickWithAssertion() + } + } + ComposeScreen.onComposeScreen(composeTestRule) { + step("Assert Link more cards button does not exist") { + linkMoreCardsButton.assertIsNotDisplayed() + } + step("Assert Card Settings button is visible") { + cardSettingsButton.assertIsDisplayed() + } + step("Assert Referral program button is visible") { + referralProgramButton.assertIsDisplayed() + } + step("Assert Forget wallet button is visible") { + forgetWalletButton.assertIsDisplayed() + } + } + } + + @Test + fun noteDetails() = + setupHooks().run { + scenario(OpenMainScreenScenario(composeTestRule, ProductType.Note)) + ComposeScreen.onComposeScreen(composeTestRule) { + step("Open wallet details") { + moreButton.clickWithAssertion() + } + } + ComposeScreen.onComposeScreen(composeTestRule) { + step("Assert wallet connect button does not exist") { + walletConnectButton.assertIsNotDisplayed() + } + step("Assert scan card button is visible") { + scanCardButton.assertIsDisplayed() + } + step("Assert buy Tangem card button is visible") { + buyTangemButton.assertIsDisplayed() + } + step("Assert app settings button is visible") { + appSettingsButton.assertIsDisplayed() + } + step("Assert contact support button is visible") { + contactSupportButton.assertIsDisplayed() + } + step("Assert terms or service button is visible") { + toSButton.assertIsDisplayed() + } + step("Open wallet settings screen") { + walletNameButton.clickWithAssertion() + } + } + ComposeScreen.onComposeScreen(composeTestRule) { + step("Assert Link more cards button does not exist") { + linkMoreCardsButton.assertIsNotDisplayed() + } + step("Assert Card Settings button is visible") { + cardSettingsButton.assertIsDisplayed() + } + step("Assert Referral program button does not exist") { + referralProgramButton.assertIsNotDisplayed() + } + step("Assert Forget wallet button is visible") { + forgetWalletButton.assertIsDisplayed() + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/MainScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/MainScreenTest.kt index dcebde4556..98e9cad75c 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/MainScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/MainScreenTest.kt @@ -1,11 +1,8 @@ package com.tangem.tests import com.tangem.common.BaseTestCase -import com.tangem.screens.DisclaimerScreen -import com.tangem.screens.StoriesScreen -import com.tangem.screens.WalletScreen +import com.tangem.scenarios.OpenMainScreenScenario import dagger.hilt.android.testing.HiltAndroidTest -import io.github.kakaocup.compose.node.element.ComposeScreen import org.junit.Test @HiltAndroidTest @@ -14,26 +11,6 @@ class MainScreenTest : BaseTestCase() { @Test fun goToMain() = setupHooks().run { - ComposeScreen.onComposeScreen(composeTestRule) { - step("Click on \"Scan\" button") { - scanButton { - assertIsDisplayed() - performClick() - } - } - } - DisclaimerScreen { - step("Click on \"Accept\" button") { - acceptButton { - isVisible() - click() - } - } - } - ComposeScreen.onComposeScreen(composeTestRule) { - step("Make sure wallet screen is visible") { - assertIsDisplayed() - } - } + scenario(OpenMainScreenScenario(composeTestRule)) } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/ScanEmptyTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/ScanErrorTest.kt similarity index 56% rename from app/src/androidTest/kotlin/com/tangem/tests/ScanEmptyTest.kt rename to app/src/androidTest/kotlin/com/tangem/tests/ScanErrorTest.kt index a1d67d8c1c..da686950a7 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/ScanEmptyTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/ScanErrorTest.kt @@ -1,9 +1,10 @@ package com.tangem.tests import com.tangem.common.BaseTestCase -import com.tangem.screens.DisclaimerScreen -import com.tangem.screens.StoriesScreen -import com.tangem.screens.WalletScreen +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.screens.DisclaimerTestScreen +import com.tangem.screens.MainTestScreen +import com.tangem.screens.StoriesTestScreen import com.tangem.tap.domain.sdk.mocks.MockProvider import dagger.hilt.android.testing.HiltAndroidTest import io.github.kakaocup.compose.node.element.ComposeScreen @@ -15,31 +16,22 @@ class ScanErrorTest : BaseTestCase() { @Test fun goToMain() = setupHooks().run { - ComposeScreen.onComposeScreen(composeTestRule) { + ComposeScreen.onComposeScreen(composeTestRule) { + step("Click on \"Accept\" button") { + acceptButton.clickWithAssertion() + } + } + ComposeScreen.onComposeScreen(composeTestRule) { step("Click on \"Scan\" button emulating scan error") { MockProvider.setEmulateError() - scanButton { - assertIsDisplayed() - performClick() - } + scanButton.clickWithAssertion() } step("Click on \"Scan\" button again without emulating error") { MockProvider.resetEmulateError() - scanButton { - assertIsDisplayed() - performClick() - } + scanButton.clickWithAssertion() } } - DisclaimerScreen { - step("Click on \"Accept\" button") { - acceptButton { - isVisible() - click() - } - } - } - ComposeScreen.onComposeScreen(composeTestRule) { + ComposeScreen.onComposeScreen(composeTestRule) { step("Make sure wallet screen is visible") { assertIsDisplayed() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/StoriesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/StoriesTest.kt index d6418c9364..9eb88b0075 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/StoriesTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/StoriesTest.kt @@ -2,7 +2,9 @@ package com.tangem.tests import android.content.Intent.ACTION_VIEW import com.tangem.common.BaseTestCase -import com.tangem.screens.StoriesScreen +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.screens.DisclaimerTestScreen +import com.tangem.screens.StoriesTestScreen import com.tangem.tap.features.home.redux.HomeMiddleware.NEW_BUY_WALLET_URL import dagger.hilt.android.testing.HiltAndroidTest import io.github.kakaocup.compose.node.element.ComposeScreen @@ -13,22 +15,16 @@ import org.junit.Test class StoriesTest : BaseTestCase() { @Test - fun clickOnButtons() = + fun clickOnOrderButton() = setupHooks().run { - ComposeScreen.onComposeScreen(composeTestRule) { - step("Click on \"Scan\" button") { - scanButton { - assertIsDisplayed() - performClick() - } - } - step("Assert: \"Scan card\" popup opened") { - enableNFCAlert.isDisplayed() - cancelButton.click() - device.uiDevice.pressBack() + ComposeScreen.onComposeScreen(composeTestRule) { + step("Click on \"Accept\" button") { + acceptButton.clickWithAssertion() } + } + ComposeScreen.onComposeScreen(composeTestRule) { step("Click on \"Order\" button") { - orderButton.performClick() + orderButton.clickWithAssertion() } step("Assert: browser opened") { val expectedIntent = KIntent { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5979dc8c9a..4a84a2b29f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,7 +55,7 @@ android:launchMode="singleTop" android:screenOrientation="portrait" android:theme="@style/SplashTheme" - android:windowSoftInputMode="adjustResize"> + android:windowSoftInputMode="adjustNothing"> diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 095b8f4ea0..57484a0139 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 095b8f4ea0fa02e7ccea93cf0f437346345297ef +Subproject commit 57484a0139299d1229872c626b2c9cb34442038c diff --git a/app/src/main/assets/testnet_tokens.json b/app/src/main/assets/testnet_tokens.json index 409a6b6067..cb960e3694 100644 --- a/app/src/main/assets/testnet_tokens.json +++ b/app/src/main/assets/testnet_tokens.json @@ -708,6 +708,16 @@ "networkId": "cyber/test" } ] + }, + { + "id": "sei-network", + "name": "Sei Network", + "symbol": "SEI", + "networks": [ + { + "networkId": "sei-network/test" + } + ] } ] } diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index ff4810cfc6..21ab576e7f 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -10,7 +10,7 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.datasource.asset.loader.AssetLoader import com.tangem.datasource.config.ConfigManager import com.tangem.datasource.connection.NetworkConnectionManager -import com.tangem.datasource.local.token.UserTokensStore +import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.apptheme.repository.AppThemeModeRepository @@ -76,7 +76,7 @@ interface ApplicationEntryPoint { fun getBalanceHidingRepository(): BalanceHidingRepository - fun getUserTokensStore(): UserTokensStore + fun getAppPreferencesStore(): AppPreferencesStore fun getGetAppThemeModeUseCase(): GetAppThemeModeUseCase diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index f9888910fa..7735974a6d 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -583,8 +583,8 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac private fun sendStakingUnsubmittedHashes() { lifecycleScope.launch { sendUnsubmittedHashesUseCase.invoke() - .onRight { Timber.d("Submitting hashes succeeded") } .onLeft { Timber.e(it.toString()) } + .onRight { Timber.d("Submitting hashes succeeded") } } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 42280203ef..ef8490029a 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -22,7 +22,7 @@ import com.tangem.datasource.config.ConfigManager import com.tangem.datasource.config.FeaturesLocalLoader import com.tangem.datasource.config.models.Config import com.tangem.datasource.connection.NetworkConnectionManager -import com.tangem.datasource.local.token.UserTokensStore +import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.apptheme.repository.AppThemeModeRepository @@ -126,8 +126,8 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { private val balanceHidingRepository: BalanceHidingRepository get() = entryPoint.getBalanceHidingRepository() - private val userTokensStore: UserTokensStore - get() = entryPoint.getUserTokensStore() + private val appPreferencesStore: AppPreferencesStore + get() = entryPoint.getAppPreferencesStore() val getAppThemeModeUseCase: GetAppThemeModeUseCase get() = entryPoint.getGetAppThemeModeUseCase() @@ -228,7 +228,7 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { } derivationsFinder = DerivationsFinder( - newTokensStore = userTokensStore, + appPreferencesStore = appPreferencesStore, dispatchers = AppCoroutineDispatcherProvider(), ) appStateHolder.mainStore = store diff --git a/app/src/main/java/com/tangem/tap/common/DialogManager.kt b/app/src/main/java/com/tangem/tap/common/DialogManager.kt index a13d000c44..8bc1f8e7ae 100644 --- a/app/src/main/java/com/tangem/tap/common/DialogManager.kt +++ b/app/src/main/java/com/tangem/tap/common/DialogManager.kt @@ -51,6 +51,11 @@ class DialogManager : StoreSubscriber { source = state.dialog.source, onTryAgain = state.dialog.onTryAgain, ) + is StateDialog.NfcFeatureIsUnavailable -> SimpleAlertDialog.create( + titleRes = R.string.common_error, + messageRes = R.string.nfc_error_unavailable, + context = context, + ) is AppDialog.AddressInfoDialog -> AddressInfoBottomSheetDialog(state.dialog, context) is AppDialog.TestActionsDialog -> TestActionsBottomSheetDialog(state.dialog, context) is AppDialog.RussianCardholdersWarningDialog -> RussianCardholdersWarningBottomSheetDialog( diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseAppInstanceIdProvider.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseAppInstanceIdProvider.kt new file mode 100644 index 0000000000..5a8a85c072 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseAppInstanceIdProvider.kt @@ -0,0 +1,29 @@ +package com.tangem.tap.common.analytics.handlers.firebase + +import com.google.firebase.analytics.ktx.analytics +import com.google.firebase.ktx.Firebase +import com.tangem.core.analytics.AppInstanceIdProvider +import kotlinx.coroutines.suspendCancellableCoroutine +import timber.log.Timber +import kotlin.coroutines.resume + +internal class FirebaseAppInstanceIdProvider : AppInstanceIdProvider { + + override suspend fun getAppInstanceId(): String? = suspendCancellableCoroutine { continuation -> + Firebase.analytics.appInstanceId + .addOnSuccessListener { continuation.resume(it) } + .addOnFailureListener { + Timber.w("Fail to get appInstanceId") + continuation.resume(null) + } + } + + override fun getAppInstanceIdSync(): String? { + return try { + Firebase.analytics.appInstanceId.result + } catch (e: IllegalStateException) { + Timber.e(e, "getAppInstanceIdSync") + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt b/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt index 395f26e868..a1751aff85 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt @@ -16,6 +16,7 @@ import com.tangem.data.card.sdk.CardSdkOwner import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.sdk.DefaultSessionViewDelegate import com.tangem.sdk.extensions.* +import com.tangem.sdk.nfc.AndroidNfcAvailabilityProvider import com.tangem.sdk.nfc.NfcManager import com.tangem.sdk.storage.create import com.tangem.tap.foregroundActivityObserver @@ -98,9 +99,11 @@ internal class DefaultCardSdkProvider @Inject constructor( val viewDelegate = DefaultSessionViewDelegate(nfcManager, activity) viewDelegate.sdkConfig = config + val androidNfcAvailabilityProvider = AndroidNfcAvailabilityProvider(activity) val sdk = TangemSdk( reader = nfcManager.reader, viewDelegate = viewDelegate, + nfcAvailabilityProvider = androidNfcAvailabilityProvider, secureStorage = secureStorage, authenticationManager = authenticationManager, keystoreManager = keystoreManager, diff --git a/app/src/main/java/com/tangem/tap/di/analytics/AnalyticsModule.kt b/app/src/main/java/com/tangem/tap/di/analytics/AnalyticsModule.kt index c687d63c10..60fea2b718 100644 --- a/app/src/main/java/com/tangem/tap/di/analytics/AnalyticsModule.kt +++ b/app/src/main/java/com/tangem/tap/di/analytics/AnalyticsModule.kt @@ -1,9 +1,11 @@ package com.tangem.tap.di.analytics +import com.tangem.core.analytics.AppInstanceIdProvider import com.tangem.core.analytics.utils.AnalyticsContextProxy import com.tangem.domain.analytics.ChangeCardAnalyticsContextUseCase import com.tangem.tap.common.analytics.DefaultAnalyticsContextProxy import com.tangem.tap.common.analytics.DefaultChangeCardAnalyticsContextUseCase +import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAppInstanceIdProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -23,4 +25,8 @@ internal object AnalyticsModule { @Provides @Singleton fun provideAnalyticsContextProxy(): AnalyticsContextProxy = DefaultAnalyticsContextProxy() + + @Provides + @Singleton + fun provideAppInstanceIdProvider(): AppInstanceIdProvider = FirebaseAppInstanceIdProvider() } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt index c74a09521c..ce9ed6e593 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt @@ -92,4 +92,12 @@ internal object CardDomainModule { fun provideNetworkHasDerivationUseCase(): NetworkHasDerivationUseCase { return NetworkHasDerivationUseCase() } + + @Provides + @Singleton + fun provideIsRequiredDerivePublicKeysUseCase( + derivationsRepository: DerivationsRepository, + ): HasMissedDerivationsUseCase { + return HasMissedDerivationsUseCase(derivationsRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt index dd78da6dfd..e15eea4635 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt @@ -1,7 +1,12 @@ package com.tangem.tap.di.domain -import com.tangem.domain.managetokens.GetManagedTokensUseCase +import com.tangem.domain.card.repository.DerivationsRepository +import com.tangem.domain.managetokens.* +import com.tangem.domain.managetokens.repository.CustomTokensRepository import com.tangem.domain.managetokens.repository.ManageTokensRepository +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.NetworksRepository +import com.tangem.domain.walletmanager.WalletManagersFacade import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -17,4 +22,84 @@ internal object ManageTokensDomainModule { fun provideGetManageTokensUseCase(manageTokensRepository: ManageTokensRepository): GetManagedTokensUseCase { return GetManagedTokensUseCase(manageTokensRepository) } + + @Provides + @Singleton + fun provideValidateTokenFormatUseCase(customTokensRepository: CustomTokensRepository): ValidateTokenFormUseCase { + return ValidateTokenFormUseCase(customTokensRepository) + } + + @Provides + @Singleton + fun provideCreateCurrencyUseCase(customTokensRepository: CustomTokensRepository): CreateCurrencyUseCase { + return CreateCurrencyUseCase(customTokensRepository) + } + + @Provides + @Singleton + fun provideFindTokenUseCase(customTokensRepository: CustomTokensRepository): FindTokenUseCase { + return FindTokenUseCase(customTokensRepository) + } + + @Provides + @Singleton + fun provideCheckIsCurrencyNotAddedUseCase( + customTokensRepository: CustomTokensRepository, + ): CheckIsCurrencyNotAddedUseCase { + return CheckIsCurrencyNotAddedUseCase(customTokensRepository) + } + + @Provides + @Singleton + fun provideRemoveCustomManagedCryptoCurrencyUseCase( + customTokensRepository: CustomTokensRepository, + ): RemoveCustomManagedCryptoCurrencyUseCase { + return RemoveCustomManagedCryptoCurrencyUseCase(customTokensRepository) + } + + @Provides + @Singleton + fun provideSaveManagedTokensUseCase( + customTokensRepository: CustomTokensRepository, + walletManagersFacade: WalletManagersFacade, + currenciesRepository: CurrenciesRepository, + networksRepository: NetworksRepository, + derivationsRepository: DerivationsRepository, + ): SaveManagedTokensUseCase { + return SaveManagedTokensUseCase( + customTokensRepository = customTokensRepository, + walletManagersFacade = walletManagersFacade, + currenciesRepository = currenciesRepository, + networksRepository = networksRepository, + derivationsRepository = derivationsRepository, + ) + } + + @Provides + @Singleton + fun provideGetSupportedNetworksUseCase( + customTokensRepository: CustomTokensRepository, + ): GetSupportedNetworksUseCase { + return GetSupportedNetworksUseCase(customTokensRepository) + } + + @Provides + @Singleton + fun provideValidateDerivationPathUseCase( + customTokensRepository: CustomTokensRepository, + ): ValidateDerivationPathUseCase { + return ValidateDerivationPathUseCase(customTokensRepository) + } + + @Provides + @Singleton + fun provideCheckHasLinkedTokensUseCase(repository: ManageTokensRepository): CheckHasLinkedTokensUseCase { + return CheckHasLinkedTokensUseCase(repository) + } + + @Provides + @Singleton + fun provideCheckCurrencyUnsupportedUseCase(repository: ManageTokensRepository): CheckCurrencyUnsupportedUseCase { + return CheckCurrencyUnsupportedUseCase(repository) + } } \ No newline at end of file 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 28b30cb9e3..59b0f45dc5 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 @@ -1,10 +1,11 @@ package com.tangem.tap.di.domain -import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase -import com.tangem.domain.markets.GetTokenMarketInfoUseCase -import com.tangem.domain.markets.GetTokenPriceChartUseCase -import com.tangem.domain.markets.GetTokenQuotesUseCase +import com.tangem.domain.card.repository.DerivationsRepository +import com.tangem.domain.markets.* import com.tangem.domain.markets.repositories.MarketsTokenRepository +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.NetworksRepository +import com.tangem.domain.tokens.repository.QuotesRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -37,7 +38,29 @@ object MarketsDomainModule { @Provides @Singleton - fun provideGetTokenQuotesUseCase(marketsTokenRepository: MarketsTokenRepository): GetTokenQuotesUseCase { - return GetTokenQuotesUseCase(marketsTokenRepository = marketsTokenRepository) + fun provideTokenFullQuotesUseCase(marketsTokenRepository: MarketsTokenRepository): GetTokenFullQuotesUseCase { + return GetTokenFullQuotesUseCase(marketsTokenRepository = marketsTokenRepository) + } + + @Provides + @Singleton + fun provideGetTokenQuotesUseCase(quotesRepository: QuotesRepository): GetCurrencyQuotesUseCase { + return GetCurrencyQuotesUseCase(quotesRepository = quotesRepository) + } + + @Provides + @Singleton + fun provideSaveMarketTokensUseCase( + derivationsRepository: DerivationsRepository, + marketsTokenRepository: MarketsTokenRepository, + currenciesRepository: CurrenciesRepository, + networksRepository: NetworksRepository, + ): SaveMarketTokensUseCase { + return SaveMarketTokensUseCase( + derivationsRepository = derivationsRepository, + marketsTokenRepository = marketsTokenRepository, + currenciesRepository = currenciesRepository, + networksRepository = networksRepository, + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt index bebc27226f..1b5860c2c0 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt @@ -57,6 +57,14 @@ internal object SettingsDomainModule { return ShouldShowSaveWalletScreenUseCase(settingsRepository = settingsRepository) } + @Provides + @Singleton + fun provideShouldShowMarketsTooltipUseCase( + settingsRepository: SettingsRepository, + ): ShouldShowMarketsTooltipUseCase { + return ShouldShowMarketsTooltipUseCase(settingsRepository = settingsRepository) + } + @Provides @Singleton fun providesCanUseBiometryUseCase(tangemSdkManager: TangemSdkManager): CanUseBiometryUseCase { diff --git a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt index a714d71200..c6d4c83b91 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt @@ -3,6 +3,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.staking.* import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.repositories.StakingTransactionHashRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -73,18 +74,6 @@ internal object StakingDomainModule { ) } - @Provides - @Singleton - fun provideGetStakingYieldBalanceUseCase( - stakingRepository: StakingRepository, - stakingErrorResolver: StakingErrorResolver, - ): GetStakingYieldBalanceUseCase { - return GetStakingYieldBalanceUseCase( - stakingRepository = stakingRepository, - stakingErrorResolver = stakingErrorResolver, - ) - } - @Provides @Singleton fun provideInitializeStakingProcessUseCase( @@ -112,11 +101,11 @@ internal object StakingDomainModule { @Provides @Singleton fun provideSubmitHashUseCase( - stakingRepository: StakingRepository, + stakingTransactionHashRepository: StakingTransactionHashRepository, stakingErrorResolver: StakingErrorResolver, ): SubmitHashUseCase { return SubmitHashUseCase( - stakingRepository = stakingRepository, + stakingTransactionHashRepository = stakingTransactionHashRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -124,11 +113,11 @@ internal object StakingDomainModule { @Provides @Singleton fun provideSaveUnsubmittedHashUseCase( - stakingRepository: StakingRepository, + stakingTransactionHashRepository: StakingTransactionHashRepository, stakingErrorResolver: StakingErrorResolver, ): SaveUnsubmittedHashUseCase { return SaveUnsubmittedHashUseCase( - stakingRepository = stakingRepository, + stakingTransactionHashRepository = stakingTransactionHashRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -136,23 +125,11 @@ internal object StakingDomainModule { @Provides @Singleton fun provideSendUnsubmittedHashesUseCase( - stakingRepository: StakingRepository, + stakingTransactionHashRepository: StakingTransactionHashRepository, stakingErrorResolver: StakingErrorResolver, ): SendUnsubmittedHashesUseCase { return SendUnsubmittedHashesUseCase( - stakingRepository = stakingRepository, - stakingErrorResolver = stakingErrorResolver, - ) - } - - @Provides - @Singleton - fun provideIsStakeMoreAvailableUseCase( - stakingRepository: StakingRepository, - stakingErrorResolver: StakingErrorResolver, - ): IsStakeMoreAvailableUseCase { - return IsStakeMoreAvailableUseCase( - stakingRepository = stakingRepository, + stakingTransactionHashRepository = stakingTransactionHashRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -180,4 +157,16 @@ internal object StakingDomainModule { stakingErrorResolver = stakingErrorResolver, ) } + + @Provides + @Singleton + fun provideIsAnyTokenStakedUseCase( + stakingRepository: StakingRepository, + stakingErrorResolver: StakingErrorResolver, + ): IsAnyTokenStakedUseCase { + return IsAnyTokenStakedUseCase( + stakingRepository = stakingRepository, + stakingErrorResolver = stakingErrorResolver, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index 24c222149b..343d1e9a8e 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -7,6 +7,7 @@ import com.tangem.domain.tokens.* import com.tangem.domain.tokens.repository.* import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.feature.swap.domain.api.SwapRepository +import com.tangem.features.markets.MarketsFeatureToggles import com.tangem.features.staking.api.featuretoggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -38,8 +39,9 @@ internal object TokensDomainModule { currenciesRepository: CurrenciesRepository, quotesRepository: QuotesRepository, networksRepository: NetworksRepository, + stakingRepository: StakingRepository, ): FetchTokenListUseCase { - return FetchTokenListUseCase(currenciesRepository, networksRepository, quotesRepository) + return FetchTokenListUseCase(currenciesRepository, networksRepository, quotesRepository, stakingRepository) } @Provides @@ -73,8 +75,8 @@ internal object TokensDomainModule { quotesRepository: QuotesRepository, networksRepository: NetworksRepository, stakingRepository: StakingRepository, - ): GetCardTokensListUseCase { - return GetCardTokensListUseCase(currenciesRepository, quotesRepository, networksRepository, stakingRepository) + ): GetNodlTokenListUseCase { + return GetNodlTokenListUseCase(currenciesRepository, quotesRepository, networksRepository, stakingRepository) } @Provides @@ -104,6 +106,22 @@ internal object TokensDomainModule { ) } + @Provides + @Singleton + fun provideGetAllWalletsCryptoCurrencyStatusesUseCase( + currenciesRepository: CurrenciesRepository, + quotesRepository: QuotesRepository, + networksRepository: NetworksRepository, + stakingRepository: StakingRepository, + ): GetAllWalletsCryptoCurrencyStatusesUseCase { + return GetAllWalletsCryptoCurrencyStatusesUseCase( + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + stakingRepository = stakingRepository, + ) + } + @Provides @Singleton fun provideGetCurrencyWarningsUseCase( @@ -158,8 +176,9 @@ internal object TokensDomainModule { currenciesRepository: CurrenciesRepository, quotesRepository: QuotesRepository, networksRepository: NetworksRepository, + stakingRepository: StakingRepository, ): FetchCurrencyStatusUseCase { - return FetchCurrencyStatusUseCase(currenciesRepository, networksRepository, quotesRepository) + return FetchCurrencyStatusUseCase(currenciesRepository, networksRepository, quotesRepository, stakingRepository) } @Provides @@ -213,6 +232,7 @@ internal object TokensDomainModule { networksRepository: NetworksRepository, stakingRepository: StakingRepository, stakingFeatureToggles: StakingFeatureToggles, + marketsFeatureToggles: MarketsFeatureToggles, dispatchers: CoroutineDispatcherProvider, ): GetCryptoCurrencyActionsUseCase { return GetCryptoCurrencyActionsUseCase( @@ -224,6 +244,7 @@ internal object TokensDomainModule { networksRepository = networksRepository, stakingRepository = stakingRepository, stakingFeatureToggles = stakingFeatureToggles, + marketsFeatureToggles = marketsFeatureToggles, dispatchers = dispatchers, ) } @@ -416,7 +437,7 @@ internal object TokensDomainModule { @Provides @Singleton - fun provideCheckHasLinkedTokensUseCase(currenciesRepository: CurrenciesRepository): CheckHasLinkedTokensUseCase { - return CheckHasLinkedTokensUseCase(currenciesRepository) + fun provideGetCurrencyCheckUseCase(currencyChecksRepository: CurrencyChecksRepository): GetCurrencyCheckUseCase { + return GetCurrencyCheckUseCase(currencyChecksRepository) } } \ No newline at end of file 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 73c0236567..e50891f228 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 @@ -42,6 +42,21 @@ internal object TransactionDomainModule { ) } + @Provides + @Singleton + fun provideSendMultipleTransactionUseCase( + cardSdkConfigRepository: CardSdkConfigRepository, + transactionRepository: TransactionRepository, + walletManagersFacade: WalletManagersFacade, + ): SendMultipleTransactionUseCase { + return SendMultipleTransactionUseCase( + demoConfig = DemoConfig(), + cardSdkConfigRepository = cardSdkConfigRepository, + transactionRepository = transactionRepository, + walletManagersFacade = walletManagersFacade, + ) + } + @Provides @Singleton fun provideAssociateAssetUseCase( diff --git a/app/src/main/java/com/tangem/tap/domain/card/DefaultDerivationsRepository.kt b/app/src/main/java/com/tangem/tap/domain/card/DefaultDerivationsRepository.kt index 5f4b695932..72b36175ef 100644 --- a/app/src/main/java/com/tangem/tap/domain/card/DefaultDerivationsRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/card/DefaultDerivationsRepository.kt @@ -1,5 +1,7 @@ package com.tangem.tap.domain.card +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.common.CompletionResult import com.tangem.common.card.EllipticCurve import com.tangem.common.core.TangemSdkError @@ -8,10 +10,13 @@ import com.tangem.common.doOnSuccess import com.tangem.common.extensions.ByteArrayKey import com.tangem.common.extensions.toMapKey import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.data.common.currency.getNetwork import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.card.repository.DerivationsRepository +import com.tangem.domain.common.util.derivationStyleProvider import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.operations.derivation.ExtendedPublicKeysMap @@ -30,6 +35,25 @@ internal class DefaultDerivationsRepository( ) : DerivationsRepository { override suspend fun derivePublicKeys(userWalletId: UserWalletId, currencies: List) { + derivePublicKeysByNetworks(userWalletId = userWalletId, networks = currencies.map(CryptoCurrency::network)) + } + + override suspend fun derivePublicKeysByNetworkIds(userWalletId: UserWalletId, networkIds: List) { + val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("User wallet not found") + + derivePublicKeysByNetworks( + userWalletId = userWalletId, + networks = networkIds.mapNotNull { + getNetwork( + blockchain = Blockchain.fromNetworkId(it.value) ?: return@mapNotNull null, + extraDerivationPath = null, + derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider, + ) + }, + ) + } + + override suspend fun derivePublicKeysByNetworks(userWalletId: UserWalletId, networks: List) { val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("User wallet not found") if (!userWallet.scanResponse.card.settings.isHDWalletAllowed) { @@ -38,7 +62,7 @@ internal class DefaultDerivationsRepository( } val derivations = MissedDerivationsFinder(scanResponse = userWallet.scanResponse) - .find(currencies) + .findByNetworks(networks) .ifEmpty { Timber.d("Nothing to derive") return @@ -47,6 +71,26 @@ internal class DefaultDerivationsRepository( derivePublicKeys(userWalletId = userWalletId, derivations = derivations) } + override suspend fun hasMissedDerivations( + userWalletId: UserWalletId, + networksWithDerivationPath: Map, + ): Boolean { + val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("User wallet not found") + + val derivations = MissedDerivationsFinder(scanResponse = userWallet.scanResponse) + .findByNetworks( + networksWithDerivationPath.mapNotNull { (networkId, extraDerivationPath) -> + getNetwork( + blockchain = Blockchain.fromNetworkId(networkId.value) ?: return@mapNotNull null, + extraDerivationPath = extraDerivationPath, + derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider, + ) + }, + ) + + return derivations.isNotEmpty() + } + override suspend fun derivePublicKeys(userWalletId: UserWalletId, derivations: Derivations): DerivedKeys { tangemSdkManager.derivePublicKeys(cardId = null, derivations = derivations) .doOnSuccess { response -> diff --git a/app/src/main/java/com/tangem/tap/domain/card/MissedDerivationsFinder.kt b/app/src/main/java/com/tangem/tap/domain/card/MissedDerivationsFinder.kt index 97c0a7bf8f..af980fd382 100644 --- a/app/src/main/java/com/tangem/tap/domain/card/MissedDerivationsFinder.kt +++ b/app/src/main/java/com/tangem/tap/domain/card/MissedDerivationsFinder.kt @@ -11,6 +11,7 @@ import com.tangem.domain.common.util.derivationStyleProvider import com.tangem.domain.models.scan.KeyWalletPublicKey import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network import com.tangem.operations.derivation.ExtendedPublicKeysMap private typealias DerivationData = Pair> @@ -26,8 +27,12 @@ internal class MissedDerivationsFinder(private val scanResponse: ScanResponse) { /** Find missed derivations for given currencies [currencies] */ fun find(currencies: List): Derivations { + return currencies.map { it.network }.let(::findByNetworks) + } + + fun findByNetworks(networks: List): Derivations { return buildMap> { - currencies + networks .mapToNewDerivations() .forEach { data -> val current = this[data.first] @@ -41,25 +46,25 @@ internal class MissedDerivationsFinder(private val scanResponse: ScanResponse) { } } - private fun List.mapToNewDerivations(): List { + private fun List.mapToNewDerivations(): List { val config = CardConfig.createConfig(scanResponse.card) - return mapNotNull { currency -> - val blockchain = Blockchain.fromId(id = currency.network.id.value) + return mapNotNull { network -> + val blockchain = Blockchain.fromId(id = network.id.value) val curve = config.primaryCurve(blockchain) ?: return@mapNotNull null - findNewDerivations(curve = curve, scanResponse = scanResponse, currency = currency) + findNewDerivations(curve = curve, scanResponse = scanResponse, network = network) } } private fun findNewDerivations( curve: EllipticCurve, scanResponse: ScanResponse, - currency: CryptoCurrency, + network: Network, ): DerivationData? { val wallet = scanResponse.card.wallets.firstOrNull { it.curve == curve } ?: return null val publicKey = wallet.publicKey.toMapKey() - val derivationCandidates = currency + val derivationCandidates = network .getDerivationCandidates(curve) .ifEmpty { return null } .filterAlreadyDerivedKeys(publicKey) @@ -68,13 +73,13 @@ internal class MissedDerivationsFinder(private val scanResponse: ScanResponse) { return publicKey to derivationCandidates } - private fun CryptoCurrency.getDerivationCandidates(curve: EllipticCurve): List { - val blockchain = Blockchain.fromId(id = network.id.value) + private fun Network.getDerivationCandidates(curve: EllipticCurve): List { + val blockchain = Blockchain.fromId(id = this.id.value) return buildList { add(blockchain.getDerivationPath(curve = curve)) - add(blockchain.getCustomDerivationPath(curve = curve, currency = this@getDerivationCandidates)) - add(blockchain.getCardanoDerivationPathIfNeeded(currency = this@getDerivationCandidates)) + add(blockchain.getCustomDerivationPath(curve = curve, network = this@getDerivationCandidates)) + add(blockchain.getCardanoDerivationPathIfNeeded(network = this@getDerivationCandidates)) } .filterNotNull() .distinct() @@ -88,17 +93,17 @@ internal class MissedDerivationsFinder(private val scanResponse: ScanResponse) { } } - private fun Blockchain.getCustomDerivationPath(curve: EllipticCurve, currency: CryptoCurrency): DerivationPath? { + private fun Blockchain.getCustomDerivationPath(curve: EllipticCurve, network: Network): DerivationPath? { return if (getSupportedCurves().contains(curve)) { - currency.network.derivationPath.value?.let(::DerivationPath) + network.derivationPath.value?.let(::DerivationPath) } else { null } } - private fun Blockchain.getCardanoDerivationPathIfNeeded(currency: CryptoCurrency): DerivationPath? { - return if (currency is CryptoCurrency.Coin && this == Blockchain.Cardano) { - currency.network.derivationPath.value?.let { + private fun Blockchain.getCardanoDerivationPathIfNeeded(network: Network): DerivationPath? { + return if (this == Blockchain.Cardano) { + network.derivationPath.value?.let { CardanoUtils.extendedDerivationPath(derivationPath = DerivationPath(it)) } } else { diff --git a/app/src/main/java/com/tangem/tap/domain/configurable/warningMessage/WarningMessage.kt b/app/src/main/java/com/tangem/tap/domain/configurable/warningMessage/WarningMessage.kt deleted file mode 100644 index 2f8f892e51..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/configurable/warningMessage/WarningMessage.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.tangem.tap.domain.configurable.warningMessage - -import androidx.annotation.StringRes -import com.squareup.moshi.Json -import com.tangem.blockchain.common.Blockchain - -/** -[REDACTED_AUTHOR] - */ -data class WarningMessage( - val title: String, - val message: String, - val type: Type, - val priority: Priority, - val location: List, - private val blockchains: List?, - @StringRes val titleResId: Int? = null, - @StringRes val messageResId: Int? = null, - val origin: Origin = Origin.Remote, - @StringRes val buttonTextId: Int? = null, - val titleFormatArg: String? = null, - val messageFormatArg: String? = null, -) { - val blockchainList: List? by lazy { - blockchains?.map { Blockchain.fromId(it.uppercase()) } - } - - var isHidden = false - - enum class Priority { - @Json(name = "critical") - Critical, - - @Json(name = "warning") - Warning, - - @Json(name = "info") - Info, - } - - enum class Type { - @Json(name = "permanent") - Permanent, // нельзя скрыть - - @Json(name = "temporary") - Temporary, // можно скрыть (кнопка ОК) - - AppRating, - - TestCard, - } - - enum class Location { - - @Json(name = "send") - SendScreen, - } - - enum class Origin { - Remote, - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt index 9468912e1c..3811371aa7 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt @@ -4,6 +4,7 @@ import com.tangem.common.CompletionResult import com.tangem.common.core.TangemError import com.tangem.common.core.TangemSdkError import com.tangem.domain.models.scan.ProductType +import com.tangem.tap.domain.sdk.mocks.content.NoteMockContent import com.tangem.tap.domain.sdk.mocks.content.WalletMockContent import com.tangem.tap.domain.sdk.mocks.content.Wallet2MockContent import com.tangem.tap.domain.tasks.product.CreateProductWalletTaskResponse @@ -67,6 +68,7 @@ object MockProvider { return when (productType) { ProductType.Wallet -> WalletMockContent ProductType.Wallet2 -> Wallet2MockContent + ProductType.Note -> NoteMockContent else -> TODO() } } diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/NoteMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/NoteMockContent.kt new file mode 100644 index 0000000000..de2da2c546 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/NoteMockContent.kt @@ -0,0 +1,117 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import com.tangem.tap.domain.tasks.product.CreateProductWalletTaskResponse +import java.util.Date + +object NoteMockContent : MockContent { + + override val cardDto = CardDTO( + cardId = "AB04000000010905", + batchId = "AB04", + cardPublicKey = byteArrayOf(2, 102, 3, -106, -14, -87, -118, 120, 10, 93, 17, 55, 26, -44, 5, 115, 88, 35, 49, -88, -69, 116, 0, -72, -27, 57, 50, -55, 80, -16, 39, -70, 119), + firmwareVersion = CardDTO.FirmwareVersion( + major = 4, + minor = 39, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1649635200000), + signature = byteArrayOf(-2, -45, 100, -79, 7, -120, 49, 74, 126, -3, -75, 54, -36, -1, 19, -83, 47, -82, 52, -43, -119, 75, 58, 97, 50, 37, 103, -6, -2, 28, 120, 103, -38, -95, 7, -126, -3, -23, -22, -30, -24, -126, 3, 70, 7, -20, -6, -49, 55, -93, 26, 57, -71, 12, -20, 41, -105, -75, 82, 116, 12, -75, -22, 55), + ), + issuer = CardDTO.Issuer( + name = "TANGEM AG", + publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 1, + isSettingAccessCodeAllowed = false, + isSettingPasscodeAllowed = false, + isResettingUserCodesAllowed = true, + isLinkedTerminalEnabled = true, + isBackupAllowed = false, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = false, + isHDWalletAllowed = false, + isKeysImportAllowed = false, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = false), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = false, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Secp256r1, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(2, -27, -117, 23, 68, -3, 21, -109, 18, -67, -107, -42, -44, -16, -127, -53, 46, -109, -46, -51, 89, 119, 79, 111, 78, 62, -125, 72, 109, 8, 45, 59, 117), + chainCode = byteArrayOf(), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 3, + remainingSignatures = null, + index = 0, + hasBackup = false, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.NoBackup, + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Note, + walletData = WalletData(blockchain = "DOGE", token = null), + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = emptyMap(), + ) + + override val extendedPublicKey + get() = error("Available only for wallet+?") + + override val successResponse = SuccessResponse(cardId = "AB04000000010905") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = error("Available only for Wallet 2") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2MockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2MockContent.kt index 2b9a29b938..4920acf082 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2MockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2MockContent.kt @@ -1,33 +1,292 @@ package com.tangem.tap.domain.sdk.mocks.content import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap import com.tangem.operations.wallet.CreateWalletResponse import com.tangem.tap.domain.sdk.mocks.MockContent import com.tangem.tap.domain.tasks.product.CreateProductWalletTaskResponse +import java.util.Date object Wallet2MockContent : MockContent { - override val scanResponse: ScanResponse - get() = TODO("Not yet implemented") + private val primaryCard = PrimaryCard( + cardId = "AF10000000981426", + batchId = "AF10", + cardPublicKey = byteArrayOf(3, 7, 80, -118, 6, 77, -15, -22, 107, 105, -64, 103, 77, -79, -102, 106, 46, 84, 21, -34, 47, -74, -56, 124, 17, -49, -29, 76, 84, 59, 50, -15, -52), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM 2.0", + publicKey = byteArrayOf(2, -120, 89, -52, -60, 36, -73, -17, 103, 107, -110, -36, 3, 110, -122, 72, 43, -38, 8, 30, -50, 25, -23, -17, 38, 94, 5, -112, -20, 9, 54, -24, -32), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) - override val cardDto: CardDTO - get() = TODO("Not yet implemented") + override val cardDto = CardDTO( + cardId = "AF10000000981426", + batchId = "AF10", + cardPublicKey = byteArrayOf(3, 7, 80, -118, 6, 77, -15, -22, 107, 105, -64, 103, 77, -79, -102, 106, 46, 84, 21, -34, 47, -74, -56, 124, 17, -49, -29, 76, 84, 59, 50, -15, -52), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(71, -20, 9, 31, 25, 111, 61, 119, 109, -123, -63, 51, 58, -71, -44, 53, 57, 20, -16, 97, -87, -82, 1, -35, -48, 63, 77, -78, -89, -112, -27, 25, -10, 90, 7, -53, -84, -125, 112, 68, -14, -85, -100, -64, -115, 31, 42, 119, 87, -79, 127, 42, -87, -102, 13, -9, -10, -51, -29, -63, -1, -52, -117, 57), + ), + issuer = CardDTO.Issuer( + name = "TANGEM 2.0", + publicKey = byteArrayOf(2, -120, 89, -52, -60, 36, -73, -17, 103, 107, -110, -36, 3, 110, -122, 72, 43, -38, 8, 30, -50, 25, -23, -17, 38, 94, 5, -112, -20, 9, 54, -24, -32), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = false, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(2, -114, -64, 120, -121, 11, -28, 89, -91, 114, 10, 84, -87, -36, 36, 19, -69, 95, 66, 14, 32, -35, -99, -67, 118, 51, 26, 71, -78, -36, 59, -126, -58), + chainCode = byteArrayOf(-47, -8, 74, 69, -1, 52, 10, -56, -85, 118, 56, 77, 125, -12, 85, -23, 42, -58, 99, 47, -87, -34, -83, 72, -122, -29, 88, -85, 46, -118, -26, 116), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 73, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -114, -64, 120, -121, 11, -28, 89, -91, 114, 10, 84, -87, -36, 36, 19, -69, 95, 66, 14, 32, -35, -99, -67, 118, 51, 26, 71, -78, -36, 59, -126, -58), + chainCode = byteArrayOf(-47, -8, 74, 69, -1, 52, 10, -56, -85, 118, 56, 77, 125, -12, 85, -23, 42, -58, 99, 47, -87, -34, -83, 72, -122, -29, 88, -85, 46, -118, -26, 116), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-94, 111, -89, -77, -62, -113, 16, -118, -46, -16, -20, 28, 53, -82, -109, 28, 99, -98, -54, 59, -3, 99, 16, -70, -73, 43, -6, 33, -53, -66, 76, -72, 6, -49, 83, -121, -122, 111, -111, -116, -119, 98, -94, -98, -121, -37, 20, -95), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 15, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(1), + ) - override val derivationTaskResponse: DerivationTaskResponse - get() = TODO("Not yet implemented") + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) - override val extendedPublicKey: ExtendedPublicKey - get() = TODO("Not yet implemented") + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) - override val successResponse: SuccessResponse - get() = TODO("Not yet implemented") + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(3, 2, 95, 53, 40, -87, -60, 11, -8, -47, 41, 37, 100, 15, -69, 1, -122, 127, -20, -81, -32, -20, -24, 5, -28, 113, 106, -90, -59, -30, -27, -110, -110), + chainCode = byteArrayOf(-95, -87, -95, -25, 27, 96, -57, -92, -69, -106, -45, 10, 85, 4, -92, -68, 49, -24, -28, -50, -49, -77, -20, 118, -50, -27, 104, -93, 115, -50, -46, -34), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) - override val createProductWalletTaskResponse: CreateProductWalletTaskResponse - get() = TODO("Not yet implemented") + override val successResponse = SuccessResponse(cardId = "AF10000000981426") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) override val importWalletResponse: CreateProductWalletTaskResponse get() = TODO("Not yet implemented") diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index 8e059f5450..6168bdda1f 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -111,6 +111,10 @@ object WalletMockContent : MockContent { publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), ), + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + ), ), extendedPublicKey = ExtendedPublicKey( publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), @@ -189,6 +193,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), ), ), ), diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/product/DerivationsFinder.kt b/app/src/main/java/com/tangem/tap/domain/tasks/product/DerivationsFinder.kt index d1f7b8a3d6..6fcad66476 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/product/DerivationsFinder.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/product/DerivationsFinder.kt @@ -5,7 +5,10 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.derivation.DerivationStyle import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.crypto.hdWallet.DerivationPath -import com.tangem.datasource.local.token.UserTokensStore +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull import com.tangem.domain.common.DerivationStyleProvider import com.tangem.domain.common.TapWorkarounds.useOldStyleDerivation import com.tangem.domain.models.scan.CardDTO @@ -22,7 +25,7 @@ internal data class BlockchainToDerive( // FIXME: May be move to DI, currently unnecessary internal class DerivationsFinder( - private val newTokensStore: UserTokensStore, + private val appPreferencesStore: AppPreferencesStore, private val dispatchers: CoroutineDispatcherProvider, ) { @@ -64,7 +67,9 @@ internal class DerivationsFinder( } private suspend fun getBlockchains(userWalletId: UserWalletId): MutableSet { - val responseTokens = newTokensStore.getSyncOrNull(userWalletId) + val responseTokens = appPreferencesStore.getObjectSyncOrNull( + key = PreferencesKeys.getUserTokensKey(userWalletId.stringValue), + ) ?.tokens ?: return hashSetOf() diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt index bd7f90f809..8c78ffa3a5 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt @@ -89,7 +89,7 @@ class WalletConnectSdkHelper { } else { feeAmount } - Fee.Ethereum(patchedAmount, gasLimit.toBigInteger(), gasPrice.toBigInteger()) + Fee.Ethereum.Legacy(patchedAmount, gasLimit.toBigInteger(), gasPrice.toBigInteger()) } else { Fee.Common(feeAmount) } diff --git a/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/ui/AddCustomTokenTestContent.kt b/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/ui/AddCustomTokenTestContent.kt index 3bb17cddb9..f5b7759a82 100644 --- a/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/ui/AddCustomTokenTestContent.kt +++ b/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/ui/AddCustomTokenTestContent.kt @@ -43,7 +43,7 @@ import kotlinx.coroutines.launch internal fun AddCustomTokenTestContent(state: AddCustomTokenStateHolder.TestContent, modifier: Modifier = Modifier) { val coroutineScope = rememberCoroutineScope() val bottomSheetScaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = BottomSheetState(initialValue = BottomSheetValue.Collapsed), + bottomSheetState = BottomSheetState(initialValue = BottomSheetValue.Collapsed, LocalDensity.current), ) BackHandler( diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsAlertDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsAlertDialog.kt index b7b5b15da2..06e802b5e0 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsAlertDialog.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsAlertDialog.kt @@ -7,7 +7,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import com.tangem.core.ui.components.BasicDialog -import com.tangem.core.ui.components.DialogButton +import com.tangem.core.ui.components.DialogButtonUM import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemThemePreview import com.tangem.tap.features.details.ui.appsettings.AppSettingsDialogsFactory @@ -20,12 +20,12 @@ internal fun SettingsAlertDialog(dialog: Dialog.Alert) { title = dialog.title.resolveReference(), message = dialog.description.resolveReference(), isDismissable = false, - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = dialog.confirmText.resolveReference(), warning = true, onClick = dialog.onConfirm, ), - dismissButton = DialogButton( + dismissButton = DialogButtonUM( title = stringResource(id = R.string.common_cancel), onClick = dialog.onDismiss, ), diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSelectorDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSelectorDialog.kt index 41b568b7ed..59af4ac24b 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSelectorDialog.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSelectorDialog.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import com.tangem.core.ui.components.DialogButton +import com.tangem.core.ui.components.DialogButtonUM import com.tangem.core.ui.components.SelectorDialog import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemThemePreview @@ -21,7 +21,7 @@ internal fun SettingsSelectorDialog(dialog: Dialog.Selector) { title = dialog.title.resolveReference(), selectedItemIndex = dialog.selectedItemIndex, items = dialog.items.map { it.resolveReference() }.toImmutableList(), - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = stringResource(R.string.common_cancel), onClick = dialog.onDismiss, ), diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsViewModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsViewModel.kt index 7f972b948d..9af2855279 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsViewModel.kt @@ -13,6 +13,7 @@ import com.tangem.core.analytics.Analytics import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.common.CardTypesResolver import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.common.util.getBackupCardsCount import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.wallets.builder.UserWalletIdBuilder @@ -159,6 +160,8 @@ internal class CardSettingsViewModel @Inject constructor( } if (scanResponse.cardTypesResolver.isTangemTwins()) { + // needs to prepare twin state if it's twin cards (depends on onboarding refactoring) + store.dispatch(TwinCardsAction.IfTwinsPrepareState(scanResponse)) store.dispatch(TwinCardsAction.SetMode(CreateTwinWalletMode.RecreateWallet(scanResponse))) cardSettingsInteractor.clear() @@ -173,13 +176,7 @@ internal class CardSettingsViewModel @Inject constructor( userWalletId = userWalletId, cardId = card.cardId, isActiveBackupStatus = card.backupStatus?.isActive == true, - backupCardsCount = when (val status = card.backupStatus) { - is CardDTO.BackupStatus.Active -> status.cardCount - is CardDTO.BackupStatus.CardLinked, - CardDTO.BackupStatus.NoBackup, - null, - -> 0 - }, + backupCardsCount = scanResponse.getBackupCardsCount() ?: 0, ), ) } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt index 1b52a50fd1..6f35968117 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt @@ -190,11 +190,11 @@ private fun CommonResetDialog(dialog: ResetCardScreenState.Dialog) { BasicDialog( title = stringResource(dialog.titleResId), message = stringResource(dialog.messageResId), - dismissButton = DialogButton( + dismissButton = DialogButtonUM( title = stringResource(id = R.string.common_cancel), onClick = dialog.onDismiss, ), - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = stringResource(id = R.string.card_settings_action_sheet_reset), warning = true, onClick = dialog.onConfirmClick, @@ -208,7 +208,7 @@ private fun CompletedResetDialog(dialog: ResetCardDialog) { BasicDialog( title = stringResource(id = dialog.titleResId), message = stringResource(id = dialog.messageResId), - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = stringResource(id = R.string.common_ok), onClick = dialog.onConfirmClick, ), diff --git a/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt b/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt index 508c8a2f4e..2379b399a9 100644 --- a/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt @@ -7,18 +7,11 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import com.tangem.common.routing.AppRoute -import com.tangem.core.analytics.Analytics import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.components.SystemBarsIconsDisposable import com.tangem.core.ui.screen.ComposeFragment -import com.tangem.domain.tokens.TokensAction -import com.tangem.tap.common.analytics.events.IntroductionProcess -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.common.redux.AppState import com.tangem.tap.features.home.compose.StoriesScreen -import com.tangem.tap.features.home.featuretoggles.HomeFeatureToggles import com.tangem.tap.features.home.redux.HomeAction import com.tangem.tap.features.home.redux.HomeState import com.tangem.tap.store @@ -32,9 +25,6 @@ internal class HomeFragment : ComposeFragment(), StoreSubscriber { @Inject override lateinit var uiDependencies: UiDependencies - @Inject - lateinit var homeFeatureToggles: HomeFeatureToggles - private var homeState: MutableState = mutableStateOf(store.state.homeState) private val viewModel by viewModels() @@ -77,31 +67,9 @@ internal class HomeFragment : ComposeFragment(), StoreSubscriber { private fun ScreenContent() { StoriesScreen( homeState = homeState, - onScanButtonClick = { - if (homeFeatureToggles.isCallbacksRefactoringEnabled) { - viewModel.onScanClick() - } else { - Analytics.send(IntroductionProcess.ButtonScanCard()) - store.dispatch(action = HomeAction.ReadCard(scope = requireActivity().lifecycleScope)) - } - }, - onShopButtonClick = { - if (homeFeatureToggles.isCallbacksRefactoringEnabled) { - viewModel.onShopClick() - } else { - Analytics.send(IntroductionProcess.ButtonBuyCards()) - store.dispatch(HomeAction.GoToShop) - } - }, - onSearchTokensClick = { - if (homeFeatureToggles.isCallbacksRefactoringEnabled) { - viewModel.onSearchClick() - } else { - Analytics.send(IntroductionProcess.ButtonTokensList()) - store.dispatchNavigationAction { push(AppRoute.ManageTokens(readOnlyContent = true)) } - store.dispatch(TokensAction.SetArgs.ReadAccess) - } - }, + onScanButtonClick = viewModel::onScanClick, + onShopButtonClick = viewModel::onShopClick, + onSearchTokensClick = viewModel::onSearchClick, ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/HomeViewModel.kt b/app/src/main/java/com/tangem/tap/features/home/HomeViewModel.kt index c545f2f83b..dd335deba7 100644 --- a/app/src/main/java/com/tangem/tap/features/home/HomeViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/home/HomeViewModel.kt @@ -2,6 +2,8 @@ package com.tangem.tap.features.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.firebase.analytics.ktx.analytics +import com.google.firebase.ktx.Firebase import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -45,6 +47,8 @@ internal class HomeViewModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, ) : ViewModel() { + private val tangemErrorHandler = TangemTangemErrorsHandler(store) + fun onScanClick() { analyticsEventHandler.send(IntroductionProcess.ButtonScanCard()) scanCard() @@ -54,14 +58,16 @@ internal class HomeViewModel @Inject constructor( analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards()) analyticsEventHandler.send(Shop.ScreenOpened()) - urlOpener.openUrl(NEW_BUY_WALLET_URL) + Firebase.analytics.appInstanceId + .addOnSuccessListener { urlOpener.openUrl(url = "$NEW_BUY_WALLET_URL&app_instance_id=$it") } + .addOnFailureListener { urlOpener.openUrl(url = NEW_BUY_WALLET_URL) } } fun onSearchClick() { analyticsEventHandler.send(IntroductionProcess.ButtonTokensList()) store.dispatch(TokensAction.SetArgs.ReadAccess) - store.dispatchNavigationAction { push(AppRoute.ManageTokens(readOnlyContent = true)) } + store.dispatchNavigationAction { push(AppRoute.ManageTokens()) } } private fun scanCard() { @@ -79,7 +85,7 @@ internal class HomeViewModel @Inject constructor( } }, onFailure = { - Timber.e(it, "Unable to scan card") + tangemErrorHandler.onErrorReceived(error = it) delay(HIDE_PROGRESS_DELAY) store.dispatch(HomeAction.ScanInProgress(scanInProgress = false)) }, diff --git a/app/src/main/java/com/tangem/tap/features/home/TangemTangemErrorsHandler.kt b/app/src/main/java/com/tangem/tap/features/home/TangemTangemErrorsHandler.kt new file mode 100644 index 0000000000..40e51a8de8 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/home/TangemTangemErrorsHandler.kt @@ -0,0 +1,44 @@ +package com.tangem.tap.features.home + +import com.tangem.blockchain.common.BlockchainError +import com.tangem.common.core.TangemError +import com.tangem.common.core.TangemSdkError +import com.tangem.domain.redux.StateDialog +import com.tangem.tap.common.extensions.dispatchOnMain +import com.tangem.tap.common.redux.AppState +import com.tangem.tap.common.redux.global.GlobalAction +import com.tangem.tap.features.home.errors.TangemSdkErrorHandler +import org.rekotlin.Store +import timber.log.Timber + +class TangemTangemErrorsHandler(val store: Store) : TangemSdkErrorHandler { + + override fun onErrorReceived(error: TangemError) { + when (error) { + is TangemSdkError -> { + handleCardSdkError(error) + } + is BlockchainError -> { + handleBlockchainSdkError(error) + } + else -> { + Timber.e("Error happened", error) + } + } + } + + private fun handleCardSdkError(error: TangemSdkError) { + when (error) { + is TangemSdkError.NfcFeatureIsUnavailable -> { + store.dispatchOnMain(GlobalAction.ShowDialog(StateDialog.NfcFeatureIsUnavailable)) + } + else -> { + Timber.e(error, "Unable to scan card") + } + } + } + + private fun handleBlockchainSdkError(error: TangemError) { + Timber.e("Sdk error happened", error) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/errors/TangemSdkErrorHandler.kt b/app/src/main/java/com/tangem/tap/features/home/errors/TangemSdkErrorHandler.kt new file mode 100644 index 0000000000..7946b7ec5e --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/home/errors/TangemSdkErrorHandler.kt @@ -0,0 +1,8 @@ +package com.tangem.tap.features.home.errors + +import com.tangem.common.core.TangemError + +interface TangemSdkErrorHandler { + + fun onErrorReceived(error: TangemError) +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/featuretoggles/HomeFeatureToggles.kt b/app/src/main/java/com/tangem/tap/features/home/featuretoggles/HomeFeatureToggles.kt deleted file mode 100644 index 8d23463a53..0000000000 --- a/app/src/main/java/com/tangem/tap/features/home/featuretoggles/HomeFeatureToggles.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.tap.features.home.featuretoggles - -import com.tangem.core.featuretoggle.manager.FeatureTogglesManager -import javax.inject.Inject - -internal class HomeFeatureToggles @Inject constructor( - private val featureTogglesManager: FeatureTogglesManager, -) { - - val isCallbacksRefactoringEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(name = "HOME_SCREEN_CALLBACKS_REFACTORING_ENABLED") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/redux/HomeAction.kt b/app/src/main/java/com/tangem/tap/features/home/redux/HomeAction.kt index f0171194c2..e3f0375cc1 100644 --- a/app/src/main/java/com/tangem/tap/features/home/redux/HomeAction.kt +++ b/app/src/main/java/com/tangem/tap/features/home/redux/HomeAction.kt @@ -15,7 +15,6 @@ sealed class HomeAction : Action { data class ReadCard(val scope: CoroutineScope) : HomeAction() data class ScanInProgress(val scanInProgress: Boolean) : HomeAction() - data object GoToShop : HomeAction() data class UpdateCountryCode(val userCountryCode: String) : HomeAction() } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt b/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt index 9da71e64e8..87a615ef97 100644 --- a/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt @@ -1,7 +1,5 @@ package com.tangem.tap.features.home.redux -import com.google.firebase.analytics.ktx.analytics -import com.google.firebase.ktx.Firebase import com.tangem.common.doOnFailure import com.tangem.common.doOnResult import com.tangem.common.doOnSuccess @@ -15,11 +13,12 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter import com.tangem.tap.common.analytics.events.IntroductionProcess -import com.tangem.tap.common.analytics.events.Shop -import com.tangem.tap.common.extensions.* +import com.tangem.tap.common.extensions.dispatchNavigationAction +import com.tangem.tap.common.extensions.eraseContext +import com.tangem.tap.common.extensions.inject +import com.tangem.tap.common.extensions.onUserWalletSelected import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.features.home.redux.HomeMiddleware.NEW_BUY_WALLET_URL import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.scope import com.tangem.tap.store @@ -61,21 +60,6 @@ private fun handleHomeAction(action: Action) { readCard() } } - is HomeAction.GoToShop -> { - Analytics.send(Shop.ScreenOpened()) - Firebase.analytics.appInstanceId - .addOnSuccessListener { - store.dispatchOpenUrl("$NEW_BUY_WALLET_URL&app_instance_id=$it") - } - .addOnFailureListener { - store.dispatchOpenUrl(NEW_BUY_WALLET_URL) - } - // disabled for now in task [REDACTED_JIRA] - // when (action.userCountryCode) { - // RUSSIA_COUNTRY_CODE, BELARUS_COUNTRY_CODE -> store.dispatchOpenUrl(BUY_WALLET_URL) - // else -> store.dispatch(NavigationAction.NavigateTo(AppScreen.Shop)) - // } - } } } diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index 375a326b2b..6df235a481 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -123,7 +123,7 @@ internal class MainViewModel @Inject constructor( private fun fetchStakingTokens() { viewModelScope.launch(dispatchers.main) { - fetchStakingTokensUseCase() + fetchStakingTokensUseCase(true) .onLeft { Timber.e(it.toString(), "Unable to fetch the staking tokens list") } .onRight { Timber.d("Staking token list was fetched successfully") } } diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/twins/ui/OnboardingTwinsFragment.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/twins/ui/OnboardingTwinsFragment.kt index eda118c3dc..76880fb5e1 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/twins/ui/OnboardingTwinsFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/twins/ui/OnboardingTwinsFragment.kt @@ -317,16 +317,16 @@ internal class OnboardingTwinsFragment : BaseOnboardingFragment( private fun setupTopUpWalletState(state: TwinCardsState) = with(mainBinding.onboardingActionContainer) { when (previousStep) { - TwinCardsStep.None -> { + TwinCardsStep.None, TwinCardsStep.Welcome -> { when (state.cardNumber) { TwinCardNumber.First -> { - twinsWidget.leapfrogWidget.unfold(false) { - twinsWidget.toActivate(false) + twinsWidget.leapfrogWidget.unfold(true) { + twinsWidget.toActivate(true) } } TwinCardNumber.Second -> { - switchToCard(state.cardNumber, false) { - twinsWidget.toActivate(false) + switchToCard(state.cardNumber, true) { + twinsWidget.toActivate(true) } } else -> {} diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletAction.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletAction.kt index c624e36825..3538c3bb27 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletAction.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletAction.kt @@ -23,7 +23,9 @@ sealed class OnboardingWalletAction : Action { data object ResumeBackup : OnboardingWalletAction() data class LoadArtwork(val cardArtworkUriForUnfinishedBackup: Uri? = null) : OnboardingWalletAction() - class SetArtworkUrl(val artworkUri: Uri?) : OnboardingWalletAction() + class SetPrimaryCardArtworkUrl(val artworkUri: Uri?) : OnboardingWalletAction() + class SetSecondCardArtworkUrl(val artworkUri: Uri?) : OnboardingWalletAction() + class SetThirdCardArtworkUrl(val artworkUri: Uri?) : OnboardingWalletAction() data object OnBackPressed : OnboardingWalletAction() } diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt index 0e32a392c4..95b2fd2452 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt @@ -3,6 +3,7 @@ package com.tangem.tap.features.onboarding.products.wallet.redux import android.net.Uri import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tangem.common.CompletionResult +import com.tangem.common.card.Card import com.tangem.common.core.TangemSdkError import com.tangem.common.extensions.guard import com.tangem.common.extensions.ifNotNull @@ -11,6 +12,7 @@ import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.common.services.Result import com.tangem.core.analytics.Analytics +import com.tangem.domain.common.extensions.withIOContext import com.tangem.domain.common.extensions.withMainContext import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.models.scan.CardDTO @@ -106,7 +108,7 @@ private fun handleWalletAction(action: Action) { val cardPublicKey = backupService.primaryPublicKey if (primaryCardId != null && cardPublicKey != null) { // uses when no scanResponse and backup state restored - loadArtworkForUnfinishedBackup( + loadArtworkForCard( cardId = primaryCardId, cardPublicKey = cardPublicKey, defaultArtwork = action.cardArtworkUriForUnfinishedBackup, @@ -119,7 +121,7 @@ private fun handleWalletAction(action: Action) { .takeIf { it != Artwork.DEFAULT_IMG_URL } ?.let { Uri.parse(it) } } - store.dispatchOnMain(OnboardingWalletAction.SetArtworkUrl(cardArtwork)) + store.dispatchOnMain(OnboardingWalletAction.SetPrimaryCardArtworkUrl(cardArtwork)) } } is OnboardingWalletAction.CreateWallet -> { @@ -232,11 +234,7 @@ private suspend fun readCard(onSuccess: (ScanResponse) -> Unit) { ) } -private suspend fun loadArtworkForUnfinishedBackup( - cardId: String, - cardPublicKey: ByteArray, - defaultArtwork: Uri?, -): Uri { +private suspend fun loadArtworkForCard(cardId: String, cardPublicKey: ByteArray, defaultArtwork: Uri?): Uri { return when (val cardInfo = OnlineCardVerifier().getCardInfo(cardId, cardPublicKey)) { is Result.Success -> { val artworkId = cardInfo.data.artwork?.id @@ -443,6 +441,7 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) store.dispatchOnMain(BackupAction.AddBackupCard.ChangeButtonLoading(false)) when (result) { is CompletionResult.Success -> { + updateArtworks(backupService.addedBackupCardsCount, result.data) store.dispatchOnMain(BackupAction.AddBackupCard.Success) } is CompletionResult.Failure -> { @@ -630,6 +629,22 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) } } +fun updateArtworks(addedBackupCardsCount: Int, card: Card) { + mainScope.launch { + withIOContext { + val imageUri = loadArtworkForCard(card.cardId, card.cardPublicKey, Uri.EMPTY) + when (addedBackupCardsCount) { + 1 -> { + store.dispatchOnMain(OnboardingWalletAction.SetSecondCardArtworkUrl(imageUri)) + } + 2 -> { + store.dispatchOnMain(OnboardingWalletAction.SetThirdCardArtworkUrl(imageUri)) + } + } + } + } +} + internal fun gatherCardIds(backupState: BackupState, card: CardDTO?): List { return (listOf(backupState.primaryCardId, card?.cardId) + backupState.backupCardIds) .filterNotNull() diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletReducer.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletReducer.kt index 1a400ea778..035db68bae 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletReducer.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletReducer.kt @@ -21,8 +21,20 @@ private fun internalReduce(action: Action, appState: AppState): OnboardingWallet step = OnboardingWalletStep.CreateWallet, ) is OnboardingWalletAction.ResumeBackup -> state.copy(step = OnboardingWalletStep.Backup) - is OnboardingWalletAction.SetArtworkUrl -> { - state.copy(cardArtworkUri = action.artworkUri) + is OnboardingWalletAction.SetPrimaryCardArtworkUrl -> { + state.copy( + walletImages = state.walletImages.copy(primaryCardImage = action.artworkUri), + ) + } + is OnboardingWalletAction.SetSecondCardArtworkUrl -> { + state.copy( + walletImages = state.walletImages.copy(secondCardImage = action.artworkUri), + ) + } + is OnboardingWalletAction.SetThirdCardArtworkUrl -> { + state.copy( + walletImages = state.walletImages.copy(thirdCardImage = action.artworkUri), + ) } is OnboardingWalletAction.Done -> state.copy(step = OnboardingWalletStep.Done) else -> state diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletState.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletState.kt index 6663311df8..853678f202 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletState.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletState.kt @@ -12,7 +12,7 @@ data class OnboardingWalletState( val step: OnboardingWalletStep = OnboardingWalletStep.None, val wallet2State: OnboardingWallet2State? = null, val backupState: BackupState = BackupState(), - val cardArtworkUri: Uri? = null, + val walletImages: WalletImages = WalletImages(), val showConfetti: Boolean = false, val isRingOnboarding: Boolean = false, ) : StateType { @@ -49,6 +49,12 @@ data class OnboardingWalletState( private fun getWallet2Progress(): Int = wallet2State?.maxProgress ?: 0 } +data class WalletImages( + val primaryCardImage: Uri? = null, + val secondCardImage: Uri? = null, + val thirdCardImage: Uri? = null, +) + data class OnboardingWallet2State( val maxProgress: Int, ) diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/OnboardingWalletFragment.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/OnboardingWalletFragment.kt index e0577994f9..fb9acfefd5 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/OnboardingWalletFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/OnboardingWalletFragment.kt @@ -26,6 +26,7 @@ import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic import com.tangem.core.ui.extensions.setStatusBarColor +import com.tangem.datasource.utils.isNullOrEmpty import com.tangem.domain.common.util.cardTypesResolver import com.tangem.feature.onboarding.data.model.CreateWalletResponse import com.tangem.feature.onboarding.presentation.wallet2.analytics.SeedPhraseSource @@ -186,30 +187,27 @@ class OnboardingWalletFragment : when { state.wallet2State != null -> { seedPhraseStateHandler.newState(this, state, seedPhraseViewModel) - state.cardArtworkUri?.let { - seedPhraseViewModel.setCardArtworkUri(it.toString()) - if (state.isRingOnboarding) { - binding.imvFrontCard.load(R.drawable.img_ring_placeholder) - } else { - loadImageIntoImageView(state.cardArtworkUri, binding.imvFrontCard) - } - loadImageIntoImageView(it, binding.imvFirstBackupCard) - loadImageIntoImageView(it, binding.imvSecondBackupCard) - } + updateWalletImagesState(state.walletImages) } else -> { - if (state.isRingOnboarding) { - binding.imvFrontCard.load(R.drawable.img_ring_placeholder) - } else { - loadImageIntoImageView(state.cardArtworkUri, binding.imvFrontCard) - } - loadImageIntoImageView(state.cardArtworkUri, binding.imvFirstBackupCard) - loadImageIntoImageView(state.cardArtworkUri, binding.imvSecondBackupCard) + updateWalletImagesState(state.walletImages) handleOnboardingStep(state) } } } + private fun updateWalletImagesState(walletImages: WalletImages) { + if (!walletImages.primaryCardImage.isNullOrEmpty()) { + loadImageIntoImageView(walletImages.primaryCardImage, binding.imvFrontCard) + } + if (!walletImages.secondCardImage.isNullOrEmpty()) { + loadImageIntoImageView(walletImages.secondCardImage, binding.imvFirstBackupCard) + } + if (!walletImages.thirdCardImage.isNullOrEmpty()) { + loadImageIntoImageView(walletImages.thirdCardImage, binding.imvSecondBackupCard) + } + } + private fun loadImageIntoImageView(uri: Uri?, view: ImageView) { view.load(uri) { placeholder(R.drawable.card_placeholder_black) diff --git a/app/src/main/java/com/tangem/tap/features/saveWallet/ui/components/EnrollBiometricsDialogConent.kt b/app/src/main/java/com/tangem/tap/features/saveWallet/ui/components/EnrollBiometricsDialogConent.kt index 115bd8d273..1f3b36e57d 100644 --- a/app/src/main/java/com/tangem/tap/features/saveWallet/ui/components/EnrollBiometricsDialogConent.kt +++ b/app/src/main/java/com/tangem/tap/features/saveWallet/ui/components/EnrollBiometricsDialogConent.kt @@ -9,9 +9,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R import com.tangem.core.ui.components.BasicDialog -import com.tangem.core.ui.components.DialogButton -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.components.DialogButtonUM import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview import com.tangem.tap.features.saveWallet.ui.models.EnrollBiometricsDialog @Composable @@ -19,11 +19,11 @@ fun EnrollBiometricsDialogContent(dialog: EnrollBiometricsDialog) { BasicDialog( title = stringResource(R.string.save_user_wallet_agreement_enroll_biometrics_title), message = stringResource(R.string.save_user_wallet_agreement_enroll_biometrics_description), - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = stringResource(R.string.common_enable), onClick = dialog.onEnroll, ), - dismissButton = DialogButton( + dismissButton = DialogButtonUM( onClick = dialog.onCancel, ), onDismissDialog = dialog.onCancel, diff --git a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt index cfb77e6180..1abdf861b9 100644 --- a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt @@ -5,7 +5,6 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.Analytics -import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.NetworkAddress @@ -48,12 +47,6 @@ object TradeCryptoMiddleware { is TradeCryptoAction.FinishSelling -> openReceiptUrl(action.transactionId) is TradeCryptoAction.Buy -> proceedBuyAction(state, action) is TradeCryptoAction.Sell -> proceedSellAction(action) - is TradeCryptoAction.Swap -> openSwap(currency = action.cryptoCurrency) - is TradeCryptoAction.Stake -> openStaking( - userWalletId = action.userWalletId, - cryptoCurrencyId = action.cryptoCurrencyId, - yield = action.yield, - ) is TradeCryptoAction.SendToken -> handleNewSendToken(action = action) is TradeCryptoAction.SendCoin -> handleNewSendCoin(action = action) } @@ -141,22 +134,6 @@ object TradeCryptoMiddleware { )?.let { store.dispatchOpenUrl(it) } } - private fun openSwap(currency: CryptoCurrency) { - store.dispatchNavigationAction { push(AppRoute.Swap(currency = currency)) } - } - - private fun openStaking(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID, yield: Yield) { - store.dispatchNavigationAction { - push( - AppRoute.Staking( - userWalletId = userWalletId, - cryptoCurrencyId = cryptoCurrencyId, - yield = yield, - ), - ) - } - } - private fun handleNewSendToken(action: TradeCryptoAction.SendToken) { handleNewSend( userWalletId = action.userWallet.walletId, diff --git a/app/src/main/java/com/tangem/tap/features/welcome/ui/components/WarningDialog.kt b/app/src/main/java/com/tangem/tap/features/welcome/ui/components/WarningDialog.kt index a6315d5b1e..57f226a9d7 100644 --- a/app/src/main/java/com/tangem/tap/features/welcome/ui/components/WarningDialog.kt +++ b/app/src/main/java/com/tangem/tap/features/welcome/ui/components/WarningDialog.kt @@ -7,7 +7,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.components.BasicDialog -import com.tangem.core.ui.components.DialogButton +import com.tangem.core.ui.components.DialogButtonUM import com.tangem.core.ui.res.TangemThemePreview import com.tangem.tap.features.welcome.ui.model.WarningModel import com.tangem.wallet.R @@ -27,7 +27,7 @@ internal fun WarningDialog(warning: WarningModel?) { }, ), onDismissDialog = warning.onDismiss, - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = stringResource(id = R.string.common_ok), onClick = warning.onDismiss, ), @@ -38,7 +38,7 @@ internal fun WarningDialog(warning: WarningModel?) { title = stringResource(id = R.string.common_attention), message = stringResource(id = R.string.key_invalidated_warning_description), onDismissDialog = warning.onDismiss, - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = stringResource(id = R.string.common_ok), onClick = warning.onDismiss, ), @@ -50,7 +50,7 @@ internal fun WarningDialog(warning: WarningModel?) { message = stringResource(id = R.string.biometric_unavailable_warning), onDismissDialog = warning.onDismiss, isDismissable = false, - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = stringResource(id = R.string.common_ok), onClick = warning.onDismiss, ), 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 af1bebc286..6d8ea8f746 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 @@ -11,6 +11,7 @@ import com.tangem.features.details.component.DetailsComponent import com.tangem.features.disclaimer.api.components.DisclaimerComponent import com.tangem.features.managetokens.ManageTokensToggles import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.markets.details.MarketsTokenDetailsComponent import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles import com.tangem.features.pushnotifications.api.navigation.PushNotificationsRouter import com.tangem.features.send.api.navigation.SendRouter @@ -50,6 +51,7 @@ internal class ChildFactory @Inject constructor( private val walletSettingsComponentFactory: WalletSettingsComponent.Factory, private val disclaimerComponentFactory: DisclaimerComponent.Factory, private val manageTokensComponentFactory: ManageTokensComponent.Factory, + private val marketsTokenDetailsComponentFactory: MarketsTokenDetailsComponent.Factory, private val sendRouter: SendRouter, private val tokenDetailsRouter: TokenDetailsRouter, private val walletRouter: WalletRouter, @@ -126,13 +128,7 @@ internal class ChildFactory @Inject constructor( if (manageTokensToggles.isFeatureEnabled) { route.asComponentChild( contextProvider = contextProvider(route, contextFactory), - params = ManageTokensComponent.Params( - mode = if (route.readOnlyContent) { - ManageTokensComponent.Mode.READ_ONLY - } else { - ManageTokensComponent.Mode.MANAGE - }, - ), + params = ManageTokensComponent.Params(route.userWalletId), componentFactory = manageTokensComponentFactory, ) } else { @@ -191,6 +187,23 @@ internal class ChildFactory @Inject constructor( componentFactory = walletSettingsComponentFactory, ) } + is AppRoute.MarketsTokenDetails -> { + route.asComponentChild( + contextProvider = contextProvider(route, contextFactory), + params = MarketsTokenDetailsComponent.Params( + token = route.token, + appCurrency = route.appCurrency, + showPortfolio = route.showPortfolio, + analyticsParams = route.analyticsParams?.let { + MarketsTokenDetailsComponent.AnalyticsParams( + blockchain = it.blockchain, + source = it.source, + ) + }, + ), + componentFactory = marketsTokenDetailsComponentFactory, + ) + } } } diff --git a/app/src/main/res/layout/layout_receipt_total.xml b/app/src/main/res/layout/layout_receipt_total.xml deleted file mode 100644 index 5ff5d3377c..0000000000 --- a/app/src/main/res/layout/layout_receipt_total.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt b/app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt index 5ecd65378b..a9e93345cc 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt +++ b/app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt @@ -1,8 +1,12 @@ package com.tangem.tap.domain.card import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.FeePaidCurrency import com.tangem.blockchain.common.Token +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.data.common.currency.CryptoCurrencyFactory +import com.tangem.data.common.currency.getNetworkDerivationPath +import com.tangem.data.common.currency.getNetworkStandardType import com.tangem.domain.common.util.derivationStyleProvider import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrency @@ -40,11 +44,23 @@ internal class CryptoCurrenciesMocks(private val scanResponse: ScanResponse) { } private fun createCoin(blockchain: Blockchain): CryptoCurrency { - return factory.createCoin( - blockchain = blockchain, - extraDerivationPath = null, - derivationStyleProvider = scanResponse.derivationStyleProvider, - )!! + val network = Network( + id = Network.ID(blockchain.id), + backendId = blockchain.toNetworkId(), + name = blockchain.getNetworkName(), + isTestnet = blockchain.isTestnet(), + derivationPath = getNetworkDerivationPath( + blockchain, + extraDerivationPath = null, + scanResponse.derivationStyleProvider, + ), + currencySymbol = blockchain.currency, + standardType = getNetworkStandardType(blockchain), + hasFiatFeeRate = blockchain.feePaidCurrency() !is FeePaidCurrency.FeeResource, + canHandleTokens = false, + ) + + return factory.createCoin(network = network) } // Impossible to create custom token by CryptoCurrencyFactory because it works with URI under the hood @@ -68,6 +84,7 @@ internal class CryptoCurrenciesMocks(private val scanResponse: ScanResponse) { isTestnet = false, standardType = Network.StandardType.ERC20, hasFiatFeeRate = true, + canHandleTokens = true, ), name = "NEVER-MIND", symbol = "NEVER-MIND", diff --git a/common/routing/build.gradle.kts b/common/routing/build.gradle.kts index a492ede7aa..71933a08b2 100644 --- a/common/routing/build.gradle.kts +++ b/common/routing/build.gradle.kts @@ -19,6 +19,8 @@ dependencies { implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) implementation(projects.domain.staking.models) + implementation(projects.domain.markets.models) + implementation(projects.domain.appCurrency.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 6c03778967..0d8cee2df5 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 @@ -5,6 +5,8 @@ import com.tangem.common.routing.bundle.RouteBundleParams import com.tangem.common.routing.bundle.bundle import com.tangem.common.routing.entity.SerializableIntent import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.tokens.model.CryptoCurrency @@ -177,8 +179,8 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class ManageTokens( - val readOnlyContent: Boolean, - ) : AppRoute(path = "/manage_tokens/$readOnlyContent"), RouteBundleParams { + val userWalletId: UserWalletId? = null, + ) : AppRoute(path = "/manage_tokens/$userWalletId"), RouteBundleParams { override fun getBundle(): Bundle = bundle(serializer()) } @@ -217,12 +219,14 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class Swap( val currency: CryptoCurrency, - ) : AppRoute(path = "/swap/${currency.id.value}"), RouteBundleParams { + val userWalletId: UserWalletId, + ) : AppRoute(path = "/swap/${currency.id.value}/${userWalletId.stringValue}"), RouteBundleParams { override fun getBundle(): Bundle = bundle(serializer()) companion object { const val CURRENCY_BUNDLE_KEY = "currency" + const val USER_WALLET_ID_KEY = "userWalletId" } } @@ -262,4 +266,19 @@ sealed class AppRoute(val path: String) : Route { data class WalletSettings( val userWalletId: UserWalletId, ) : AppRoute(path = "/wallet_settings/${userWalletId.stringValue}") + + @Serializable + data class MarketsTokenDetails( + val token: TokenMarketParams, + val appCurrency: AppCurrency, + val showPortfolio: Boolean, + val analyticsParams: AnalyticsParams? = null, + ) : AppRoute(path = "/markets_token_details/${token.id}/$showPortfolio") { + + @Serializable + data class AnalyticsParams( + val blockchain: String?, + val source: String, + ) + } } \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/utils/RouterProxy.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/utils/RouterProxy.kt new file mode 100644 index 0000000000..0761de97a5 --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/utils/RouterProxy.kt @@ -0,0 +1,51 @@ +package com.tangem.common.routing.utils + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.core.decompose.navigation.Route +import com.tangem.core.decompose.navigation.Router +import kotlin.reflect.KClass + +/** + * Temporary solution to convert [AppRouter] to [Router]. + + * (through manual ComponentContext creation). + * + * **Will be removed when all screens will be migrated to Decompose.** + * + * @return [Router] that wraps [AppRouter]. + */ +fun AppRouter.asRouter(): Router { + return RouterProxy(appRouter = this) +} + +private class RouterProxy( + private val appRouter: AppRouter, +) : Router { + override fun push(route: Route, onComplete: (isSuccess: Boolean) -> Unit) { + if (route is AppRoute) { + appRouter.push(route, onComplete) + } + } + + override fun replaceAll(vararg routes: Route, onComplete: (isSuccess: Boolean) -> Unit) { + routes.filterIsInstance().let { + appRouter.replaceAll(*it.toTypedArray(), onComplete = onComplete) + } + } + + override fun pop(onComplete: (isSuccess: Boolean) -> Unit) { + appRouter.pop(onComplete) + } + + override fun popTo(route: Route, onComplete: (isSuccess: Boolean) -> Unit) { + if (route is AppRoute) { + appRouter.popTo(route, onComplete) + } + } + + @Suppress("UNCHECKED_CAST") + override fun popTo(routeClass: KClass, onComplete: (isSuccess: Boolean) -> Unit) { + appRouter.popTo(routeClass as KClass, onComplete) + } +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt index cd14d8f8c8..992380b05e 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt @@ -125,6 +125,8 @@ fun MarketChart( // Sometimes the chart is not drawn correctly (ex. in LazyLayout), so we need to force the redraw .drawBehind { state.markerFraction + state.chartColor + state.markerHighlightRightSide }, chart = chart, modelProducer = state.modelProducer, diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/Utils.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/Utils.kt new file mode 100644 index 0000000000..558f63dbde --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/Utils.kt @@ -0,0 +1,13 @@ +package com.tangem.common.ui.charts.state + +import kotlinx.collections.immutable.toImmutableList + +fun MarketChartData.Data.sorted(): MarketChartData.Data { + val points = this.x.zip(this.y).sortedBy { it.first } + val (x, y) = points.unzip() + + return MarketChartData.Data( + x = x.toImmutableList(), + y = y.toImmutableList(), + ) +} \ No newline at end of file diff --git a/common/ui/build.gradle.kts b/common/ui/build.gradle.kts index e57cd07bd1..315574334e 100644 --- a/common/ui/build.gradle.kts +++ b/common/ui/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(deps.compose.ui.tooling) implementation(deps.compose.navigation) implementation(deps.compose.navigation.hilt) + implementation(deps.compose.coil) /** Deps */ implementation(deps.kotlin.immutable.collections) @@ -27,9 +28,14 @@ dependencies { implementation(projects.core.utils) /** Project - Domain */ - implementation(projects.domain.tokens.models) - implementation(projects.domain.wallets.models) implementation(projects.domain.appCurrency.models) + implementation(projects.domain.legacy) + implementation(projects.domain.staking.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.transaction.models) + implementation(projects.domain.wallets.models) + + implementation(deps.tangem.card.core) implementation(deps.tangem.blockchain) { exclude(module = "joda-time") } diff --git a/common/ui/src/main/java/com/tangem/common/ui/alerts/SendTransactionAlertConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/alerts/SendTransactionAlertConverter.kt new file mode 100644 index 0000000000..2cb03f4f16 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/alerts/SendTransactionAlertConverter.kt @@ -0,0 +1,49 @@ +package com.tangem.common.ui.alerts + +import com.tangem.common.ui.alerts.models.AlertDemoModeUM +import com.tangem.common.ui.alerts.models.AlertTransactionErrorUM +import com.tangem.common.ui.alerts.models.AlertUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.utils.converter.Converter + +class SendTransactionAlertConverter( + private val popBackStack: () -> Unit, + private val onFailedTxEmailClick: (String) -> Unit, +) : Converter { + override fun convert(value: SendTransactionError): AlertUM? { + return when (value) { + is SendTransactionError.DemoCardError -> AlertDemoModeUM( + onConfirmClick = popBackStack, + ) + is SendTransactionError.TangemSdkError -> AlertTransactionErrorUM( + code = value.code.toString(), + cause = null, + causeTextReference = resourceReference(value.messageRes, wrappedList(value.args)), + onConfirmClick = { onFailedTxEmailClick(value.code.toString()) }, + ) + is SendTransactionError.BlockchainSdkError -> AlertTransactionErrorUM( + code = value.code.toString(), + cause = value.message, + onConfirmClick = { onFailedTxEmailClick("${value.code}: ${value.message.orEmpty()}") }, + ) + is SendTransactionError.DataError -> AlertTransactionErrorUM( + code = "", + cause = value.message, + onConfirmClick = { onFailedTxEmailClick(value.message.orEmpty()) }, + ) + is SendTransactionError.NetworkError -> AlertTransactionErrorUM( + code = value.code.orEmpty(), + cause = value.message.orEmpty(), + onConfirmClick = { onFailedTxEmailClick(value.message.orEmpty()) }, + ) + is SendTransactionError.UnknownError -> AlertTransactionErrorUM( + code = "", + cause = value.ex?.localizedMessage, + onConfirmClick = { onFailedTxEmailClick(value.ex?.localizedMessage.orEmpty()) }, + ) + else -> null + } + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/alerts/models/AlertDemoModeUM.kt b/common/ui/src/main/java/com/tangem/common/ui/alerts/models/AlertDemoModeUM.kt new file mode 100644 index 0000000000..e63ccb0229 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/alerts/models/AlertDemoModeUM.kt @@ -0,0 +1,13 @@ +package com.tangem.common.ui.alerts.models + +import com.tangem.common.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference + +data class AlertDemoModeUM( + override val onConfirmClick: () -> Unit, +) : AlertUM { + override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok) + override val title: TextReference = resourceReference(id = R.string.warning_demo_mode_title) + override val message: TextReference = resourceReference(id = R.string.warning_demo_mode_message) +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/alerts/models/AlertTransactionErrorUM.kt b/common/ui/src/main/java/com/tangem/common/ui/alerts/models/AlertTransactionErrorUM.kt new file mode 100644 index 0000000000..5fd83163be --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/alerts/models/AlertTransactionErrorUM.kt @@ -0,0 +1,21 @@ +package com.tangem.common.ui.alerts.models + +import com.tangem.common.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList + +data class AlertTransactionErrorUM( + val code: String, + val cause: String?, + val causeTextReference: TextReference? = null, + override val onConfirmClick: (() -> Unit)? = null, +) : AlertUM { + override val title: TextReference = resourceReference(id = R.string.send_alert_transaction_failed_title) + override val message: TextReference = resourceReference( + id = R.string.send_alert_transaction_failed_text, + formatArgs = wrappedList(causeTextReference ?: cause.orEmpty(), code), + ) + override val confirmButtonText: TextReference = + resourceReference(id = R.string.common_support) +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/alerts/models/AlertUM.kt b/common/ui/src/main/java/com/tangem/common/ui/alerts/models/AlertUM.kt new file mode 100644 index 0000000000..1cf955bebe --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/alerts/models/AlertUM.kt @@ -0,0 +1,12 @@ +package com.tangem.common.ui.alerts.models + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference + +@Immutable +interface AlertUM { + val title: TextReference? + val message: TextReference + val confirmButtonText: TextReference + val onConfirmClick: (() -> Unit)? +} \ 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 64615bc792..cfd16d8ee2 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 @@ -1,7 +1,6 @@ package com.tangem.common.ui.amountScreen import android.content.res.Configuration -import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable @@ -20,24 +19,28 @@ import com.tangem.core.ui.res.TangemThemePreview /** * Amount screen with field * @param amountState amount state - * @param isBalanceHiding flag hidden balances + * @param isBalanceHidden flag hidden balances * @param clickIntents amount screen clicks */ @Composable -fun AmountScreenContent(amountState: AmountState, isBalanceHiding: Boolean, clickIntents: AmountScreenClickIntents) { +fun AmountScreenContent( + amountState: AmountState, + isBalanceHidden: Boolean, + clickIntents: AmountScreenClickIntents, + modifier: Modifier = Modifier, +) { if (amountState !is AmountState.Data) return // Do not put fillMaxSize() in here LazyColumn( - modifier = Modifier - .background(TangemTheme.colors.background.tertiary) + modifier = modifier .padding( start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing16, bottom = TangemTheme.dimens.spacing16, ), ) { - amountField(amountState = amountState, isBalanceHiding = isBalanceHiding) + amountField(amountState = amountState, isBalanceHidden = isBalanceHidden) buttons( segmentedButtonConfig = amountState.segmentedButtonConfig, clickIntents = clickIntents, @@ -57,7 +60,7 @@ private fun SendAmountContentPreview( TangemThemePreview { AmountScreenContent( amountState = amountState, - isBalanceHiding = false, + isBalanceHidden = false, clickIntents = AmountScreenClickIntentsStub, ) } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt index d45e7c07bd..8de99b1fc5 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt @@ -1,6 +1,7 @@ package com.tangem.common.ui.amountScreen.converters.field import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountState @@ -8,6 +9,7 @@ import com.tangem.common.ui.amountScreen.utils.checkExceedBalance import com.tangem.common.ui.amountScreen.utils.getCryptoValue import com.tangem.common.ui.amountScreen.utils.getFiatValue import com.tangem.common.ui.amountScreen.utils.getKeyboardAction +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.tokens.model.CryptoCurrencyStatus @@ -61,7 +63,8 @@ class AmountFieldChangeTransformer( value = cryptoValue, fiatValue = fiatValue, isError = isExceedBalance, - error = resourceReference(R.string.send_validation_amount_exceeds_balance), + error = resourceReference(R.string.send_validation_amount_exceeds_balance).takeIf { isExceedBalance } + ?: TextReference.EMPTY, cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue), fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue), keyboardOptions = KeyboardOptions( @@ -81,6 +84,10 @@ class AmountFieldChangeTransformer( cryptoAmount = amountTextField.cryptoAmount.copy(value = BigDecimal.ZERO), fiatAmount = amountTextField.fiatAmount.copy(value = BigDecimal.ZERO), isError = false, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.None, + keyboardType = KeyboardType.Number, + ), ), ) } 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 bfe5259127..d460c0ca5d 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 @@ -4,7 +4,6 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.common.ui.amountScreen.models.AmountFieldModel import com.tangem.core.ui.extensions.TextReference @@ -62,7 +61,8 @@ class AmountFieldConverter( cryptoAmount = cryptoAmount, fiatAmount = getAppCurrencyAmount(fiatDecimal, appCurrencyProvider()), isError = false, - error = TextReference.Res(R.string.send_validation_amount_exceeds_balance), + isWarning = false, + error = TextReference.EMPTY, isFiatUnavailable = fiatRate == null, isValuePasted = false, onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountFieldModel.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountFieldModel.kt index 572239504d..995852df94 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountFieldModel.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountFieldModel.kt @@ -35,5 +35,6 @@ data class AmountFieldModel( val isValuePasted: Boolean, val onValuePastedTriggerDismiss: () -> Unit, val isError: Boolean, + val isWarning: Boolean, val error: TextReference, ) \ 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 91951b4220..a622f752c9 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 @@ -53,6 +53,7 @@ object AmountStatePreviewData { fiatValue = "123.123", isFiatUnavailable = false, isError = false, + isWarning = false, error = TextReference.EMPTY, isValuePasted = false, onValuePastedTriggerDismiss = {}, 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 index f190cea1db..d8347d67af 100644 --- 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 @@ -4,7 +4,10 @@ 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.* +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 @@ -112,6 +115,7 @@ private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: Stri ) AmountFieldError( isError = amountField.isError, + isWarning = amountField.isWarning, error = amountField.error, modifier = Modifier .align(BottomCenter) @@ -124,17 +128,24 @@ private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: Stri } @Composable -private fun AmountFieldError(isError: Boolean, error: TextReference, modifier: Modifier = Modifier) { +private fun AmountFieldError( + isError: Boolean, + isWarning: Boolean, + error: TextReference, + modifier: Modifier = Modifier, +) { AnimatedVisibility( - visible = isError, + visible = isError || isWarning, enter = fadeIn(), exit = fadeOut(), modifier = modifier, ) { + val errorText = remember(this) { error } + val color = if (isError) TangemTheme.colors.text.warning else TangemTheme.colors.text.attention Text( - text = error.resolveReference(), + text = errorText.resolveReference(), style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.warning, + color = color, textAlign = TextAlign.Center, ) } 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 146c491074..caee01e48c 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 @@ -14,15 +14,15 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextAlign import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.utils.StringsSigns.STARS private const val AMOUNT_FIELD_KEY = "amountFieldKey" internal fun LazyListScope.amountField( amountState: AmountState.Data, - isBalanceHiding: Boolean, + isBalanceHidden: Boolean, modifier: Modifier = Modifier, ) { item(key = AMOUNT_FIELD_KEY) { @@ -41,7 +41,7 @@ internal fun LazyListScope.amountField( .padding(top = TangemTheme.dimens.spacing14), ) - val balance = if (isBalanceHiding) STARS else amountState.walletBalance.resolveReference() + val balance = amountState.walletBalance.orMaskWithStars(isBalanceHidden).resolveReference() AnimatedContent( targetState = balance, label = "Hide Balance Animation", diff --git a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt index 73c62d311c..52398bd178 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -59,7 +58,7 @@ fun GiveTxPermissionBottomSheet(config: TangemBottomSheetConfig) { BasicDialog( message = content.data.dialogText.resolveReference(), title = stringResource(id = R.string.common_approve), - confirmButton = DialogButton { isPermissionAlertShow = false }, + confirmButton = DialogButtonUM { isPermissionAlertShow = false }, onDismissDialog = {}, ) } @@ -166,7 +165,7 @@ private fun AmountItem( .clickable( enabled = onChangeApproveType != null, interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(), + indication = ripple(), onClick = { isExpandSelector = true }, ), ) { 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 8a9ccffd1e..8cb08d87c0 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 @@ -2,27 +2,32 @@ package com.tangem.common.ui.navigationButtons import android.content.res.Configuration import androidx.compose.animation.* +import androidx.compose.animation.core.tween 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.Icon -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.material3.Text +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.vectorResource +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 com.tangem.common.ui.navigationButtons.preview.NavigationButtonsPreview +import com.tangem.core.ui.components.Keyboard import com.tangem.core.ui.components.buttons.common.TangemButton import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults +import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.isNullOrEmpty import com.tangem.core.ui.extensions.rememberHapticFeedback import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme @@ -30,39 +35,34 @@ import com.tangem.core.ui.res.TangemThemePreview import kotlinx.collections.immutable.ImmutableList @Composable -fun NavigationButtonsBlock(buttonState: NavigationButtonsState, modifier: Modifier = Modifier) { +fun NavigationButtonsBlock( + buttonState: NavigationButtonsState, + modifier: Modifier = Modifier, + footerText: TextReference? = null, +) { val state = buttonState as? NavigationButtonsState.Data Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.fillMaxWidth(), ) { + InfoText(footerText) ExtraButtons(state?.extraButtons, state?.txUrl) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { PreviousButton(state?.prevButton) - PrimaryButton(state?.primaryButton, modifier = Modifier.weight(1f)) + NavigationPrimaryButton(state?.primaryButton, modifier = Modifier.weight(1f)) } - - SecondaryButton(state?.secondaryButton) } } @Composable -private fun PrimaryButton(primaryButton: NavigationButton?, modifier: Modifier = Modifier) { +fun NavigationPrimaryButton(primaryButton: NavigationButton?, modifier: Modifier = Modifier) { + val wrappedButton by rememberNavigationButton(primaryButton) AnimatedContent( - targetState = primaryButton, - transitionSpec = { - val isPrimaryToHide = targetState != null && initialState == null - val isPrimaryWasVisible = targetState == null && initialState != null - if (isPrimaryToHide || isPrimaryWasVisible) { - slideInVertically(initialOffsetY = { it / 2 }).plus(fadeIn()) - .togetherWith(slideOutVertically(targetOffsetY = { it / 2 }).plus(fadeOut())) - } else { - fadeIn().togetherWith(fadeOut()) - } - }, + targetState = wrappedButton, + transitionSpec = { navigationButtonsTransition() }, contentAlignment = Alignment.Center, label = "Animate show primary button", modifier = modifier.fillMaxWidth(), @@ -88,43 +88,6 @@ private fun PrimaryButton(primaryButton: NavigationButton?, modifier: Modifier = } } -@Composable -private fun SecondaryButton(secondaryButton: NavigationButton?) { - AnimatedContent( - targetState = secondaryButton, - transitionSpec = { - val isPrimaryToHide = targetState != null && initialState == null - val isPrimaryWasVisible = targetState == null && initialState != null - if (isPrimaryToHide || isPrimaryWasVisible) { - slideInVertically(initialOffsetY = { it / 2 }).plus(fadeIn()) - .togetherWith(slideOutVertically(targetOffsetY = { it / 2 }).plus(fadeOut())) - } else { - fadeIn().togetherWith(fadeOut()) - } - }, - contentAlignment = Alignment.Center, - label = "Animate show secondary button", - modifier = Modifier.fillMaxWidth(), - ) { button -> - if (button != null && button.textReference != TextReference.EMPTY) { - val icon = button.iconRes?.let { TangemButtonIconPosition.End(iconResId = it) } - ?: TangemButtonIconPosition.None - - TangemButton( - text = button.textReference.resolveReference(), - enabled = button.isEnabled, - onClick = button.onClick, - icon = icon, - showProgress = button.showProgress, - colors = TangemButtonsDefaults.secondaryButtonColors, - modifier = Modifier.padding(top = TangemTheme.dimens.spacing12), - ) - } else { - Spacer(modifier = Modifier.fillMaxWidth()) - } - } -} - @Composable private fun PreviousButton(prevButton: NavigationButton?) { AnimatedVisibility( @@ -182,6 +145,61 @@ private fun ExtraButtons(extraButtons: ImmutableList?, txUrl: } } +@Composable +private fun InfoText(footerText: TextReference?, modifier: Modifier = Modifier) { + var isVisibleProxy by remember { mutableStateOf(!footerText.isNullOrEmpty()) } + val keyboard by keyboardAsState() + + // the text should appear when the keyboard is closed + LaunchedEffect(footerText, keyboard) { + if (footerText.isNullOrEmpty() && keyboard is Keyboard.Opened) { + return@LaunchedEffect + } + isVisibleProxy = !footerText.isNullOrEmpty() + } + + AnimatedVisibility( + visible = isVisibleProxy, + modifier = modifier, + enter = slideInVertically() + fadeIn(), + exit = fadeOut(tween(durationMillis = 300)), + label = "Animate footer text appearance", + ) { + val text = remember(this) { requireNotNull(footerText) } + Text( + text = text.resolveReference(), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption2, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = TangemTheme.dimens.spacing12), + ) + } +} + +@Composable +private fun rememberNavigationButton(button: NavigationButton?): MutableState { + return remember( + button?.iconRes, + button?.isIconVisible, + button?.isEnabled, + button?.showProgress, + button?.textReference, + ) { mutableStateOf(button) } +} + +private fun AnimatedContentTransitionScope.navigationButtonsTransition(): ContentTransform { + val isPrimaryToHide = targetState != null && initialState == null + val isPrimaryWasVisible = targetState == null && initialState != null + return if (isPrimaryToHide || isPrimaryWasVisible) { + slideInVertically(initialOffsetY = { it / 2 }).plus(fadeIn()) + .togetherWith(slideOutVertically(targetOffsetY = { it / 2 }).plus(fadeOut())) + } else { + fadeIn().togetherWith(fadeOut()) + } +} + // region Preview @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) 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 c125a871d1..cb7a3743ec 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 @@ -10,9 +10,9 @@ sealed class NavigationButtonsState { data class Data( val primaryButton: NavigationButton, val prevButton: NavigationButton?, - val secondaryButton: NavigationButton?, val extraButtons: ImmutableList, val txUrl: String? = null, + val onTextClick: (String) -> Unit, ) : NavigationButtonsState() } 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 738598de82..9d2e71fe85 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 @@ -30,14 +30,6 @@ internal object NavigationButtonsPreview { ), ) - private val next = NavigationButton( - textReference = resourceReference(R.string.common_next), - isSecondary = false, - isIconVisible = false, - showProgress = false, - isEnabled = true, - onClick = {}, - ) private val prev = NavigationButton( textReference = TextReference.EMPTY, iconRes = R.drawable.ic_back_24, @@ -60,8 +52,8 @@ internal object NavigationButtonsPreview { val allButtons = NavigationButtonsState.Data( primaryButton = finished, prevButton = prev, - secondaryButton = next, extraButtons = extraButtons, txUrl = "https://tangem.com", + onTextClick = {}, ) } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt new file mode 100644 index 0000000000..accc615136 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt @@ -0,0 +1,302 @@ +package com.tangem.common.ui.notifications + +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.ui.R +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.wrappedList +import com.tangem.core.ui.utils.BigDecimalFormatter +import java.math.BigDecimal + +sealed class NotificationUM(val config: NotificationConfig) { + + open class Error( + title: TextReference, + subtitle: TextReference, + iconResId: Int = R.drawable.ic_alert_24, + buttonState: NotificationConfig.ButtonsState? = null, + onCloseClick: (() -> Unit)? = null, + ) : NotificationUM( + config = NotificationConfig( + title = title, + subtitle = subtitle, + iconResId = iconResId, + buttonsState = buttonState, + onCloseClick = onCloseClick, + ), + ) { + + data object TotalExceedsBalance : Error( + title = resourceReference(R.string.send_notification_exceed_balance_title), + subtitle = resourceReference(R.string.send_notification_exceed_balance_text), + ) + + data object InvalidAmount : Error( + title = resourceReference(R.string.send_notification_invalid_amount_title), + subtitle = resourceReference(R.string.send_notification_invalid_amount_text), + ) + + data class MinimumAmountError(val amount: String) : Error( + title = resourceReference(R.string.send_notification_invalid_amount_title), + subtitle = resourceReference( + R.string.send_notification_invalid_minimum_amount_text, + wrappedList(amount, amount), + ), + ) + + data class TransactionLimitError( + val cryptoCurrency: String, + val utxoLimit: String, + val amountLimit: String, + val onConfirmClick: () -> Unit, + ) : Error( + title = resourceReference(R.string.send_notification_transaction_limit_title), + subtitle = resourceReference( + R.string.send_notification_transaction_limit_text, + wrappedList(cryptoCurrency, utxoLimit, amountLimit), + ), + buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig( + text = resourceReference(R.string.send_notification_leave_button, wrappedList(amountLimit)), + onClick = onConfirmClick, + ), + ) + + data class TokenExceedsBalance( + val networkIconId: Int, + val currencyName: String, + val feeName: String, + val feeSymbol: String, + val networkName: String, + val mergeFeeNetworkName: Boolean = false, + val onClick: (() -> Unit)? = null, + ) : Error( + title = resourceReference( + id = R.string.warning_send_blocked_funds_for_fee_title, + wrappedList(feeName), + ), + subtitle = resourceReference( + id = R.string.warning_send_blocked_funds_for_fee_message, + formatArgs = wrappedList(currencyName, networkName, currencyName, feeName, feeSymbol), + ), + iconResId = networkIconId, + buttonState = onClick?.let { + NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference( + R.string.common_buy_currency, + wrappedList( + if (mergeFeeNetworkName) { + "$currencyName ($feeSymbol)" + } else { + feeName + }, + ), + ), + onClick = onClick, + ) + }, + ) + + data class ExceedsBalance( + val networkIconId: Int, + val currencyName: String, + val feeName: String, + val feeSymbol: String, + val networkName: String, + val mergeFeeNetworkName: Boolean = false, + val onClick: (() -> Unit)? = null, + ) : Error( + title = resourceReference( + id = R.string.warning_blocked_funds_for_fee_title, + wrappedList(feeName), + ), + subtitle = resourceReference( + id = R.string.warning_blocked_funds_for_fee_message, + formatArgs = wrappedList(currencyName), + ), + iconResId = networkIconId, + buttonState = onClick?.let { + NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference( + R.string.common_buy_currency, + wrappedList( + if (mergeFeeNetworkName) { + "$currencyName ($feeSymbol)" + } else { + feeName + }, + ), + ), + onClick = onClick, + ) + }, + ) + + data class ExistentialDeposit(val deposit: String, val onConfirmClick: () -> Unit) : Error( + title = resourceReference(R.string.send_notification_existential_deposit_title), + subtitle = resourceReference(R.string.send_notification_existential_deposit_text, wrappedList(deposit)), + buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig( + text = resourceReference(R.string.send_notification_leave_button, wrappedList(deposit)), + onClick = onConfirmClick, + ), + ) + + data class ReserveAmount(val amount: String) : Error( + title = resourceReference( + id = R.string.send_notification_invalid_reserve_amount_title, + wrappedList(amount), + ), + subtitle = resourceReference(id = R.string.send_notification_invalid_reserve_amount_text), + ) + } + + open class Warning( + title: TextReference, + subtitle: TextReference, + iconResId: Int = R.drawable.img_attention_20, + buttonsState: NotificationConfig.ButtonsState? = null, + onCloseClick: (() -> Unit)? = null, + ) : NotificationUM( + config = NotificationConfig( + title = title, + subtitle = subtitle, + iconResId = iconResId, + buttonsState = buttonsState, + onCloseClick = onCloseClick, + ), + ) { + data class HighFeeError( + val currencyName: String, + val amount: String, + val onConfirmClick: () -> Unit, + val onCloseClick: () -> Unit, + ) : Warning( + title = resourceReference(R.string.send_notification_high_fee_title), + subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(currencyName, amount)), + buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig( + text = resourceReference(R.string.send_notification_reduce_by, wrappedList(amount)), + onClick = onConfirmClick, + ), + onCloseClick = onCloseClick, + ) + + data object FeeTooLow : Warning( + title = resourceReference(id = R.string.send_notification_transaction_delay_title), + subtitle = resourceReference(id = R.string.send_notification_transaction_delay_text), + ) + + data class TooHigh( + val value: String, + ) : Warning( + title = resourceReference(id = R.string.send_notification_fee_too_high_title), + subtitle = resourceReference(id = R.string.send_notification_fee_too_high_text, wrappedList(value)), + ) + + data class NetworkFeeUnreachable(val onRefresh: () -> Unit) : Warning( + title = resourceReference(R.string.send_fee_unreachable_error_title), + subtitle = resourceReference(R.string.send_fee_unreachable_error_text), + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.warning_button_refresh), + onClick = onRefresh, + ), + ) + + data class TronAccountNotActivated(val tokenName: String) : Warning( + title = resourceReference(R.string.send_fee_unreachable_error_title), + subtitle = resourceReference( + R.string.send_tron_account_activation_error, + wrappedList(tokenName), + ), + ) + + data class FeeCoverageNotification(val cryptoAmount: String, val fiatAmount: String) : Warning( + title = resourceReference(R.string.send_network_fee_warning_title), + subtitle = resourceReference( + R.string.common_network_fee_warning_content, + wrappedList(cryptoAmount, fiatAmount), + ), + ) + } + + open class Info( + title: TextReference, + subtitle: TextReference, + iconResId: Int = R.drawable.ic_alert_circle_24, + buttonsState: NotificationConfig.ButtonsState? = null, + onCloseClick: (() -> Unit)? = null, + ) : NotificationUM( + config = NotificationConfig( + title = title, + subtitle = subtitle, + iconResId = iconResId, + buttonsState = buttonsState, + onCloseClick = onCloseClick, + ), + ) + + sealed interface Cardano { + + data class MinAdaValueCharged(val tokenName: String, val minAdaValue: String) : Warning( + title = resourceReference(id = R.string.cardano_coin_will_be_send_with_token_title), + subtitle = resourceReference( + id = R.string.cardano_coin_will_be_send_with_token_description, + formatArgs = wrappedList(minAdaValue, tokenName), + ), + ) + + data object InsufficientBalanceToTransferCoin : Error( + title = resourceReference(id = R.string.cardano_max_amount_has_token_title), + subtitle = resourceReference(id = R.string.cardano_max_amount_has_token_description), + ) + + data class InsufficientBalanceToTransferToken(val tokenName: String) : Error( + title = resourceReference(id = R.string.cardano_insufficient_balance_to_send_token_title), + subtitle = resourceReference( + id = R.string.cardano_insufficient_balance_to_send_token_description, + formatArgs = wrappedList(tokenName), + ), + ) + } + + sealed interface Koinos { + data class InsufficientRecoverableMana( + val mana: BigDecimal, + val maxMana: BigDecimal, + ) : Error( + title = resourceReference(R.string.koinos_insufficient_mana_to_send_koin_title), + subtitle = resourceReference( + R.string.koinos_insufficient_mana_to_send_koin_description, + formatArgs = wrappedList( + BigDecimalFormatter.formatCryptoAmountShorted(mana, "", Blockchain.Koinos.decimals()), + BigDecimalFormatter.formatCryptoAmountShorted(maxMana, "", Blockchain.Koinos.decimals()), + ), + ), + ) + + data object InsufficientBalance : Error( + title = resourceReference(R.string.koinos_insufficient_balance_to_send_koin_title), + subtitle = resourceReference(R.string.koinos_insufficient_balance_to_send_koin_description), + ) + + data class ManaExceedsBalance( + val availableKoinForTransfer: BigDecimal, + val onReduceClick: () -> Unit, + ) : Error( + title = resourceReference(R.string.koinos_mana_exceeds_koin_balance_title), + subtitle = resourceReference( + R.string.koinos_mana_exceeds_koin_balance_description, + formatArgs = wrappedList( + BigDecimalFormatter.formatCryptoAmount( + availableKoinForTransfer, + Blockchain.Koinos.currency, + Blockchain.Koinos.decimals(), + ), + ), + ), + buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig( + text = resourceReference(R.string.send_notification_reduce_to, wrappedList(availableKoinForTransfer)), + onClick = onReduceClick, + ), + ) + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt new file mode 100644 index 0000000000..37e29e0335 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt @@ -0,0 +1,363 @@ +package com.tangem.common.ui.notifications + +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.R +import com.tangem.common.ui.amountScreen.models.AmountFieldModel +import com.tangem.common.ui.amountScreen.utils.getFiatString +import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.blockchains.UtxoAmountLimit +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.utils.extensions.orZero +import java.math.BigDecimal + +@Suppress("LargeClass") +object NotificationsFactory { + + fun MutableList.addFeeUnreachableNotification( + feeError: GetFeeError, + tokenName: String, + onReload: () -> Unit, + ) { + when (feeError) { + is GetFeeError.BlockchainErrors.TronActivationError -> add( + NotificationUM.Warning.TronAccountNotActivated(tokenName), + ) + is GetFeeError.DataError, + is GetFeeError.UnknownError, + -> add( + NotificationUM.Warning.NetworkFeeUnreachable(onReload), + ) + else -> { + /* do nothing */ + } + } + } + + fun MutableList.addExceedBalanceNotification( + feeAmount: BigDecimal, + sendingAmount: BigDecimal, + isSubtractionAvailable: Boolean, + cryptoCurrencyStatus: CryptoCurrencyStatus, + minimumRequirement: BigDecimal? = null, + ) { + val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO + if (!isSubtractionAvailable) return + + val showNotification = sendingAmount + feeAmount > balance - minimumRequirement.orZero() + if (showNotification) { + add(NotificationUM.Error.TotalExceedsBalance) + } + } + + fun MutableList.addReserveAmountErrorNotification( + reserveAmount: BigDecimal?, + sendingAmount: BigDecimal, + cryptoCurrency: CryptoCurrency, + isAccountFunded: Boolean, + ) { + if (!isAccountFunded && reserveAmount != null && reserveAmount > sendingAmount) { + add( + NotificationUM.Error.ReserveAmount( + BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = sendingAmount, + cryptoCurrency = cryptoCurrency, + ), + ), + ) + } + } + + fun MutableList.addTransactionLimitErrorNotification( + utxoLimit: UtxoAmountLimit?, + cryptoCurrency: CryptoCurrency, + onReduceClick: ( + reduceAmountTo: BigDecimal, + notification: Class, + ) -> Unit, + ) { + if (utxoLimit != null) { + add( + NotificationUM.Error.TransactionLimitError( + cryptoCurrency = cryptoCurrency.name, + utxoLimit = utxoLimit.maxLimit.toPlainString(), + amountLimit = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = utxoLimit.maxAmount, + cryptoCurrency = cryptoCurrency, + ), + onConfirmClick = { + onReduceClick( + utxoLimit.maxAmount, + NotificationUM.Error.TransactionLimitError::class.java, + ) + }, + ), + ) + } + } + + fun MutableList.addExistentialWarningNotification( + existentialDeposit: BigDecimal?, + feeAmount: BigDecimal, + receivedAmount: BigDecimal, + cryptoCurrencyStatus: CryptoCurrencyStatus, + onReduceClick: ( + reduceAmountBy: BigDecimal, + reduceAmountByDiff: BigDecimal, + notification: Class, + ) -> Unit, + ) { + val cryptoCurrency = cryptoCurrencyStatus.currency + val balance = cryptoCurrencyStatus.value.amount ?: return + val spendingAmount = if (cryptoCurrency is CryptoCurrency.Token) { + feeAmount + } else { + receivedAmount + } + val diff = balance.minus(spendingAmount) + if (existentialDeposit != null && diff >= BigDecimal.ZERO && existentialDeposit > diff) { + add( + NotificationUM.Error.ExistentialDeposit( + deposit = BigDecimalFormatter.formatCryptoAmountUncapped( + cryptoAmount = existentialDeposit, + cryptoCurrency = cryptoCurrency, + ), + onConfirmClick = { + onReduceClick( + existentialDeposit, + existentialDeposit.minus(diff), + NotificationUM.Error.ExistentialDeposit::class.java, + ) + }, + ), + ) + } + } + + fun MutableList.addFeeCoverageNotification( + isFeeCoverage: Boolean, + amountField: AmountFieldModel, + sendingValue: BigDecimal, + appCurrency: AppCurrency, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ) { + val cryptoCurrency = cryptoCurrencyStatus.currency + val fiatRate = cryptoCurrencyStatus.value.fiatRate + val amountValue = amountField.cryptoAmount.value ?: return + + val cryptoDiff = amountValue.minus(sendingValue) + if (isFeeCoverage) { + add( + NotificationUM.Warning.FeeCoverageNotification( + cryptoAmount = BigDecimalFormatter.formatCryptoAmountUncapped( + cryptoAmount = cryptoDiff, + cryptoCurrency = cryptoCurrency, + ), + fiatAmount = getFiatString( + value = cryptoDiff, + rate = fiatRate, + appCurrency = appCurrency, + ), + ), + ) + } + } + + fun MutableList.addDustWarningNotification( + dustValue: BigDecimal?, + feeValue: BigDecimal, + sendingAmount: BigDecimal, + cryptoCurrencyStatus: CryptoCurrencyStatus, + feeCurrencyStatus: CryptoCurrencyStatus?, + ) { + if (dustValue == null) return + val isExceedsLimit = checkDustLimits( + feeAmount = feeValue, + receivedAmount = sendingAmount, + dustValue = dustValue, + cryptoCurrencyStatus = cryptoCurrencyStatus, + feeCurrencyStatus = feeCurrencyStatus, + ) + if (isExceedsLimit) { + add( + NotificationUM.Error.MinimumAmountError( + amount = dustValue.parseBigDecimal(cryptoCurrencyStatus.currency.decimals), + ), + ) + } + } + + fun MutableList.addExceedsBalanceNotification( + cryptoCurrencyWarning: CryptoCurrencyWarning?, + cryptoCurrencyStatus: CryptoCurrencyStatus, + shouldMergeFeeNetworkName: Boolean, + onClick: (CryptoCurrency) -> Unit, + onAnalyticsEvent: (CryptoCurrency) -> Unit, + ) { + when (cryptoCurrencyWarning) { + is CryptoCurrencyWarning.BalanceNotEnoughForFee -> { + add( + NotificationUM.Error.TokenExceedsBalance( + networkIconId = cryptoCurrencyWarning.coinCurrency.networkIconResId, + networkName = cryptoCurrencyWarning.coinCurrency.name, + currencyName = cryptoCurrencyStatus.currency.name, + feeName = cryptoCurrencyWarning.coinCurrency.name, + feeSymbol = cryptoCurrencyWarning.coinCurrency.symbol, + mergeFeeNetworkName = shouldMergeFeeNetworkName, + onClick = { + onClick(cryptoCurrencyWarning.coinCurrency) + }, + ), + ) + onAnalyticsEvent(cryptoCurrencyStatus.currency) + } + is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> { + val currency = cryptoCurrencyWarning.feeCurrency + add( + NotificationUM.Error.TokenExceedsBalance( + networkIconId = currency?.networkIconResId ?: R.drawable.ic_alert_24, + currencyName = cryptoCurrencyWarning.currency.name, + feeName = cryptoCurrencyWarning.feeCurrencyName, + feeSymbol = cryptoCurrencyWarning.feeCurrencySymbol, + networkName = cryptoCurrencyWarning.networkName, + mergeFeeNetworkName = shouldMergeFeeNetworkName, + onClick = { + currency?.let { + onClick(currency) + } + }, + ), + ) + onAnalyticsEvent(cryptoCurrencyWarning.currency) + } + else -> Unit + } + } + + fun MutableList.addValidateTransactionNotifications( + dustValue: BigDecimal, + fee: Fee?, + validationError: Throwable?, + cryptoCurrency: CryptoCurrency, + onReduceClick: ( + reduceAmountTo: BigDecimal, + notification: Class, + ) -> Unit, + ) { + when (validationError) { + is BlockchainSdkError.Cardano -> addCardanoTransactionValidationError( + error = validationError, + sendingCurrency = cryptoCurrency, + dustValue = dustValue, + ) + is BlockchainSdkError.Koinos -> addKoinosTransactionValidationError( + error = validationError, + onReduceClick = onReduceClick, + ) + null -> (fee as? Fee.CardanoToken)?.let { + add( + NotificationUM.Cardano.MinAdaValueCharged( + tokenName = cryptoCurrency.name, + minAdaValue = it.minAdaValue.parseBigDecimal(cryptoCurrency.decimals), + ), + ) + } + else -> return + } + } + + private fun MutableList.addCardanoTransactionValidationError( + error: BlockchainSdkError.Cardano, + sendingCurrency: CryptoCurrency, + dustValue: BigDecimal?, + ) { + when (error) { + BlockchainSdkError.Cardano.InsufficientMinAdaBalanceToSendToken -> { + add(NotificationUM.Cardano.InsufficientBalanceToTransferToken(sendingCurrency.name)) + } + BlockchainSdkError.Cardano.InsufficientRemainingBalanceToWithdrawTokens -> { + when (sendingCurrency) { + is CryptoCurrency.Coin -> NotificationUM.Cardano.InsufficientBalanceToTransferCoin + is CryptoCurrency.Token -> { + NotificationUM.Cardano.InsufficientBalanceToTransferToken(sendingCurrency.name) + } + }.let(::add) + } + BlockchainSdkError.Cardano.InsufficientRemainingBalance, + BlockchainSdkError.Cardano.InsufficientSendingAdaAmount, + -> { + dustValue?.let { + add( + NotificationUM.Error.MinimumAmountError( + amount = it.parseBigDecimal(sendingCurrency.decimals), + ), + ) + } + } + } + } + + private fun MutableList.addKoinosTransactionValidationError( + error: BlockchainSdkError.Koinos, + onReduceClick: ( + reduceAmountTo: BigDecimal, + notification: Class, + ) -> Unit, + ) { + when (error) { + is BlockchainSdkError.Koinos.InsufficientBalance -> { + add(NotificationUM.Koinos.InsufficientBalance) + } + is BlockchainSdkError.Koinos.InsufficientMana -> { + add( + NotificationUM.Koinos.InsufficientRecoverableMana( + mana = error.manaBalance ?: BigDecimal.ZERO, + maxMana = error.maxMana ?: BigDecimal.ZERO, + ), + ) + } + is BlockchainSdkError.Koinos.ManaFeeExceedsBalance -> { + add( + NotificationUM.Koinos.ManaExceedsBalance( + availableKoinForTransfer = error.availableKoinForTransfer, + onReduceClick = { + onReduceClick( + error.availableKoinForTransfer, + NotificationUM.Koinos.InsufficientRecoverableMana::class.java, + ) + }, + ), + ) + } + else -> {} + } + } + + private fun checkDustLimits( + feeAmount: BigDecimal, + receivedAmount: BigDecimal, + dustValue: BigDecimal, + cryptoCurrencyStatus: CryptoCurrencyStatus, + feeCurrencyStatus: CryptoCurrencyStatus?, + ): Boolean { + val change = when (cryptoCurrencyStatus.currency) { + is CryptoCurrency.Coin -> { + val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO + balance - (feeAmount + receivedAmount) + } + is CryptoCurrency.Token -> { + val balance = feeCurrencyStatus?.value?.amount ?: BigDecimal.ZERO + balance - feeAmount + } + } + + val isChangeLowerThanDust = change < dustValue && change > BigDecimal.ZERO + return receivedAmount < dustValue || isChangeLowerThanDust + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenActionsUtils.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenActionsUtils.kt new file mode 100644 index 0000000000..d33e9330f1 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenActionsUtils.kt @@ -0,0 +1,69 @@ +package com.tangem.common.ui.tokens + +import com.tangem.common.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason + +fun ScenarioUnavailabilityReason.getUnavailabilityReasonText(): TextReference { + return when (val unavailabilityReason = this) { + is ScenarioUnavailabilityReason.StakingUnavailable -> { + resourceReference( + id = R.string.token_button_unavailability_reason_staking_unavailable, + formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName), + ) + } + is ScenarioUnavailabilityReason.PendingTransaction -> { + when (unavailabilityReason.withdrawalScenario) { + ScenarioUnavailabilityReason.WithdrawalScenario.SEND -> resourceReference( + id = R.string.token_button_unavailability_reason_pending_transaction_send, + formatArgs = wrappedList(unavailabilityReason.networkName), + ) + ScenarioUnavailabilityReason.WithdrawalScenario.SELL -> resourceReference( + id = R.string.token_button_unavailability_reason_pending_transaction_sell, + formatArgs = wrappedList(unavailabilityReason.networkName), + ) + } + } + is ScenarioUnavailabilityReason.EmptyBalance -> { + when (unavailabilityReason.withdrawalScenario) { + ScenarioUnavailabilityReason.WithdrawalScenario.SEND -> resourceReference( + id = R.string.token_button_unavailability_reason_empty_balance_send, + ) + ScenarioUnavailabilityReason.WithdrawalScenario.SELL -> resourceReference( + id = R.string.token_button_unavailability_reason_empty_balance_sell, + ) + } + } + is ScenarioUnavailabilityReason.BuyUnavailable -> { + resourceReference( + id = R.string.token_button_unavailability_reason_buy_unavailable, + formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName), + ) + } + is ScenarioUnavailabilityReason.NotExchangeable -> { + resourceReference( + id = R.string.token_button_unavailability_reason_not_exchangeable, + formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName), + ) + } + is ScenarioUnavailabilityReason.NotSupportedBySellService -> { + resourceReference( + id = R.string.token_button_unavailability_reason_sell_unavailable, + formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName), + ) + } + ScenarioUnavailabilityReason.Unreachable -> { + resourceReference( + id = R.string.token_button_unavailability_generic_description, + ) + } + ScenarioUnavailabilityReason.UnassociatedAsset -> resourceReference( + id = R.string.warning_receive_blocked_hedera_token_association_required_message, + ) + ScenarioUnavailabilityReason.None -> { + throw IllegalArgumentException("The unavailability reason must be other than None") + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..1bae6dabaf --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt @@ -0,0 +1,193 @@ +package com.tangem.common.ui.tokens + +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +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.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.utils.StringsSigns.DASH_SIGN +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero +import com.tangem.utils.extensions.orZero +import java.math.BigDecimal + +/** + * Token item state converter from [CryptoCurrencyStatus] to [TokenItemState] + * + * @property appCurrency app currency + * @property titleStateProvider title state provider + * @property subtitleStateProvider subtitle state provider + * @property onItemClick callback is invoked when item is clicked + * @property onItemLongClick callback is invoked when item is long clicked + */ +class TokenItemStateConverter( + private val appCurrency: AppCurrency, + private val titleStateProvider: (CryptoCurrencyStatus) -> TokenItemState.TitleState = Companion::createTitleState, + private val subtitleStateProvider: (CryptoCurrencyStatus) -> TokenItemState.SubtitleState? = { + createSubtitleState(it, appCurrency) + }, + private val onItemClick: (CryptoCurrencyStatus) -> Unit, + private val onItemLongClick: ((CryptoCurrencyStatus) -> Unit)? = null, +) : Converter { + + private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) + + override fun convert(value: CryptoCurrencyStatus): TokenItemState { + return when (value.value) { + is CryptoCurrencyStatus.Loading -> value.mapToLoadingState() + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.NoAccount, + -> value.mapToTokenItemState() + is CryptoCurrencyStatus.MissedDerivation -> value.mapToNoAddressTokenItemState() + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + -> value.mapToUnreachableTokenItemState() + } + } + + private fun CryptoCurrencyStatus.mapToLoadingState(): TokenItemState.Loading { + return TokenItemState.Loading( + id = currency.id.value, + iconState = iconStateConverter.convert(value = this), + titleState = titleStateProvider(this) as TokenItemState.TitleState.Content, + subtitleState = requireNotNull(subtitleStateProvider(this)), + ) + } + + private fun CryptoCurrencyStatus.mapToTokenItemState(): TokenItemState.Content { + return TokenItemState.Content( + id = currency.id.value, + iconState = iconStateConverter.convert(value = this), + titleState = titleStateProvider(this), + subtitleState = requireNotNull(subtitleStateProvider(this)), + fiatAmountState = TokenItemState.FiatAmountState.Content( + text = getFormattedFiatAmount(), + hasStaked = !getStakedBalance().isZero(), + ), + cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = getFormattedAmount()), + onItemClick = { onItemClick(this) }, + onItemLongClick = onItemLongClick?.let { + { it(this) } + }, + ) + } + + private fun CryptoCurrencyStatus.getFormattedAmount(): String { + val amount = value.amount?.plus(getStakedBalance()) ?: return DASH_SIGN + + return BigDecimalFormatter.formatCryptoAmount(amount, currency.symbol, currency.decimals) + } + + private fun CryptoCurrencyStatus.getFormattedFiatAmount(): String { + val fiatYieldBalance = value.fiatRate?.times(getStakedBalance()).orZero() + val fiatAmount = value.fiatAmount?.plus(fiatYieldBalance) ?: return DASH_SIGN + + return BigDecimalFormatter.formatFiatAmount(fiatAmount, appCurrency.code, appCurrency.symbol) + } + + private fun CryptoCurrencyStatus.getStakedBalance() = + (value.yieldBalance as? YieldBalance.Data)?.getTotalWithRewardsStakingBalance().orZero() + + private fun CryptoCurrencyStatus.mapToUnreachableTokenItemState(): TokenItemState.Unreachable { + return TokenItemState.Unreachable( + id = currency.id.value, + iconState = iconStateConverter.convert(value = this), + titleState = titleStateProvider(this), + subtitleState = subtitleStateProvider(this), + onItemClick = { onItemClick(this) }, + onItemLongClick = onItemLongClick?.let { + { it(this) } + }, + ) + } + + private fun CryptoCurrencyStatus.mapToNoAddressTokenItemState(): TokenItemState.NoAddress { + return TokenItemState.NoAddress( + id = currency.id.value, + iconState = iconStateConverter.convert(this), + titleState = titleStateProvider(this), + subtitleState = subtitleStateProvider(this), + onItemLongClick = onItemLongClick?.let { + { it(this) } + }, + ) + } + + private companion object { + + fun createTitleState(currencyStatus: CryptoCurrencyStatus): TokenItemState.TitleState { + return when (val value = currencyStatus.value) { + is CryptoCurrencyStatus.Loading, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + -> { + TokenItemState.TitleState.Content(text = currencyStatus.currency.name) + } + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.NoAccount, + -> { + TokenItemState.TitleState.Content( + text = currencyStatus.currency.name, + hasPending = value.hasCurrentNetworkTransactions, + ) + } + } + } + + fun createSubtitleState( + currencyStatus: CryptoCurrencyStatus, + appCurrency: AppCurrency, + ): TokenItemState.SubtitleState? { + return when (currencyStatus.value) { + is CryptoCurrencyStatus.Loading -> TokenItemState.SubtitleState.Loading + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.NoAccount, + -> currencyStatus.getCryptoPriceState(appCurrency) + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + -> null + } + } + + private fun CryptoCurrencyStatus.getCryptoPriceState(appCurrency: AppCurrency): TokenItemState.SubtitleState { + val fiatRate = value.fiatRate + val priceChange = value.priceChange + + return if (fiatRate != null && priceChange != null) { + TokenItemState.SubtitleState.CryptoPriceContent( + price = fiatRate.getFormattedCryptoPrice(appCurrency), + priceChangePercent = BigDecimalFormatter.formatPercent( + percent = priceChange, + useAbsoluteValue = true, + ), + type = priceChange.getPriceChangeType(), + ) + } else { + TokenItemState.SubtitleState.Unknown + } + } + + private fun BigDecimal.getFormattedCryptoPrice(appCurrency: AppCurrency): String { + return BigDecimalFormatter.formatFiatAmountUncapped( + fiatAmount = this, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } + + private fun BigDecimal.getPriceChangeType(): PriceChangeType { + return PriceChangeConverter.fromBigDecimal(value = this) + } + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenPriceText.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenPriceText.kt new file mode 100644 index 0000000000..6a337dae15 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenPriceText.kt @@ -0,0 +1,55 @@ +package com.tangem.common.ui.tokens + +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.res.TangemTheme + +/** + * Text view for token price. + * + * @param price Price of the token. + * @param priceChangeType Type of the price change. + */ +@Composable +fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) { + val growColor = TangemTheme.colors.text.accent + val fallColor = TangemTheme.colors.text.warning + val generalColor = TangemTheme.colors.text.primary1 + + val color = remember(generalColor) { Animatable(generalColor) } + var animationSkipped by remember { mutableStateOf(false) } + + LaunchedEffect(price) { + if (animationSkipped.not()) { + animationSkipped = true + return@LaunchedEffect + } + + if (priceChangeType != null) { + val nextColor = when (priceChangeType) { + PriceChangeType.UP, + -> growColor + PriceChangeType.DOWN -> fallColor + PriceChangeType.NEUTRAL -> return@LaunchedEffect + } + + color.animateTo(nextColor, snap()) + color.animateTo(generalColor, tween(durationMillis = 500)) + } + } + + Text( + modifier = modifier, + text = price, + color = color.value, + maxLines = 1, + style = TangemTheme.typography.body2, + overflow = TextOverflow.Visible, + ) +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt new file mode 100644 index 0000000000..91f837f146 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt @@ -0,0 +1,213 @@ +package com.tangem.common.ui.userwallet + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.CardColors +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.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.util.fastForEach +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.common.ui.R +import com.tangem.core.ui.coil.RotationTransformation +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun UserWalletItem( + state: UserWalletItemUM, + modifier: Modifier = Modifier, + blockColors: CardColors = TangemBlockCardColors, +) { + BlockCard( + modifier = modifier, + colors = blockColors, + onClick = state.onClick, + enabled = state.isEnabled, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = TangemTheme.dimens.size68) + .padding(all = TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + CardImage(imageUrl = state.imageUrl) + NameAndInfo( + modifier = Modifier.weight(1f), + name = state.name, + information = state.information, + ) + + when (state.endIcon) { + UserWalletItemUM.EndIcon.None -> {} + 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, + ) + } + } + } + } +} + +@Composable +private fun NameAndInfo(name: TextReference, information: TextReference, modifier: Modifier = Modifier) { + Column( + modifier = modifier.heightIn(min = TangemTheme.dimens.size40), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.SpaceEvenly, + ) { + Text( + text = name.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + AnimatedContent( + targetState = information.resolveReference(), + label = "User wallet information", + ) { information -> + Text( + text = information, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun CardImage(imageUrl: String, modifier: Modifier = Modifier) { + val imageModifier = modifier + .width(TangemTheme.dimens.size24) + .height(TangemTheme.dimens.size36) + .clip(TangemTheme.shapes.roundedCornersSmall) + + SubcomposeAsyncImage( + modifier = imageModifier, + model = ImageRequest.Builder(LocalContext.current) + .transformations(RotationTransformation(angle = 90f)) + .size( + width = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }, + height = with(LocalDensity.current) { TangemTheme.dimens.size24.roundToPx() }, + ) + .data(imageUrl) + .crossfade(enable = true) + .allowHardware(enable = false) + .build(), + loading = { + RectangleShimmer( + modifier = imageModifier, + radius = TangemTheme.dimens.size2, + ) + }, + error = { + Image( + modifier = imageModifier, + imageVector = ImageVector.vectorResource(R.drawable.img_card_wallet_2_gray_22_36), + contentDescription = null, + ) + }, + contentDescription = null, + ) +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + val list = persistentListOf( + UserWalletItemUM( + id = UserWalletId("user_wallet_1".encodeToByteArray()), + name = stringReference("My Wallet"), + information = getInformation(3, "4 496,75 $"), + imageUrl = "", + isEnabled = true, + onClick = {}, + ), + UserWalletItemUM( + id = UserWalletId("user_wallet_2".encodeToByteArray()), + name = stringReference("Old wallet"), + information = getInformation(3, "4 496,75 $"), + imageUrl = "", + isEnabled = true, + onClick = {}, + endIcon = UserWalletItemUM.EndIcon.Arrow, + ), + UserWalletItemUM( + id = UserWalletId("user_wallet_3".encodeToByteArray()), + name = stringReference("Multi Card"), + information = getInformation(3, "4 496,75 $"), + imageUrl = "", + isEnabled = false, + endIcon = UserWalletItemUM.EndIcon.Checkmark, + onClick = {}, + ), + ) + + Column( + Modifier + .background(TangemTheme.colors.background.tertiary) + .padding(TangemTheme.dimens.spacing12), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + list.fastForEach { userWalletItemUM -> + UserWalletItem( + modifier = Modifier.fillMaxWidth(), + state = userWalletItemUM, + ) + } + } + } +} + +private fun getInformation(cardCount: Int, totalBalance: String): TextReference { + val t1 = TextReference.PluralRes( + id = R.plurals.card_label_card_count, + count = cardCount, + formatArgs = wrappedList(cardCount), + ) + val divider = stringReference(value = " • ") + val t2 = stringReference(totalBalance) + + return TextReference.Combined(wrappedList(t1, divider, t2)) +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt new file mode 100644 index 0000000000..34a65207e3 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt @@ -0,0 +1,103 @@ +package com.tangem.common.ui.userwallet.converter + +import com.tangem.common.ui.R +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.common.util.getCardsCount +import com.tangem.domain.tokens.model.TotalFiatBalance +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.StringsSigns.DOT +import com.tangem.utils.converter.Converter + +/** + * Converter from [UserWallet] to [UserWalletItemUM] + * + * @property onClick lambda be invoked when item is clicked + * @property appCurrency selected app currency + * @property balance wallet balance + * @property isLoading wallet loading state + * @property isBalanceHidden wallet balance is hidden + * +[REDACTED_AUTHOR] + */ +class UserWalletItemUMConverter( + private val onClick: (UserWalletId) -> Unit, + private val appCurrency: AppCurrency? = null, + private val balance: TotalFiatBalance? = null, + private val isLoading: Boolean = true, + private val isBalanceHidden: Boolean = false, + private val endIcon: UserWalletItemUM.EndIcon = UserWalletItemUM.EndIcon.None, +) : Converter { + + override fun convert(value: UserWallet): UserWalletItemUM { + return with(value) { + UserWalletItemUM( + id = walletId, + name = stringReference(name), + information = getInfo( + appCurrency = appCurrency, + balance = balance, + isBalanceHidden = isBalanceHidden, + isLoading = isLoading, + ), + imageUrl = artworkUrl, + isEnabled = !isLocked, + endIcon = endIcon, + onClick = { onClick(value.walletId) }, + ) + } + } + + private fun UserWallet.getInfo( + appCurrency: AppCurrency?, + balance: TotalFiatBalance?, + isBalanceHidden: Boolean, + isLoading: Boolean, + ): TextReference { + val dividerRef = stringReference(value = " $DOT ") + + val cardCount = getCardsCount() ?: 1 + val cardCountRef = TextReference.PluralRes( + id = R.plurals.card_label_card_count, + count = cardCount, + formatArgs = wrappedList(cardCount), + ) + + return when { + isBalanceHidden -> combinedReference(cardCountRef, dividerRef, TextReference.STARS) + isLocked -> combinedReference(cardCountRef, dividerRef, resourceReference(R.string.common_locked)) + isLoading -> cardCountRef + else -> getBalanceInfo(balance, appCurrency, cardCountRef, dividerRef) + } + } + + private fun getBalanceInfo( + balance: TotalFiatBalance?, + appCurrency: AppCurrency?, + cardCountRef: TextReference, + dividerRef: TextReference, + ): TextReference { + val amount = when (balance) { + is TotalFiatBalance.Loaded -> balance.amount + is TotalFiatBalance.Failed, + is TotalFiatBalance.Loading, + null, + -> null + } + + return if (amount != null && appCurrency != null) { + val formattedAmount = BigDecimalFormatter.formatFiatAmount( + fiatAmount = amount, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + val amountRef = stringReference(formattedAmount) + combinedReference(cardCountRef, dividerRef, amountRef) + } else { + combinedReference(cardCountRef, dividerRef, stringReference(BigDecimalFormatter.EMPTY_BALANCE_SIGN)) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..f8e361a6f2 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt @@ -0,0 +1,22 @@ +package com.tangem.common.ui.userwallet.state + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.wallets.models.UserWalletId +import javax.annotation.concurrent.Immutable + +@Immutable +data class UserWalletItemUM( + val id: UserWalletId, + val name: TextReference, + val information: TextReference, + val imageUrl: String, + val isEnabled: Boolean, + val endIcon: EndIcon = EndIcon.None, + val onClick: () -> Unit, +) { + enum class EndIcon { + None, + Arrow, + Checkmark, + } +} \ No newline at end of file diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index 093769910b..f9911c81f8 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -81,6 +81,12 @@ sealed class AnalyticsParam { override val feeType: FeeType, ) : TxSentFrom("Swap"), TxData + data class Staking( + override val blockchain: String, + override val token: String, + override val feeType: FeeType, + ) : TxSentFrom("Staking"), TxData + data class Approve( override val blockchain: String, override val token: String, @@ -131,7 +137,7 @@ sealed class AnalyticsParam { companion object Key { const val BLOCKCHAIN = "blockchain" - const val TOKEN = "Token" + const val TOKEN_PARAM = "Token" const val SOURCE = "Source" const val BALANCE = "Balance" const val STATE = "State" @@ -152,5 +158,12 @@ sealed class AnalyticsParam { const val VALIDATION = "Validation" const val BLOCKCHAIN_EXCEPTION_HOST = "exception_host" const val BLOCKCHAIN_SELECTED_HOST = "selected_host" + + // region swap + const val TOKEN_CATEGORY = "Token" + const val STATUS = "Status" + const val PROVIDER = "Provider" + const val PLACE = "Place" + // } } \ No newline at end of file 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 9f39ae5bdd..6fb52dfe29 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 @@ -55,7 +55,7 @@ sealed class Basic( this[AnalyticsParam.SOURCE] = sentFrom.value if (sentFrom is AnalyticsParam.TxData) { this[AnalyticsParam.BLOCKCHAIN] = sentFrom.blockchain - this[AnalyticsParam.TOKEN] = sentFrom.token + this[AnalyticsParam.TOKEN_PARAM] = sentFrom.token this[AnalyticsParam.FEE_TYPE] = sentFrom.feeType.value } if (sentFrom is AnalyticsParam.TxSentFrom.Approve) { diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/AppInstanceIdProvider.kt b/core/analytics/src/main/java/com/tangem/core/analytics/AppInstanceIdProvider.kt new file mode 100644 index 0000000000..0d1e973283 --- /dev/null +++ b/core/analytics/src/main/java/com/tangem/core/analytics/AppInstanceIdProvider.kt @@ -0,0 +1,8 @@ +package com.tangem.core.analytics + +interface AppInstanceIdProvider { + + suspend fun getAppInstanceId(): String? + + fun getAppInstanceIdSync(): String? +} \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/DummyAppInstanceIdProvider.kt b/core/analytics/src/main/java/com/tangem/core/analytics/DummyAppInstanceIdProvider.kt new file mode 100644 index 0000000000..94f92f0b3c --- /dev/null +++ b/core/analytics/src/main/java/com/tangem/core/analytics/DummyAppInstanceIdProvider.kt @@ -0,0 +1,12 @@ +package com.tangem.core.analytics + +class DummyAppInstanceIdProvider : AppInstanceIdProvider { + + override suspend fun getAppInstanceId(): String? { + return null + } + + override fun getAppInstanceIdSync(): String? { + return null + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/adapter/UnknownEnumMoshiAdapter.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/adapter/UnknownEnumMoshiAdapter.kt index f5627c6f96..2cc525ffd0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/adapter/UnknownEnumMoshiAdapter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/adapter/UnknownEnumMoshiAdapter.kt @@ -8,8 +8,11 @@ import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardClaimingDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardScheduleDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.RewardTypeDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.ValidatorDTO.ValidatorStatusDTO import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionStatusDTO import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionTypeDTO +import com.tangem.datasource.api.stakekit.models.response.model.error.AccessDeniedErrorTypeDTO +import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorMessageDTO import com.tangem.datasource.api.stakekit.models.response.model.transaction.StakingTransactionStatusDTO import com.tangem.datasource.api.stakekit.models.response.model.transaction.StakingTransactionTypeDTO @@ -26,6 +29,7 @@ object UnknownEnumMoshiAdapter { fun Moshi.Builder.addStakeKitEnumFallbackAdapters(): Moshi.Builder { val map = mapOf( + // valid response enums BalanceTypeDTO::class.java to BalanceTypeDTO.UNKNOWN, NetworkTypeDTO::class.java to NetworkTypeDTO.UNKNOWN, RewardClaimingDTO::class.java to RewardClaimingDTO.UNKNOWN, @@ -35,6 +39,10 @@ fun Moshi.Builder.addStakeKitEnumFallbackAdapters(): Moshi.Builder { StakingActionTypeDTO::class.java to StakingActionTypeDTO.UNKNOWN, StakingTransactionStatusDTO::class.java to StakingTransactionStatusDTO.UNKNOWN, StakingTransactionTypeDTO::class.java to StakingTransactionTypeDTO.UNKNOWN, + ValidatorStatusDTO::class.java to ValidatorStatusDTO.UNKNOWN, + // error enums + AccessDeniedErrorTypeDTO::class.java to AccessDeniedErrorTypeDTO.UNKNOWN, + StakeKitErrorMessageDTO::class.java to StakeKitErrorMessageDTO.UNKNOWN, ) return apply { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketChartResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketChartResponse.kt index 777bbc1228..fbe921d20b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketChartResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketChartResponse.kt @@ -1,8 +1,10 @@ package com.tangem.datasource.api.markets.models.response import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.math.BigDecimal +@JsonClass(generateAdapter = true) data class TokenMarketChartResponse( @Json(name = "prices") val prices: Map, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketInfoResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketInfoResponse.kt index ef485e1829..68c54776fa 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketInfoResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketInfoResponse.kt @@ -1,8 +1,10 @@ package com.tangem.datasource.api.markets.models.response import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.math.BigDecimal +@JsonClass(generateAdapter = true) data class TokenMarketInfoResponse( @Json(name = "id") val id: String, @@ -30,6 +32,7 @@ data class TokenMarketInfoResponse( val pricePerformance: PricePerformance?, ) { + @JsonClass(generateAdapter = true) data class PriceChangePercentage( @Json(name = "24h") val day: BigDecimal?, @@ -47,6 +50,7 @@ data class TokenMarketInfoResponse( val allTime: BigDecimal?, ) + @JsonClass(generateAdapter = true) data class Network( @Json(name = "network_id") val networkId: String, @@ -58,6 +62,7 @@ data class TokenMarketInfoResponse( val decimalCount: Int?, ) + @JsonClass(generateAdapter = true) data class Insights( @Json(name = "holders_change") val holdersChange: Change?, @@ -67,8 +72,19 @@ data class TokenMarketInfoResponse( val buyPressureChange: Change?, @Json(name = "experienced_buyer_change") val experiencedBuyerChange: Change?, - ) + @Json(name = "networks") + val sourceNetworks: List?, + ) { + @JsonClass(generateAdapter = true) + data class SourceNetwork( + @Json(name = "network_id") + val id: String, + @Json(name = "network_name") + val name: String, + ) + } + @JsonClass(generateAdapter = true) data class Change( @Json(name = "24h") val day: BigDecimal?, @@ -78,6 +94,7 @@ data class TokenMarketInfoResponse( val month: BigDecimal?, ) + @JsonClass(generateAdapter = true) data class Metrics( @Json(name = "market_rating") val marketRating: Int?, @@ -93,17 +110,19 @@ data class TokenMarketInfoResponse( val fullyDilutedValuation: BigDecimal?, ) + @JsonClass(generateAdapter = true) data class Links( @Json(name = "official_links") - val officialLinks: List?, + val officialLinks: List? = null, @Json(name = "social") - val social: List?, + val social: List? = null, @Json(name = "repository") - val repository: List?, + val repository: List? = null, @Json(name = "blockchain_site") - val blockchainSite: List?, + val blockchainSite: List? = null, ) + @JsonClass(generateAdapter = true) data class Link( @Json(name = "title") val title: String, @@ -113,6 +132,7 @@ data class TokenMarketInfoResponse( val link: String, ) + @JsonClass(generateAdapter = true) data class PricePerformance( @Json(name = "24h") val day: Range?, @@ -122,6 +142,7 @@ data class TokenMarketInfoResponse( val allTime: Range?, ) + @JsonClass(generateAdapter = true) data class Range( @Json(name = "low_price") val low: BigDecimal?, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt index a0f287971f..3dbf406f16 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt @@ -1,45 +1,36 @@ package com.tangem.datasource.api.markets.models.response import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.math.BigDecimal +@JsonClass(generateAdapter = true) data class TokenMarketListResponse( - @Json(name = "imageHost") - val imageHost: String?, - @Json(name = "tokens") - val tokens: List, - @Json(name = "total") - val total: Int, - @Json(name = "limit") - val limit: Int, - @Json(name = "offset") - val offset: Int, - @Json(name = "timestamp") - val timestamp: Long? = null, + @Json(name = "imageHost") val imageHost: String?, + @Json(name = "tokens") val tokens: List, + @Json(name = "total") val total: Int, + @Json(name = "limit") val limit: Int, + @Json(name = "offset") val offset: Int, + @Json(name = "timestamp") val timestamp: Long? = null, ) { + + @JsonClass(generateAdapter = true) data class Token( - @Json(name = "id") - val id: String, - @Json(name = "name") - val name: String, - @Json(name = "symbol") - val symbol: String, - @Json(name = "current_price") - val currentPrice: BigDecimal, - @Json(name = "price_change_percentage") - val priceChangePercentage: PriceChangePercentage, - @Json(name = "market_rating") - val marketRating: Int?, - @Json(name = "market_cap") - val marketCap: BigDecimal?, + @Json(name = "id") val id: String, + @Json(name = "name") val name: String, + @Json(name = "symbol") val symbol: String, + @Json(name = "current_price") val currentPrice: BigDecimal, + @Json(name = "price_change_percentage") val priceChangePercentage: PriceChangePercentage, + @Json(name = "market_rating") val marketRating: Int?, + @Json(name = "market_cap") val marketCap: BigDecimal?, + @Json(name = "is_under_market_cap_limit") val isUnderMarketCapLimit: Boolean?, ) { + + @JsonClass(generateAdapter = true) data class PriceChangePercentage( - @Json(name = "24h") - val h24: BigDecimal, - @Json(name = "1w") - val week1: BigDecimal, - @Json(name = "30d") - val day30: BigDecimal, + @Json(name = "24h") val h24: BigDecimal, + @Json(name = "1w") val week1: BigDecimal, + @Json(name = "30d") val day30: BigDecimal, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/StakeKitApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/StakeKitApi.kt index 2d5bd551ac..b1f2170fea 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/StakeKitApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/StakeKitApi.kt @@ -17,6 +17,7 @@ interface StakeKitApi { @GET("yields/enabled") suspend fun getMultipleYields( + @Query("preferredValidatorsOnly") preferredValidatorsOnly: Boolean? = null, @Query("ledgerWalletAPICompatible") ledgerWalletAPICompatible: Boolean? = null, @Query("type") type: YieldType? = null, @Query("revenueOption") revenueOption: RevenueOption? = null, @@ -34,7 +35,7 @@ interface StakeKitApi { @POST("yields/balances") suspend fun getMultipleYieldBalances( @Body body: List, - ): ApiResponse> + ): ApiResponse> @POST("yields/{integrationId}/balances") suspend fun getSingleYieldBalance( diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/request/ActionRequestBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/request/ActionRequestBody.kt index d6c09517c1..9063ff0b74 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/request/ActionRequestBody.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/request/ActionRequestBody.kt @@ -48,7 +48,7 @@ data class ActionRequestBodyArgs( @Json(name = "ledgerWalletAPICompatible") val ledgerWalletAPICompatible: Boolean? = null, @Json(name = "tronResource") - val tronResource: String? = null, + val tronResource: TronResource? = null, @Json(name = "signatureVerification") val signatureVerification: SignatureVerification? = null, @Json(name = "inputToken") @@ -60,4 +60,12 @@ data class SignatureVerification( val message: String, @Json(name = "signed") val signed: String, -) \ No newline at end of file +) + +enum class TronResource { + @Json(name = "ENERGY") + ENERGY, + + @Json(name = "BANDWIDTH") + BANDWIDTH, +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt index 1cd438732e..964bde08a7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt @@ -2,12 +2,15 @@ package com.tangem.datasource.api.stakekit.models.response.model import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import com.tangem.datasource.api.stakekit.models.request.Address import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionTypeDTO import org.joda.time.DateTime import java.math.BigDecimal @JsonClass(generateAdapter = true) data class YieldBalanceWrapperDTO( + @Json(name = "addresses") + val addresses: Address, @Json(name = "balances") val balances: List, @Json(name = "integrationId") diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt index cff84d1ce7..614184aee4 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt @@ -67,7 +67,7 @@ data class YieldDTO( @Json(name = "address") val address: String, @Json(name = "status") - val status: String, + val status: ValidatorStatusDTO, @Json(name = "name") val name: String, @Json(name = "image") @@ -84,7 +84,24 @@ data class YieldDTO( val votingPower: Double?, @Json(name = "preferred") val preferred: Boolean, - ) + ) { + @JsonClass(generateAdapter = true) + enum class ValidatorStatusDTO { + @Json(name = "active") + ACTIVE, + + @Json(name = "deactivating") + DEACTIVATING, + + @Json(name = "inactive") + INACTIVE, + + @Json(name = "jailed") + JAILED, + + UNKNOWN, + } + } @JsonClass(generateAdapter = true) data class MetadataDTO( diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/error/StakeKitErrorResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/error/StakeKitErrorResponse.kt index 9ad42062ca..fdb3436051 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/error/StakeKitErrorResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/error/StakeKitErrorResponse.kt @@ -35,6 +35,8 @@ data class StakeKitErrorDetailsDTO( enum class AccessDeniedErrorTypeDTO { @Json(name = "GEO_LOCATION") GEO_LOCATION, + + UNKNOWN, } enum class StakeKitErrorMessageDTO { @@ -157,4 +159,6 @@ enum class StakeKitErrorMessageDTO { @Json(name = "GRTStakingDisabledLedgerLiveError") GRT_STAKING_DISABLED_LEDGER_LIVE_ERROR, + + UNKNOWN, } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/transaction/tron/TronStakeKitTransaction.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/transaction/tron/TronStakeKitTransaction.kt new file mode 100644 index 0000000000..f12f5fe5ed --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/transaction/tron/TronStakeKitTransaction.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.stakekit.models.response.model.transaction.tron + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class TronStakeKitTransaction( + @Json(name = "raw_data_hex") + val rawDataHex: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt index c4b95bba88..dcd3107e06 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.api.tangemTech.models import com.squareup.moshi.Json +import com.tangem.common.extensions.calculateHashCode data class UserTokensResponse( @Json(name = "version") val version: Int = 0, @@ -17,7 +18,23 @@ data class UserTokensResponse( @Json(name = "symbol") val symbol: String, @Json(name = "decimals") val decimals: Int, @Json(name = "contractAddress") val contractAddress: String?, - ) + ) { + override fun equals(other: Any?): Boolean { + val otherToken = other as? Token ?: return false + + return otherToken.contractAddress == this.contractAddress && + otherToken.networkId == this.networkId && + otherToken.derivationPath == this.derivationPath && + otherToken.decimals == this.decimals + } + + override fun hashCode(): Int = calculateHashCode( + contractAddress.hashCode(), + networkId.hashCode(), + derivationPath.hashCode(), + decimals.hashCode(), + ) + } enum class GroupType { @Json(name = "none") diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/UserTokensStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/UserTokensStoreModule.kt deleted file mode 100644 index b2997661fb..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/di/UserTokensStoreModule.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.tangem.datasource.di - -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.token.AppPreferencesUserTokensStore -import com.tangem.datasource.local.token.UserTokensStore -import com.tangem.datasource.local.token.UserTokensStoreMigrationRunner -import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object UserTokensStoreModule { - - @Provides - @Singleton - fun provideUserTokensStore( - appPreferencesStore: AppPreferencesStore, - userTokensStoreMigrationRunner: UserTokensStoreMigrationRunner, - userWalletsStore: UserWalletsStore, - dispatchers: CoroutineDispatcherProvider, - ): UserTokensStore { - return AppPreferencesUserTokensStore( - appPreferencesStore = appPreferencesStore, - userTokensStoreMigrationRunner = userTokensStoreMigrationRunner, - userWalletsStore = userWalletsStore, - dispatchers = dispatchers, - ) - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt index d3fa302f26..6bfe043b34 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt @@ -3,7 +3,9 @@ package com.tangem.datasource.local.logs import android.content.Context import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -30,7 +32,10 @@ class AppLogsStore @Inject constructor( dispatchers: CoroutineDispatcherProvider, ) { - private val scope = CoroutineScope(dispatchers.io) + private val scope = CoroutineScope( + context = SupervisorJob() + dispatchers.io + + CoroutineExceptionHandler { _, error -> Timber.e("AppLogsStore.scope is failed $error") }, + ) private val mutex = Mutex() private val file = File(applicationContext.filesDir, LOG_FILE_NAME) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 9b01024cc8..d2c600c572 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -83,6 +83,8 @@ object PreferencesKeys { val SHOULD_SAVE_ACCESS_CODES_KEY by lazy { booleanPreferencesKey(name = "saveAccessCodes") } + val SHOULD_SHOW_MARKETS_TOOLTIP_KEY by lazy { booleanPreferencesKey(name = "shouldShowMarketsTooltip") } + val IS_WALLET_NAMES_MIGRATION_DONE_KEY by lazy { booleanPreferencesKey(name = "isWalletNamesMigrationDone") } val UNSUBMITTED_TRANSACTIONS_KEY by lazy { stringPreferencesKey(name = "unsubmittedTransactions") } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/AppPreferencesUserTokensStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/AppPreferencesUserTokensStore.kt deleted file mode 100644 index 80d70825bf..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/AppPreferencesUserTokensStore.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.tangem.datasource.local.token - -import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.PreferencesKeys -import com.tangem.datasource.local.preferences.utils.getObject -import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull -import com.tangem.datasource.local.preferences.utils.storeObject -import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.* - -/** - * Implementation of [UserTokensStore] that based on [appPreferencesStore] - * - * @property appPreferencesStore application preference store - * -[REDACTED_AUTHOR] - */ -internal class AppPreferencesUserTokensStore( - private val appPreferencesStore: AppPreferencesStore, - private val userTokensStoreMigrationRunner: UserTokensStoreMigrationRunner, - private val userWalletsStore: UserWalletsStore, - private val dispatchers: CoroutineDispatcherProvider, -) : UserTokensStore { - - init { - runUserTokensMigrations() - } - - override fun get(key: UserWalletId): Flow { - return appPreferencesStore - .getObject(PreferencesKeys.getUserTokensKey(userWalletId = key.stringValue)) - .filterNotNull() - } - - override suspend fun getSyncOrNull(key: UserWalletId): UserTokensResponse? { - return appPreferencesStore.getObjectSyncOrNull( - key = PreferencesKeys.getUserTokensKey(userWalletId = key.stringValue), - ) - } - - override suspend fun store(key: UserWalletId, value: UserTokensResponse) { - appPreferencesStore.storeObject( - key = PreferencesKeys.getUserTokensKey(userWalletId = key.stringValue), - value = value, - ) - } - - // TODO: delete in 5.15 (Mobile Sprint 161) [REDACTED_JIRA] - private fun runUserTokensMigrations() { - userWalletsStore.userWallets - .filter { it.isNotEmpty() } - .take(1) - .onEach { userWallets -> - userTokensStoreMigrationRunner.run(ids = userWallets.map { it.walletId.stringValue }) - } - .flowOn(dispatchers.io) - .launchIn(CoroutineScope(dispatchers.io)) - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultStakingBalanceStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultStakingBalanceStore.kt index c88970b238..0e2ae15ec3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultStakingBalanceStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultStakingBalanceStore.kt @@ -1,51 +1,62 @@ package com.tangem.datasource.local.token -import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO import com.tangem.datasource.local.datastore.core.StringKeyDataStore +import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.extensions.addOrReplace import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock internal class DefaultStakingBalanceStore( - private val dataStore: StringKeyDataStore>, + private val dataStore: StringKeyDataStore>, ) : StakingBalanceStore { - override fun get(): Flow> { - return dataStore.get(STAKING_BALANCE_KEY) + private val mutex = Mutex() + + override fun get(userWalletId: UserWalletId): Flow> { + return dataStore.get(userWalletId.stringValue) } - override suspend fun getSyncOrNull(): List? { - return dataStore.getSyncOrNull(STAKING_BALANCE_KEY) + override suspend fun getSyncOrNull(userWalletId: UserWalletId): Set? { + return dataStore.getSyncOrNull(userWalletId.stringValue) } - override suspend fun store(items: List) { - return dataStore.store(STAKING_BALANCE_KEY, items) + override suspend fun store(userWalletId: UserWalletId, items: Set) { + mutex.withLock { + dataStore.store(userWalletId.stringValue, items) + } } - override fun get(integrationId: String): Flow> { - return dataStore.get(STAKING_BALANCE_KEY) - .map { balances -> - balances.filter { it.integrationId == integrationId } - .flatMap { it.balances } + override fun get(userWalletId: UserWalletId, address: String, integrationId: String): Flow { + return dataStore.get(userWalletId.stringValue) + .mapNotNull { balances -> + balances.firstOrNull { it.integrationId == integrationId && it.addresses.address == address } } } - override suspend fun getSyncOrNull(integrationId: String): List? { - return dataStore.getSyncOrNull(STAKING_BALANCE_KEY) - ?.firstOrNull { it.integrationId == integrationId }?.balances + override suspend fun getSyncOrNull( + userWalletId: UserWalletId, + address: String, + integrationId: String, + ): YieldBalanceWrapperDTO? { + return dataStore.getSyncOrNull(userWalletId.stringValue) + ?.firstOrNull { it.integrationId == integrationId && it.addresses.address == address } } - override suspend fun store(integrationId: String, item: YieldBalanceWrapperDTO) { - val balances = dataStore.getSyncOrNull(STAKING_BALANCE_KEY) - ?.toMutableList() - ?.addOrReplace(item) { item.integrationId == integrationId } - ?: listOf(item) + override suspend fun store( + userWalletId: UserWalletId, + integrationId: String, + address: String, + item: YieldBalanceWrapperDTO, + ) { + mutex.withLock { + val balances = dataStore.getSyncOrNull(userWalletId.stringValue) + ?.addOrReplace(item) { it.integrationId == integrationId && it.addresses.address == address } + ?: setOf(item) - return dataStore.store(STAKING_BALANCE_KEY, balances) - } - - companion object { - private const val STAKING_BALANCE_KEY = "STAKING_BALANCE_KEY" + dataStore.store(userWalletId.stringValue, balances) + } } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/StakingBalanceStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/StakingBalanceStore.kt index 0a9ea06c9e..601b11ccb0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/StakingBalanceStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/StakingBalanceStore.kt @@ -1,20 +1,24 @@ package com.tangem.datasource.local.token -import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow interface StakingBalanceStore { - fun get(): Flow> + fun get(userWalletId: UserWalletId): Flow> - suspend fun getSyncOrNull(): List? + suspend fun getSyncOrNull(userWalletId: UserWalletId): Set? - suspend fun store(items: List) + suspend fun store(userWalletId: UserWalletId, items: Set) - fun get(integrationId: String): Flow> + fun get(userWalletId: UserWalletId, address: String, integrationId: String): Flow - suspend fun getSyncOrNull(integrationId: String): List? + suspend fun getSyncOrNull( + userWalletId: UserWalletId, + address: String, + integrationId: String, + ): YieldBalanceWrapperDTO? - suspend fun store(integrationId: String, item: YieldBalanceWrapperDTO) + suspend fun store(userWalletId: UserWalletId, integrationId: String, address: String, item: YieldBalanceWrapperDTO) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensStore.kt deleted file mode 100644 index f3f12f026b..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensStore.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.tangem.datasource.local.token - -import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.domain.wallets.models.UserWalletId -import kotlinx.coroutines.flow.Flow - -@Deprecated( - message = "Use AppPreferencesStore", - replaceWith = ReplaceWith( - expression = "AppPreferencesStore", - imports = arrayOf("com.tangem.datasource.local.preferences.AppPreferencesStore"), - ), - level = DeprecationLevel.WARNING, -) -interface UserTokensStore { - - @Deprecated( - message = "Use getObject", - replaceWith = ReplaceWith( - expression = "appPreferencesStore.getObject(userWalletId)", - imports = arrayOf("com.tangem.datasource.local.preferences.AppPreferencesStore"), - ), - level = DeprecationLevel.WARNING, - ) - fun get(key: UserWalletId): Flow - - @Deprecated( - message = "Use getObjectSyncOrNull", - replaceWith = ReplaceWith( - expression = "appPreferencesStore.getObjectSyncOrNull(userWalletId)", - imports = arrayOf("com.tangem.datasource.local.preferences.AppPreferencesStore"), - ), - level = DeprecationLevel.WARNING, - ) - suspend fun getSyncOrNull(key: UserWalletId): UserTokensResponse? - - @Deprecated( - message = "Use storeObject", - replaceWith = ReplaceWith( - expression = "appPreferencesStore.storeObject(userWalletId, response)", - imports = arrayOf("com.tangem.datasource.local.preferences.AppPreferencesStore"), - ), - level = DeprecationLevel.WARNING, - ) - suspend fun store(key: UserWalletId, value: UserTokensResponse) -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensStoreMigration.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensStoreMigration.kt deleted file mode 100644 index dae09c0616..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensStoreMigration.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.tangem.datasource.local.token - -import androidx.datastore.core.DataMigration -import com.squareup.moshi.Moshi -import com.squareup.moshi.adapter -import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.datasource.files.FileReader -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.PreferencesKeys -import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull -import com.tangem.datasource.local.preferences.utils.storeObject - -/** - * Migration of saving [UserTokensResponse] from file to [AppPreferencesStore] - * - * @param userWalletId user wallet id - * @param moshi moshi - * @property fileReader file reader - * -[REDACTED_AUTHOR] - */ -internal class UserTokensStoreMigration( - userWalletId: String, - moshi: Moshi, - private val fileReader: FileReader, -) : DataMigration { - - private val legacyFileName = "user_tokens_$userWalletId" - private val keyName = PreferencesKeys.getUserTokensKey(userWalletId = userWalletId) - - @OptIn(ExperimentalStdlibApi::class) - private val adapter = moshi.adapter() - - override suspend fun shouldMigrate(currentData: AppPreferencesStore): Boolean = true - - override suspend fun migrate(currentData: AppPreferencesStore): AppPreferencesStore { - val currentKey = currentData.getObjectSyncOrNull(key = keyName) - - if (currentKey != null) return currentData - - val value = runCatching { - val json = fileReader.readFile(legacyFileName) - adapter.fromJson(json) - }.getOrNull() - - if (value != null) { - currentData.storeObject(key = keyName, value = value) - } - - return currentData - } - - override suspend fun cleanUp() { - fileReader.removeFile(legacyFileName) - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensStoreMigrationRunner.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensStoreMigrationRunner.kt deleted file mode 100644 index eafb84aa79..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensStoreMigrationRunner.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.tangem.datasource.local.token - -import com.squareup.moshi.Moshi -import com.tangem.datasource.di.NetworkMoshi -import com.tangem.datasource.files.FileReader -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.withContext -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Runner that launch migrations of saving user tokens store - * - * @property appPreferencesStore application preference store - * @property fileReader file reader - * @property moshi moshi - * @property dispatchers dispatchers - * -[REDACTED_AUTHOR] - */ -@Singleton -class UserTokensStoreMigrationRunner @Inject constructor( - private val appPreferencesStore: AppPreferencesStore, - private val fileReader: FileReader, - @NetworkMoshi private val moshi: Moshi, - private val dispatchers: CoroutineDispatcherProvider, -) { - - suspend fun run(ids: List) { - ids.forEach { id -> - coroutineScope { run(id) } - } - } - - private suspend fun run(id: String) { - withContext(dispatchers.io) { - val migration = UserTokensStoreMigration( - userWalletId = id, - moshi = moshi, - fileReader = fileReader, - ) - - migration.migrate(appPreferencesStore) - - migration.cleanUp() - } - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/UriExt.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/UriExt.kt new file mode 100644 index 0000000000..dc9fda4985 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/UriExt.kt @@ -0,0 +1,7 @@ +package com.tangem.datasource.utils + +import android.net.Uri + +fun Uri?.isNullOrEmpty(): Boolean { + return this == null || this == Uri.EMPTY +} \ No newline at end of file diff --git a/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/DefaultAppComponentContext.kt b/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/DefaultAppComponentContext.kt index 586779ec4a..6ffebce9fe 100644 --- a/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/DefaultAppComponentContext.kt +++ b/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/DefaultAppComponentContext.kt @@ -19,6 +19,7 @@ class DefaultAppComponentContext( messageHandler: UiMessageHandler, override val dispatchers: CoroutineDispatcherProvider, override val hiltComponentBuilder: DecomposeComponent.Builder, + private val replaceRouter: Router? = null, ) : AppComponentContext, ComponentContext by componentContext { override val tags: HashMap = HashMap() @@ -31,5 +32,5 @@ class DefaultAppComponentContext( get() = instanceKeeper.getOrCreate { DefaultAppNavigationProvider() } override val router: Router - get() = instanceKeeper.getOrCreate { DefaultRouter(navigationProvider) } + get() = replaceRouter ?: instanceKeeper.getOrCreate { DefaultRouter(navigationProvider) } } \ No newline at end of file diff --git a/core/decompose/src/main/kotlin/com/tangem/core/decompose/model/Model.kt b/core/decompose/src/main/kotlin/com/tangem/core/decompose/model/Model.kt index c845658265..b1337c72f9 100644 --- a/core/decompose/src/main/kotlin/com/tangem/core/decompose/model/Model.kt +++ b/core/decompose/src/main/kotlin/com/tangem/core/decompose/model/Model.kt @@ -46,14 +46,36 @@ abstract class Model : InstanceKeeper.Instance { progressFlow: MutableSharedFlow, dispatcher: CoroutineDispatcher = dispatchers.mainImmediate, crossinline block: suspend () -> Unit, + ): Job = resource( + acquire = { progressFlow.emit(true) }, + release = { progressFlow.emit(false) }, + dispatcher = dispatcher, + block = block, + ) + + /** + * Launches [block] in the model's scope and acquires a resource before executing the block and releases it after. + * + * @param acquire The block of code to acquire the resource. + * @param release The block of code to release the resource. + * @param dispatcher The [CoroutineDispatcher] to launch the coroutine. Default is [Dispatchers.Main.immediate]. + * @param block The block of code to execute. + * + * @return The [Job] of the launched coroutine. + * */ + protected inline fun resource( + crossinline acquire: suspend () -> Unit, + crossinline release: suspend () -> Unit, + dispatcher: CoroutineDispatcher = dispatchers.mainImmediate, + crossinline block: suspend () -> Unit, ): Job = modelScope.launch(dispatcher) { - progressFlow.emit(value = true) + acquire() try { block() } finally { withContext(NonCancellable) { - progressFlow.emit(value = false) + release() } } } diff --git a/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json b/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json index f0c510874b..652af05e72 100644 --- a/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json @@ -15,13 +15,9 @@ "name": "WC_SOLANA_TX_SIGN_ENABLED", "version": "undefined" }, - { - "name": "CARDANO_TOKENS_SUPPORT_ENABLED", - "version": "5.12.0" - }, { "name": "STAKING_ENABLED", - "version": "undefined" + "version": "5.15.0" }, { "name": "DETAILS_REDESIGN_ENABLED", @@ -33,14 +29,14 @@ }, { "name": "MARKETS_ENABLED", - "version": "undefined" - }, - { - "name": "HOME_SCREEN_CALLBACKS_REFACTORING_ENABLED", - "version": "5.14.0" + "version": "5.15.0" }, { "name": "NEW_MANAGE_TOKENS", - "version": "undefined" + "version": "5.15.0" + }, + { + "name": "IS_ETHEREUM_EIP_1559_ENABLED", + "version": "5.16.0" } ] diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index c9b6c36eff..ef2d858a71 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -70,6 +70,7 @@ Zugang verweigert Alle Erlauben + Analysen Anwenden Genehmigung Genehmigen @@ -82,6 +83,8 @@ Gehe zu %1$s D hast keinen Zugang zur Kamera erteilt, bitte passe deine Datenschutzeinstellungen an Abbrechen + Aktion wählen + Beanspruchen Stakingbelohnungen beanstpruchen Schließen Weiter @@ -100,10 +103,10 @@ Aktivieren Aktiviert Fehler + Umtausch Erkunden Transaktionsverlauf einsehen Explorer - Gebühr Netzwerkgebühren sind Gebühren, die Nutzer für die Verarbeitung und Bestätigung von Transaktionen zahlen. Die Höhe der Gebühren kann von der Überlastung des Netzes, der Größe der Transaktion und der Ausführungspriorität abhängen. %s Schnell Markt @@ -113,7 +116,9 @@ Zum Anbieter gehen Zum Token Importieren + In Arbeit Später + %1$s übrig Gesperrt Hauptnetz Netzgebühr @@ -126,6 +131,7 @@ Primär Karte Passphrase Einfügen + Datenschutzbestimmungen %1$s-%2$s %1$s — %2$s Weiterlesen @@ -153,6 +159,7 @@ Unterstützung Tauschen Allgemeine Geschäftsbedingungen + Nutzungsbedingungen Heute Transaktion fehlgeschlagen Transaktionen @@ -168,7 +175,9 @@ Vertragsadresse Vertragsadresse ist ungültig Bitte wähle das Netzwerk - Dezimalzahl muss eine gültige Ganzzahl sein, bis zu %li + Dieses Token wurde bereits zur Liste hinzugefügt + Token existiert bereits + Dezimalzahl muss eine gültige Ganzzahl sein, bis zu %d Benutzerdefinierte Ableitung(derivation) E. g. m/00\'/0000\'/0\'/0/0 Benutzerdefinierte Ableitung (Derivation) eingeben @@ -256,7 +265,6 @@ Durch die Nutzung der Swap-Funktion erklärst du dich mit den folgenden Bedingungen des Anbieters einverstanden %s Durch die Nutzung der Swap-Funktionalität erklärst du dich mit des Anbieters %1$s und %2$s einverstanden Weitere Anbieter werden bald folgen.\nBleib dabei! - Datenschutzbestimmungen Anbieter Bester Preis Verfügbar bis zu %s @@ -264,7 +272,6 @@ Für dieses Paar nicht verfügbar Erlaubnis erforderlich Empfohlen - Nutzungsbedingungen Keine Token gefunden. Bitte versuche eine andere Anfrage ID: %s Transaktions-ID kopiert @@ -290,6 +297,7 @@ Unbegrenzt Karte bestellen Karte scannen + Diese Information ist KI generiert Um den Zugangscode zu ändern, halte die Karte wie oben gezeigt an das Gerät und entferne sie erst am Ende des Vorgangs. Um den Passcode zu ändern, halte die Karte wie oben gezeigt an das Gerät und entferne sie erst am Ende des Vorgangs. Um die Wallet zu erstellen, halte die Karte wie oben gezeigt an das Gerät und entferne sie erst am Ende des Vorgangs. @@ -338,15 +346,18 @@ Um mit dem Kauf, Tausch oder Erhalt dieses Vermögenswerts zu beginnen, füge diesen Token zu mindestens 1 Netzwerk hinzu Dieses Asset ist nicht verfügbar Zum Portfolio hinzufügen - Token hinzufügen + Hinzufügen Verfügbare Netzwerke Mein Portfolio Markt Um Adressen für ausgewählte Netzwerke zu generieren, musst du eine Tangem-Karte scannen + Um Token hinzuzufügen, rufe dies auf oder tippe auf die Suchleiste + Die Daten dieses Abschnitts stammen aus den folgenden Netzwerken: %s Die Daten konnten nicht geladen werden... + Keine Daten Schnelle Aktionen Ergebnis - Token unter 100k Marktkapitalisierung anzeigen + Token unter 100k USD Marktkapitalisierung anzeigen Token anzeigen Kein Ergebnis Netzwerk auswählen @@ -369,7 +380,7 @@ Bewertung, basierend auf %d Bewertungen, basierend auf %d - Blockchain-Site + Webseite Kaufdruck Die Differenz zwischen Käufer- und Verkäufervolumen Umlaufmenge @@ -379,7 +390,6 @@ Vollständig verwässerte Bewertung Der theoretische Gesamtwert einer Kryptowährung, wenn alle Coins, die existieren könnten, im Umlauf sind, einschließlich derjenigen, die derzeit nicht im Umlauf sind Entstehungsdatum - Leer Hoch Inhaber/ Halter Die Änderung der Anzahl der Token-Inhaber innerhalb eines bestimmten Zeitraums @@ -390,8 +400,8 @@ Liquiditätsindex Leer Niedrig - Marktkapitalisierung - Der Gesamtmarktwert einer Kryptowährung, berechnet durch Multiplikation des aktuellen Preises der Münze mit der Gesamtzahl der im Umlauf befindlichen Münzen + MarketCap + Der Gesamtmarktwert einer Kryptowährung, berechnet durch Multiplikation des aktuellen Preises des Coins mit der Gesamtzahl der im Umlauf befindlichen Coins. Marktbewertung Position im Krypto-Rating zwischen allen Coins basierend auf der Marktkapitalisierung Maximale Versorgung @@ -401,12 +411,14 @@ Preisleistung Aufbewahrungsort Sicherheitsbewertung - Leer Soziales Gesamtangebot Die maximale Anzahl von Coins oder Tokens, die jemals für eine bestimmte Kryptowährung existieren können Handelsvolumen (24h) Der Gesamtbetrag einer Kryptowährung, der innerhalb der letzten 24 Stunden gehandelt wurde, wobei das Aktivitäts- und Liquiditätsniveau auf dem Markt angegeben wird + Rufe dies auf oder tippe auf die Suchleiste, um Token direkt vom Markt hinzuzufügen + Token hinzufügen + NFC ist auf deinem Gerät nicht verfügbar Du musst einen einzigen Zugangscode einrichten, um alle deine Karten zu schützen Schützen Du kannst später auf jeder Karte einen individuellen Zugangscode einrichten @@ -450,12 +462,12 @@ Backups anlegen Lese mehr über die Seed-Phrase - leer + Schreibe diese %d-Wörter in der unten angegebenen Reihenfolge auf und bewahre sie an einem sicheren und geheimen Ort auf. Deine Seed-Phrase - leer + %d Wörter Um deine Wallets zu importieren, gib bitte deine Seed-Phrase in das folgende Feld ein @@ -465,7 +477,7 @@ Seed-Phrase verwenden Ungültige Seed-Phrase. Bitte überprüfe die Wortreihenfolge. Ungültige Seed-Phrase. Bitte überprüfe die Rechtschreibung. - veralteter Standard + Veralteter Standard Um zu überprüfen, ob du deine Seed-Phrase richtig aufgeschrieben hast, gib bitte das 2., 7. und 11 Wort ein. Eine letzte Prüfung! Um den Sicherungsvorgang zu starten, füge bis zu zwei Sicherungskarten hinzu. @@ -571,18 +583,11 @@ Die Gebühr, die für die Nutzung jeder nicht ausgegebenen Transaktionsausgabe (UTXO) im Kaspa-Netzwerk erforderlich ist. Je mehr UTXOs du in einer Transaktion verwenden, desto höher ist die Gebühr. KAS per UTXO %1$s, %2$s - Adresse Ziel-Tag Adresse eingeben Die Adresse stimmt mit der Adresse Ihrer Brieftasche überein - Ungültiges Tag. Es wird der Transaktion nicht hinzugefügt. Ungültiges Memo. Sie wird der Transaktion nicht hinzugefügt. - Tag Memo - inkl. Gebühr - Niedrig - Normal - Priorität Überprüfe deine Netzwerkverbindung Informationen zur Netzwerkgebühr nicht erreichbar Von @@ -597,7 +602,7 @@ Abdeckung der Netzgebühren Unzureichende Mittel für die Überweisung, da die Summe aus Gebühr und Überweisungsbetrag das bestehende Guthaben übersteigt Gesamtbetrag übersteigt den Saldo - Das Konto wird von der Blockchain gelöscht, wenn der Kontostand unter die Mindesteinlage fällt. Bitte belasse %s auf deinem Konto. + Ein Guthaben von mindestens %s ist erforderlich, um dein Konto in der Blockchain aktiv zu halten und Sicherheitsrisiken zu vermeiden. Dieser Betrag verbleibt auf deinem Guthaben und kann nicht abgehoben werden. Mindesteinlage Der Kommissionsbetrag ist %s mal der empfohlene Betrag. Stelle sicher, dass die benutzerdefinierten Einstellungen korrekt sind. Die individuelle Gebühr ist hoch @@ -631,26 +636,24 @@ Versende %s Du sendest **%1$s** inklusive der Netzwerkgebühr %2$s Du sendest **%1$s** und %2$s - Senden %s - Gesamt - %1$s und %2$s werden gesendet - ≈ %1$s (inkl. Gebühr: %2$s) - %s wird gesendet + Du sendest **%1$s** + Die Netzwerkgebühr wird durch die Nutzung von %1$s Energieträgern gedeckt. + Die Netzwerkgebühr wird durch das Ausgeben von %1$s Energieträgern reduziert. + inklusive einer Netzgebühr von %1$s Die Transaktion wurde erfolgreich signiert und an den Blockchain-Knoten gesendet. Die Walletbilanz wird aktualisiert %1$s ist ein Vermögenswert im Tron-Netzwerk. Um die Gebühr zu berechnen und eine Transaktion durchzuführen, musst du etwas Tron (TRX) auf deinem Konto einzahlen. - Ungültige Adresse Transaktion gesendet Bereite das Scannen der Karte vor, die du einrichten möchtest. Entferne diese Wallet Hiermit wird die Wallet aus der Anwendung entfernt. Die Wallet selbst kann wieder hinzugefügt werden. Name - Aktiv - Um deine Kryptos zu unstaken, klick hier. Die Anzahl der zu stakenden Krypros muss mindesten %s betragen + Der Stakingbetrag wird aufgrund der Netzwerkregeln auf %1$s TRX aufgerundet. Nicht gestakte beanspruche Jährliche prozentuale Rendite Die jährliche prozentuale Rendite, die du durch die Teilnahme am Staking erzielen kannst. Effektiver Jahreszins + Belohnungen sammeln sich täglich automatisch in deinem Staking-Konto an. Verfügbar Durchschnittliche Belohnungsquote Was sit Staking? @@ -658,25 +661,40 @@ Marktbewertung Metriken Mindestanforderungen - Keine Belohnungen zu beanspruchen. + Keine Belohnungen Belohnungen beanspruchen Eine Möglichkeit, Staking-Belohnungen zu erhalten. Es kann automatisch oder manuell beansprucht werden. Belohnungszeitplan Dabei handelt es sich um einen Zeitplan, der festlegt, wann die Teilnehmer am Staking ihre Belohnungen erhalten. - Belohnungen, die du beanspruchen kannst: %s + Belohnungen %s Staking %s Entbindungsdauer Der Zeitraum, den du nach der Beantragung der Abhebung von Geldern aus dem Staking warten musst, bevor die Token verfügbar werden. Aufwärmphase Die zugewiesene Zeit für die Aktivierung der Teilnahme am Staking. + Indem Du die Staking-Funktionalität nutzen, stimmst Du den %1$s und %2$s des Anbieters zu. + Gesperrt Migrieren Natives Staking + Verdiente Belohnungen werden an deine Wallet gesendet und stehen sofort zur Verwendung zur Verfügung + Sicher staken und tägliche Belohnungen verdienen. + Sicher staken und stündliche Belohnungen verdienen. + Sicher staken und monatliche Belohnungen verdienen. Mit dem Staking kannst Du %1$s verdienen. Deine Staking-Belohnungen erhältst Du jeden tag. Mit dem Staking kannst Du %1$s verdienen. Deine Staking-Belohnungen erhältst Du jede Stunde. Mit dem Staking kannst Du %1$s verdienen. Deine Staking-Belohnungen erhältst Du jeden Monat. Mit dem Staking kannst Du %1$s verdienen. Deine Staking-Belohnungen erhältst Du jede Woche. + Sicher staken und wöchentliche Belohnungen verdienen. Verdiene Staking-Belohnungen - Die Belohnungen werden sofort nach dem unstaken gestoppt. Der unstakingprozess dauert %s. + Beim Staking im %1$s -Netzwerk mit einem neuen Validator werden alle zuvor eingesetzten Kryptos automatisch an diesen Validator übertragen + Reinvestiert Deine verdienten Prämien in Deinen Einsatzbetrag und erhöht so den potenziellen Gewinn. + Entsperre dein Geld, um es aus dem Staking-Prozess abzuheben. Das Freischalten nimmt %s. + Nach Ablauf der 21-tägigen Bindungsfrist kannst Du über Dein Guthaben verfügen. Die Prämie wird zusammen mit dem ungestaketen Guthaben abgehoben. + Deine Assets steht Dir nach Ablauf der Frist für die Aufhebung der Bindung %s zur Verfügung. + Du kannst Dein Guthaben jetzt abheben. Es steht Dir sofort zur Verfügung. + Wenn du im Tron-Netzwerk mit einem neuen Validator stakest, werden alle zuvor eingesetzten Assets automatisch an diesen Validator übertragen. + Vorbereitung + Bereit zum Abheben Erneut binden Erneut staken Belohnungen erneut staken @@ -695,17 +713,23 @@ Belohnungen Stake gesperrt Mehr staken + Du setzt %1$s ein und erhältst jährlich %2$s + Zum Entsperren antippen + Zum abheben antippen Stake %s Staking beenden%s + Die Transaktion wird bearbeitet! Derzeit findet eine Validierung in der Blockchain statt. Dies kann einige Minuten dauern. + Lösen der Bindungen Gelocktes unlocken Unstaken - Prüfe, was nicht eingesetzt wurde, um dein Vermögen zu beanspruchen Staking beenden + Unstake Assets %s Validator/ Prüfer Validatoren Abstimmung Abstimmung gesperrt Zurückziehen + Deine Einsätze Bewahre deine Krypto-Assets sicher auf, während die privaten Schlüssel auf deiner Karte bleiben Revolutionäre Hardware-Wallet Bis zu 3 physische Karten pro Wallet @@ -729,7 +753,6 @@ Der Tausch dieser Menge ausgewählter Token hat erhebliche Auswirkungen auf den Preis und verringert dein Ergebnis. Unzureichende Mittel Erlaubnis erteilen - In Arbeit Tauschen Du erhältst Token auswählen @@ -834,6 +857,8 @@ Wallet-Einstellungen Tangem Verwende %s oder scanne eine Karte, um den Zugriff auf deine Wallet freizuschalten. + Das Genehmigungsverfahren ist derzeit im Gange und wird in Kürze abgeschlossen sein + Genehmigung läuft Es scheint, dass die Aktivierung der Karte nicht korrekt abgeschlossen wurde. Dies kann an einem Problem mit dem NFC-Modul deines Gerätes oder an einem falschen Tippen der Karte auf dein Gerät liegen. Bitte wende dich an unser Support-Team, um Unterstützung zu erhalten. Aktivierungsfehler Laut den Entwicklern des BNB-Netzes wird die Unterstützung für den BEP-2-Standard im Juni 2024 enden. Um den Verlust von Vermögenswerten mit diesem Standard zu vermeiden, konvertiere bitte in den BEP-20 Standard. Nutze gerne unseren Swap-Service, um sie auf das BNB Smart Chain Netzwerk zu übertragen. @@ -875,8 +900,8 @@ Auf dieser Karte sind nur noch %s Unterschriften übrig. Du musst dein gesamtes Guthaben abheben. Geringe Anzahl von Unterschriften Token in verschiedenen Netzwerken können unterschiedliche Adressen haben. Überprüfe bei der Überweisung noch einmal, ob deine Adresse mit der des Netzwerks übereinstimmt. - Migration von MATIC zu POL MATIC wird auf POL migriert. Es gibt jedoch keine Frist, und MATIC wird noch nicht abgeschafft. Du kannst MATIC-Token weiterhin verwenden oder sie über eien Exchange gegen POL tauschen. + Migration von MATIC zu POL Verwende deine Karte, um eine Adresse für das %d-Netz zu erhalten Verwende deine Karte, um mehrere Adressen für die %d-Netzwerke zu erhalten diff --git a/core/res/src/main/res/values-es/strings-blockchain.xml b/core/res/src/main/res/values-es/strings-blockchain.xml new file mode 100644 index 0000000000..709f3ab9dd --- /dev/null +++ b/core/res/src/main/res/values-es/strings-blockchain.xml @@ -0,0 +1,25 @@ + + + Por defecto + Legado + No se pudo obtener la tarifa + Debido a limitaciónes sobre %1$s, solos %2$d UTXO pueden caber en una sola transacción. Esto significa que solo puedes enviar %3$s o menos. Debe reducir la cantidad. + No hay fondos suficientes para la transacción. Por favor, recargue su cuenta. + Ocurrió un error. Código: %s + Para utilizar la red %1$s, debe pagar la reserva de cuenta (%2$s%3$s), que bloquea y oculta ese monto indefinidamente + La cuenta de destino no está activa. Envíe %s o más para activar la cuenta. + Para crear una cuenta, envíe fondos a esta dirección + El monto mínimo es %s + La cantidad es demasiado baja + Tarifa no válida + El saldo mínimo es %s + No se ha creado la cuenta de destino. El monto a enviar debe ser %s + comisiones o más + Error desconocido + El monto excede el saldo + Cantidad no válida + La tarifa excede el saldo + El monto total excede el saldo + No, enviar todo + Réduire de %s XTZ + Para evitar pagar una comisión mayor la próxima vez que recargue su billetera, reduzca el monto en %s + diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml new file mode 100644 index 0000000000..3c8fb3e199 --- /dev/null +++ b/core/res/src/main/res/values-es/strings.xml @@ -0,0 +1,923 @@ + + + Elige red + Agregue un token personalizado + Gestionar tokens + Envíe solo %1$s (%2$s) desde redes como %3$s a esta dirección. Usar otros tokens y redes puede resultar en la pérdida de fondos. + Cómo escanear + Solicitar soporte + Inténtelo de nuevo + Esta función está desactivada en el modo Demo + Razón: %s + No se puede completar una transacción + El seleccionado no admite la red %1$s + Para activar el %1$s cifrado criptográfico de la blockchain, necesitarás reiniciar la billetera a los ajustes de fábrica. Por favor, retire sus fondos antes de hacerlo para asegurarte de no perderlos, y luego complete el proceso de reinicio. El acceso a la billetera actual no será posible después del reinicio. + Esta tarjeta no admite %1$s tokens de red debido a una limitación del firmware. + ¿Tiene dificultades para escanear su tarjeta? + Esta tarjeta no está diseñada para funcionar con Tangem + Tarifa por defecto + Habilite las Tarifas predeterminadas para establecer automáticamente las tarifas de transacción y omitir la página de Tarifas al enviar fondos. Siempre puede volver a esta página si es necesario. + Vaya a ajustes para habilitar la autenticación biométrica en la Tangem App + Habilitar autenticación biométrica + Esto eliminará todos los códigos de acceso guardados de la billetera. Cualquier operación posterior con la billetera requerirá introducir el código de acceso. + Eliminar la tarjeta guardada borra todos las billeteras guardadas y sus códigos de acceso de la app. + Guardar código de acceso + Se solicitará la autenticación biométrica en lugar del código de acceso para interactuar con su tarjeta. + Mantener la billetera en la app + Habilite para vincular todos las billeteras a la app Tangem. Se requerirá autenticación biométrica para desbloquear la app. La firma de transacciones requiere tocar su tarjeta Tangem. + Oscuro + Claro + Predeterminado del sistema + Tema + Ajustes de la app + Para ocultar o mostrar tus saldos, simplemente gira la pantalla de tu dispositivo hacia abajo, o desactívalo en Ajustes + No mostrar de nuevo + Entendido + Los saldos están ocultos + Por favor, escanee la tarjeta + Por favor, inténtelo de nuevo en 30 segundos o escanee la tarjeta + Demasiados intentos + Ha desactivado la autenticación biométrica en su teléfono y no podrá guardar billeteras en la aplicación. Para guardar billeteras, active la función de autenticación biométrica en los ajustes de su teléfono. + Iniciar proceso de backup + Con su tarjeta bancaria o cuenta bancaria + + %d tarjeta + %d tarjetas + + Desactive esta opción si no quiere que esta tarjeta se use para reiniciar códigos de acceso en otras tarjetas de esta billetera. Tenga en cuenta que esto también evitará que reinicie el código de acceso en esta tarjeta. + Permite usar esta tarjeta para reiniciar el código de acceso en otras tarjetas de esta billetera + Recuperación de código de acceso + Reiniciar + ¿Está seguro de que quiere hacer esto? + Cambiar código de acceso + El código de acceso se cambiará solo en esta tarjeta + Todas las tarjetas de la billetera seleccionada se han restablecido a la configuración de fábrica. Ahora puedes crear una nueva billetera. + Reinicio completado + ¿Quiere restablecer la siguiente tarjeta en esta billetera? + Reinicio de tarjeta + Recomendamos completar el proceso de reinicio para todas las tarjetas en esta billetera. + No ha reseteado todas sus tarjetas + Restablecer a ajustes de fábrica + Modo de seguridad + Ajustes de la tarjeta + Además de la tarifa de red, la red Cardano cobra %1$s ADA al realizar transacciones con el %2$s token + Requisitos de transacción de Cardano + Para realizar una %1$s transacción, deba depositar algo de ADA para cubrir la tarifa de red y el valor mínimo de ADA (se recomiendan 5 ADA) + ADA insuficiente para la transferencia de tokens + Deba mantener algo de ADA porque tiene algunos tokens en la blockchain de Cardano + ADA insuficiente + Aceptar + Acceso denegado + Todos + Autorizar + Analítica + Aplicar + Aprobación + Aprobar + Atención + Soldo: %s + Soldo + autenticación biométrica + biometría + Comprar + Ir a %1$s + No ha otorgado acceso a su cámara, cambie su configuración de privacidad + Cancelar + Elige una acción + Reclame recompensas + Cerrar + Continuar + Copiar + Copiar la dirección + Crear + %1$s (%2$s) + Personalizado + + %d día + %d días + + Suprimir + Desactivado + Listo + Activar + Activado + Error + Intercambie + Explore + Explore historial de transacciones + Explorador(a) + Las tarifas de red son cargos que los usuarios pagan para procesar y confirmar transacciones. El importe de la tarifa puede verse afectado por la congestión de la red, el tamaño de la transacción y la prioridad de ejecución. %s + Rápido + Mercado + Lento + Velocidad y tarifa + Obtener direcciones + Ir al proveedor + Ir al token + Importe + En progreso + Más tarde + %1$s quedan + Bloqueado + Red principal + Tarifa de la red + La cantidad enviada se reducirá en %1$s (%2$s) para cubrir el nivel de tarifa seleccionado + Siguiente + No + Ninguna dirección + Ahora + OK + Tarjeta principal + Frase de contraseña + Pegar + Política de privacidad + %1$s-%2$s + %1$s — %2$s + Leer más + Recibir + Rechazar + Recargar + Renombrar + Guarde + Guardar cambios + Buscar en el mercado + Buscar tokens + Seed phrase + Seleccione una acción + Vender + Enviar + El servidor no está disponible, por favor inténtelo de nuevo más tarde + Compartir + Firme + Firme y envíe + Stake + Staking + Empezar + Enviar + Con éxito + Soporte + Intercambiar + términos y condiciones + Condiciones de uso + Hoy + Transacción fallida + Transacciones + Transferencia + Entiendo + Hubo un error. Por favor inténtelo de nuevo. + Inaccesible + Termine el staking + + Dirección del contract copiada + Redes disponibles + Agregue un token + Dirección del contract + La dirección del contract no es válida + Por favor, seleccione la red + Este token ya ha sido agregado a la lista + Este token ya existe + Los decimales deben ser un número entero válido, hasta %d + Derivación personalizada + Por ejemplo m/00\'/0000\'/0\'/0/0 + Introduzque una derivación personalizada + Decimales + Ruta de derivación + Por defecto + Coin de tipo BIP44 + La ruta de derivación que ha ingresado no es válida + Por ejemplo USD coin + Nombre + No elegido + Red + Red de tokens + Puede agregar manualmente un token que no sea compatible de forma nativa con Tangem + Por ejemplo USDC + Símbolo + Símbolo del token + Este token/red ya ha sido añadido a su lista + Tenga en cuenta que cualquiera puede crear tokens. Tenga cuidado al añadir tokens fraudulentos, pueden no costar nada. + Tenga cuidado al agregar tokens fraudulentos, pueden no costar nada + Tenga en cuenta que cualquiera puede crear tokens + Comprar Tangem Wallet + Chat + Código de acceso + Deberá ingresar la contraseña correcta antes de escanear la tarjeta. + Sostenga la tarjeta firmemente + Este mecanismo protege contra ataques sin contacto a la tarjeta. Hay un retraso entre la recepción y la ejecución del pedido. Después de la primera transacción firmada, ese teléfono se asociará con la tarjeta y las transacciones se firmarán inmediatamente. + Contraseña + Antes de ejecutar un comando que cambie el estado de la tarjeta, deberá ingresar una contraseña. + Programa de referidos + Gire la pantalla de su dispositivo hacia abajo para ocultar y mostrar rápidamente los saldos + %s hashes + ID de la tarjeta + Contacta con el equipo de soporte + Vincular más tarjetas + Moneda de la aplicación + Girar para ocultar saldos + Editor + Firmado + Enviar comentarios + Detalles + Compruebe su conexión a internet o cambia a una red diferente + Condiciones de uso + Ha usado una tarjeta de otro wallet. Toque la tarjeta asociada con esta billetera + Mis tokens + Aún no ha añadido ningún token. Agregue tokens a través de Market para hacer swap + No se puede intercambiar por %s + Proporcionado por + Estado + Tangem ofrece swaps de tokens a través de proveedores externos según los términos de cada proveedor + Elige un proveedor + Ha ocurrido un error. Código: %s + El proveedor seleccionado no está disponible actualmente. Inténtelo de nuevo más tarde. (Código: %s) + Los cambios no están disponibles en este momento. Inténtelo de nuevo más tarde. (Código: %s) + Cantidad estimada + Intercambio por %s + Visite el sitio web del proveedor para reembolsar su dinero + Operación fallida por el proveedor + El monto de la transacción fue reembolsado en %1$s a su billetera debido a OKX o reglas puente. %2$s + El monto fue reembolsado en %1$s (red %2$s) + Visite el sitio web del proveedor para la verificación + Verificación KYC requerida por el proveedor + Cancelado + Confirmado + Esperando confirmación + En proceso de confirmación + Intercambio + En intercambio + En intercambio + Fallido + Depósito recibido + Esperando depósito + Esperando depósito + Reembolsado + Transfiriendo a su cuenta + Enviando... + Enviado + Datos proporcionados por el proveedor. La cantidad estimada puede cambiar debido a las condiciones del mercado. + Estado del intercambio + Verificación requerida + Esperando hash de transacción + Lista de todos los tokens agregados a su billetera + Buscando las mejores tarifas… + Tasa flotante + Al utilizar la función de intercambio, acepta el %s + Al utilizar la función de intercambio, aceptas las condiciones de %1$s y %2$s del proveedor + Próximamente habrá más proveedores.\n¡Estén atentos! + Proveedor + Mejor tarifa + Disponible hasta %s + Disponible desde %s + No disponible para este par + Permiso requerido + Recomendado + No se encontraron fichas. Por favor intenta con otra solicitud + ID : %s + ID de transacción copiado + Con otra moneda en su billetera + La siguiente información es opcional. Puede borrarla si no quiere compartirla. + Cuéntenos qué funciones echa de menos y trataremos de ayudarle. + Por favor, díganos qué tarjeta tiene + Hola equipo de soporte, + Por favor, cuéntenos más sobre tu problema. Cada pequeño detalle puede ayudar. + Mis recomendaciones + No se puede escanear una tarjeta + Comentarios + Comentarios en Tangem + No se puede completar una transacción + Esta transacción + La red cobrará una tarifa de aprobación de token para verificar que está autorizando el uso de su token para el swap. + Especifique el límite aprobado para el token seleccionado. + Montante %s + La función Aprobar es necesaria para otorgar permiso a otra dirección para usar una cantidad específica de sus tokens. Por diseño, los contratos inteligentes no pueden acceder a sus tokens sin su aprobación. Al \"desbloquear\" sus tokens, autoriza al contrato inteligente de StakeKit a usarlos. Los mineros de la red reciben una tarifa de gas (pagada por usted) por registrar esta acción en la cadena de bloques. Puede apostar su token después de dar su aprobación. + Para continuar, debe autorizar el contrato inteligente de StakeKit para utilizar su %s + Para continuar, de autorización a los smart contracts de %1s para usar su %2s + Dar autorización + Ilimitado + Ordenar + Escanee + Esta información fue generada con IA + Toque para cambiar la contraseña + Toque para cambiar la contraseña + Para crear la billetera, toque la tarjeta como se muestra arriba y no la retire hasta el final de la operación + Toque la tarjeta no. %s de la billetera + Coloque para escanear + Toque para firmar + Coloque la tarjeta + Ha actualizado la biometría, escanee su tarjeta para entrar + Su saldo debe ser mayor que el valor de la tarifa para hacer una transferencia + Saldo insuficiente + No tiene suficiente Mana para esta transacción. Por favor, espere hasta que el Mana se recargue. Su saldo de Mana es %1$s/%2$s + Mana insuficiente + Solo puede transferir %s debido al límite de Mana impuesto por la red Koinos + Límite de Mana + La red Koinos requiere Mana para las tarifas de red. Tienes %1$s/%2$s Mana + Nivel de Mana + Para comenzar a seguir tus activos crypto y transacciones, agregue tokens + Gestionar tokens + Para acceder a todas las redes necesita escanear la tarjeta + Escanee su tarjeta + Benefíciese de %1$s tarifa de servicio en intercambios a través de Changelly del %2$s al %3$s de febrero + Intercambio con Changelly, %s tarifa + Tokens + Agregar + Editar + Capitalización de mercado de la moneda + Blockchain en la que se creó inicialmente la criptomoneda + Red nativa + El uso de redes no nativas para tokens permite la interoperabilidad entre blockchains, permitiendo que los activos se utilicen en diversas aplicaciones descentralizadas y smart contracts en diferentes plataformas. Sin embargo, esto a menudo implica un custodio o smart contract para mantener el activo original de forma segura, introduciendo centralización y riesgo de contraparte. + No es la blockchain original o primaria donde se aloja el token + Redes no nativas + Elegir redes + Billetera + No se pudo encontrar este token, puedes añadirlo manualmente + + %1$d de %2$d billetera + %1$d de %2$d billeteras + + Eliminar + por ejemplo, BTC I trust, hodl I must + Su cartera ha sido actualizada + El token seleccionado no está disponible actualmente para acciones dentro de la billetera cripto. Pero no se preocupa, puede expresar su interés votando a favor. + Votar a favor + Elegir wallet + La billetera no admite más de una red + Para comprar, intercambiar o recibir este activo, agréguelo a su cartera + Este activo no está disponible + Añadir al portafolio + Agregue un token + Redes disponibles + Mi portafolio + Mercado + Para generar direcciones para las redes seleccionadas, debe escanear su tarjeta Tangem + Para agregar tokens, abra esta página o toque la barra de búsqueda + Los datos de este apartado proceden de las siguientes redes: %s + No se pueden cargar los datos… + Sin datos + Acciones rápidas + Resultado + Ver tokens con una capitalización de mercado de menos de $100,000 + Mostrar tokens + Sin resultado + Seleccione una red + Seleccione una billetera + 1 mo + 1 año + 24h + 3mos + 6mos + 7d + Todo + Compradores experimentados + Evaluación + Ordenar por + Mejores Ganadores + Top Perdedores + Tendencias + Acerca de %s + + Basado en %d evaluación + Basado en %d evaluaciónes + + Sitio web + Presión de compra + La diferencia entre el volumen de compradores y el volumen de vendedores + Suministro circulante + El número total de monedas que están disponibles para el comercio y que circulan en el mercado. + Compradores experimentados + Compradores netos con el requisito adicional de tener al menos 100 transacciones salientes + Valoración totalmente diluida + El valor teórico total de una criptomoneda si todas las monedas que podrían existir estuvieran en circulación, incluidas las que actualmente no circulan. + Dato de Genesis + Alto + Titulares + El cambio en el número de poseedores de tokens dentro de un período de tiempo específico + Ideas + Enlaces + Liquidez + El cambio en la cantidad de liquidez disponible para el token durante el período de tiempo especificado + Índice de liquidez + Bajo + Capital. de mercado + El valor total de una criptomoneda calculado multiplicando su precio por la cantidad de monedas en circulación + Evaluación de mercado + Posición en la clasificación de criptomonedas entre todas las monedas según la capitalización de mercado + Suministro máximo + Métrica + Enlaces oficiales + Rendimiento de precios + Repositorio + Puntuación de seguridad + Social + Suministro total + La cantidad máxima de monedas o tokens que pueden existir para una criptomoneda en particular + Volumen de operaciones (24 horas) + La cantidad total de una criptomoneda que se ha negociado en las últimas 24 horas, lo que indica el nivel de actividad y liquidez en el mercado. + Tire hacia arriba o toque la barra de búsqueda para agregar tokens directamente desde el mercado + Agregar tokens + NFC no está disponible en su dispositivo + Deba configurar un único código de acceso para proteger todas sus tarjetas + Proteger + Puede configurar un código de acceso individual en cada tarjeta más tarde + Personalizar + El código de acceso se puede recuperar con una tarjeta vinculada. No guarde todas las tarjetas en un mismo lugar. + Restaurar + Elige cualquier palabra, frase o número que quieras como su código de acceso + Crear un código de acceso + Vuelva a ingresar su contraseña para evitar un error + Vuelva a introducir su código de acceso + El código de acceso debe tener al menos 4 caracteres + El código de acceso introducido no coincide con el código de acceso inicial + Por favor repita la operación. La tarjeta se restablecerá a la configuración de fábrica. + Error de activación + Agregar tokens + Ha agregado una tarjeta de backup. Cuando el proceso de backup finalice, no podrá añadir más tarjetas de backup. Si tiene otra tarjeta, agréguela al backup. ¿Quiere continuar el proceso de backup? + El proceso de backup está parcialmente completo. No puede salir ahora. + La frase de contraseña es una característica de seguridad avanzada utilizada por las billeteras criptográficas. Agrega una palabra o frase adicional de su elección a su frase de recuperación ya existente para desbloquear un conjunto completamente nuevo de direcciones. + Agregar una tarjeta de backup + Escanee la tarjeta no. %d + Hacer backup ahora + Escanee la tarjeta principal + Continuar a mi billetera + Finalizar el backup + Recibir cripto + Escanee la tarjeta principal + Finalizar más tarde + ¿Cómo funciona? + Vamos a generar todas las claves en su tarjeta y crear una billetera segura + Crear una billetera + Crear una billetera + Otras opciones + Sus claves se generarán de forma segura dentro de la tarjeta. No hay seed phrase, lo que significa que nadie puede exportarla ni robarla. + Generar claves de forma privada + Su tarjeta está activada y lista para usar + ¡Éxito! + En este caso, necesitará empezar desde el principio. + ¿Quiere salir del proceso de activación? + Inicializando + Ya se ha creado otra billetera en la tarjeta que está intentando agregar. Si tiene fondos en esta billetera, por favor retírelos y luego reinicie esta tarjeta y agréguela como backup. + Creando un backup + Leer más sobre seed phrase + + Vacío + Escriba estas %d palabras en el orden que se indica a continuación y guárdelas en un lugar seguro y secreto. + + Su seed phrase + + Vacío + %d palabras + + Para importar su billetera, ingrese su seed phrase en el campo de abajo + Generar seed phrase + Importe una billetera + Una seed phrase es una serie de palabras que se permite recuperar su billetera. A diferencia de las claves generadas por la tarjeta, las seed phrases no están protegidas y pueden ser copiadas y robadas. Use esta opción bajo su propia responsabilidad. + Usar seed phrase + Seed phrase no válida. Por favor, comprueba el orden de las palabras. + Seed phrase no válida. Por favor, comprueba tu ortografía. + Legado + Para comprobar si has escrito correctamente su seed phrase, por favor introduce las palabras 2ª, 7ª y 11ª + Bien, vamos a comprobar + Para iniciar el proceso de backup, agregue hasta dos tarjetas de backup. + Puede agregar una tarjeta más o finalizar el proceso de backup + Prepare su tarjeta de backup con el no.%s + Escanee la tarjeta principal para iniciar el proceso de backup. + Prepare la tarjeta principal con el número %s + Su tarjeta billetera está configurada y lista para usar. + Número máximo de tarjetas añadido. Finalice el proceso de backup. + Activando tarjeta + Tarjeta de backup no.%d + Ningunas tarjetas de backup + Notificaciones + Una tarjeta de backup agregada + Prepare su tarjeta + Dos tarjetas de backup agregadas + Para empezar, simplemente recargue la billetera con cualquier cantidad + Para empezar, simplemente recargue la billetera con más de %1$s %2$s + Comprar cripto + Mostrar la dirección de la billetera + Activar una billetera + El proceso de emparejamiento está parcialmente completo. No puede salir ahora. + Si el proceso de creación del wallet se interrumpe de alguna manera, tendrá que empezar de nuevo + Puede hacer backup de sus claves en hasta dos tarjetas Tangem Wallet en blanco. + El código de acceso se puede restaurar con una de las tarjetas de backup. + Todas las tarjetas de backup se pueden usar como funcionales con las claves idénticas. + Podrá establecer un código de acceso para proteger sus billeteras. + Billetera de respaldo + Restaurar código de acceso + Tarjetas idénticas + Código de acceso + Agrupar + Por saldo + Organizar tokens + Desagrupar + Seleccione de la galería + Ajustes + No ha dado acceso a su cámara + Acceso a la cámara denegado + %1$s (%2$s) en la red %3$s + Envíe solo %s a esta dirección. Enviar cualquier otra moneda resultará en su pérdida irreversible. + Muestra un código QR o comparte tu dirección + Participar + Error al cargar la información sobre el programa de referidos. Por favor, inténtelo de nuevo más tarde. + Error al cargar la información sobre el programa de referidos. Código de error: %s. Por favor, inténtelo de nuevo más tarde. + Pagos próximos + Sus amigos compraron + Menos + Más + No hay pagos próximos + + para %d billetera + para %d billeteras + + Obtendrá ^^%1$s^^ por cada wallet comprada por su amigo en su dirección %3$s de la red %2$s ^^30 días después^^ + Usted + Obtendrá un + al comprar una billetera en tangem.com + %s descuento + Su amigo + ¡Código personal copiado! + Su código personal + ¡Compra Tangem Wallet con descuento!\n%s + Recomiende Tangem a sus amigos + Ha aceptado + Al tocar este botón, ud. acepta + del programa de referidos + + %d billetera + %d billeteras + + Restablecer la tarjeta + Entiendo que después de realizar esta acción, ya no tendré acceso a la billetera actual + Entiendo que no puedo usar esta tarjeta para recuperar mi código de acceso en las otras tarjetas de la wallet actual + El restablecimiento de fábrica eliminará completamente la billetera de la tarjeta seleccionada. No podrá restaurar la billetera actual ni usar la tarjeta para recuperar el código de acceso. + El restablecimiento de fábrica eliminará completamente la billetera de la tarjeta seleccionada y lo eliminará de la app. No podrá restaurar la billetera actual. + ¿Tiene una tarjeta bancaria de otro país y un permiso de residencia o registro fuera de la Federación Rusa? + Las tarjetas bancarias rusas no se aceptan actualmente + Inicie sesión en la app y comprueba su saldo sin escanear la tarjeta + Acceder a la app + Permitir el uso de biometría + Se solicitará la biometría en lugar del código de acceso para interactuar con su billetera + Código de acceso + Parece que tiene la autenticación biométrica deshabilitada, es necesaria para guardar billeteras + Activar la autorización biométrica + ¿Le gustaría usar la biometría? + Tenga en cuenta que realizar una transacción con sus fondos seguirá requiriendo su tarjeta + Escanee su tarjeta + Escanee la tarjeta para cambiar sus ajustes. Los cambios afectarán solo a la tarjeta que has escaneado y no afectarán a otras tarjetas vinculadas a su billetera. + ¡Prepare su tarjeta! + Ya incluido en la dirección ingresada + El monto de la comisión es %s veces el monto recomendado. Asegúrese de que la configuración personalizada sea correcta. + Ha especificado una comisión inferior a la cantidad recomendada, lo que podría provocar un retraso en su transacción. ¿Continuar? + Razón: %1$s\nCódigo: %2$s + La transacción está incompleta + Montante + Puede establecer tu tarifa de transacción ajustando el valor en el campo\nSatoshi por vByte. + La tarifa que se cobrará por tu transacción. Puedes establecer tu propio valor. + Tarifa máxima + Este es el coste que está dispuesto(a) a pagar por cada unidad de gas. Cuanto más alto sea el precio del gas, más rápido se procesará su transacción. (Tarifa prioritaria incluida) + Tarifa prioritaria + La tarifa que un usuario puede pagar a los mineros o validadores para acelerar la inclusión de su transacción en un bloque. + La tarifa que se cobra por utilizar cada salida de transacción no gastada (UTXO) en la red Kaspa. Cuanto más UTXO utilice en una transacción, mayor será la tarifa. + KAS por UTXO + %1$s, %2$s + ID de destino + Introduce la dirección + La dirección es la misma que su billetera. + Memo no válido. No se añadirá a la transacción. + Memo + Compruebe su conexión de red + Información de tarifa de red no accesible + Desde + Límite de gas + Este es el máximo de gas que se gastará para completar una transacción o contrato. Un límite de gas evita cargos inesperados o ilimitados al ejecutar una transacción. + Precios del gas + Este es el coste que estás dispuesto a pagar por cada unidad de gas. Cuanto más alto sea el precio del gas, más rápido se procesará tu transacción. + Max + Importe máximo + Tarifa hasta + Memo no válido + Cobertura de tarifa de red + Fondos insuficientes para la transferencia, ya que el total de la tarifa y el importe de la transferencia supera el saldo existente + El total supera el saldo + Se requiere un saldo de al menos %s para mantener su cuenta en blockchain para evitar riesgos de seguridad. Este monto permanecerá en su saldo y no podrá retirarse. + Depósito existencial + El importe de la comisión es %s veces la cantidad recomendada. Asegúrese de que los ajustes personalizados sean correctos. + Los aranceles aduaneros son altos + Debido a las peculiaridades de la red %1$s, la tarifa por transferir el saldo completo es más alta. Para reducir la comisión, puedes dejar %2$s. + La tarifa es más alta + La comisión incluida excede el monto de la transferencia, lo que resulta en un valor negativo + Cantidad no válida + La cantidad mínima de envío es %1$s. Asegúrate de que el saldo restante después del envío no sea menor que %2$s. + La cuenta de destino no está creada. Por favor, cambia la cantidad a enviar. + La cantidad a enviar debe ser al menos %s + Salir %s + Reducir por %s + Reducir a %s + Tenga en cuenta que su transacción puede experimentar retrasos bajo configuraciones específicas de tarifas + Es posible que haya retrasos en las transacciones + Debido a las limitaciones de %1$s, solo %2$s UTXO pueden caber en una sola transacción. Esto significa que solo puedes enviar %3$s o menos. Necesitas reducir la cantidad. + Limitación de transacciones + Opcional + Por favor, alinee su código QR con el cuadrado para escanearlo. Asegúrese de escanear la dirección de la red %s. + Reciente + Destinatario + No es una dirección válida + Asegúrese de que la dirección de la billetera receptora esté en la red %s para evitar perder sus tokens + Destinatario + Un Memo/ID de destino es un ID único para diferenciar transacciones enviadas al mismo destinatario en la misma red. Precaución: Omitir un memo puede llevar a la pérdida de fondos + Mis billeteras + Una forma de medir las tarifas de transacción de Bitcoin. Indique el número de la unidad más pequeña de Bitcoin (Satoshi) por cada byte virtual en una transacción. Cuanto más alto sea el número, más rápido procesarán los mineros la transacción. + Satoshi / vByte + Enviando... + Toque cualquier campo para editarlo + Enviar %s + Está enviando **%1$s** incluida una tarifa de red de %2$s + Está enviando**%1$s** y %2$s + La transacción se firmó con éxito y se envió al nodo blockchain. El saldo de la billetera se actualizará después de un tiempo. + %1$s es un activo en la red Tron. Para calcular la tarifa y realizar una transacción, deba depositar algo de Tron (TRX) en su cuenta. + Transacción enviada + Escanee la tarjeta que quiere configurar + Olvidar la billetera + Esto eliminará la wallet de la aplicación. La wallet en sí puede\nañadirse de nuevo. + Nombre + El monto del staking debe ser al menos %s + El monto del staking se redondeará a %1$s TRX debido a las reglas de la red. + Unstaking de la reclamación + Porcentaje de rendimiento anual + El porcentaje de rendimiento anual que podrá ganar como participante en el staking. + APR + Las recompensas se acumulan automáticamente en su saldo de apustaking esta cada día. + Disponible + Tasa de recompensa promedio + ¿Qué es el Staking? + %s beneficio estimado + Calificación de mercado + Métrica + Mínimo requerido + No hay recompensas para reclamar + Reclamo de recompensa + Método para recibir recompensas por apuesta.\nPuede ser automático, donde la recompensa se acredita en tu dirección, o manual, donde debes retirar la recompensa creando una transacción para recibirla. + Calendario de recompensas + Este es un cronograma que determina cuándo los participantes reciben sus recompensas. + Recompensas para reclamar: %s + Staking %s + Periodo de disociación + El período que debes esperar después de solicitar el retiro de fondos del staking antes de que los tokens estén disponibles. + Periodo de calentamiento + El tiempo permitido para activar la participación en la apuesta. + Al utilizar la función de staking, usted acepta %1$s y %2$s del proveedor + Bloqueado + Migrar + Native staking + Recomp. won se le enviará y estará disponible para su uso inmediato + El staking le permite ganar %1$s. Sus recompensas por apostar llegan todos los días. + El staking le permite ganar %1$s. Sus recompensas del staking llegan cada hora. + El staking le permite ganar %1$s. Sus recompensas del staking llegan todos los meses. + El staking le permite ganar %1$s. Sus recompensas por apostar llegan todas las semanas. + Gane recompensas por staking + Sus fondos estarán disponibles para su uso después del período de desvinculación de %s. + Ya puede retirar sus fondos, estarán disponibles para usar de inmediato. + Hacer staking en la red Tron con un nuevo validador transferirá automáticamente todos los fondos de staking a este validador. + Listo para retirar + Reunir + Haga el staking de nuevo + Restaking de las recompensas + Revocar + Revotar + Auto + Manual + Bloquear + Día + Cada día + Época + Era + Hora + Mes + Semana + Recompensas + El stake está bloqueado + Hacer más staking + Toque para desbloquear + Toque para retirar + Stake %s + El unstaking de %s + ¡La transacción se está procesando! La validación está en curso en la cadena de bloques. Esto puede tardar unos minutos. + Desunión + Desbloquear + Sin staking + Unstaking + Validador + Validadores + Votar + Voto bloqueado + Retirar + Sus stakes + Almacene sus activos crypto de forma segura manteniendo las claves privadas contenidas en su tarjeta + Billetera de hardware revolucionaria + Hasta 3 tarjetas físicas por billetera + Backup ultra seguro + Una billetera de hardware para su Bitcoin, Ethereum y muchas más monedas simultáneamente – todo en una sola tarjeta + Miles de monedas + Úselo en cualquier lugar, en cualquier momento. Sin cables ni baterías. Solo toque la tarjeta con su teléfono cuando necesites su cripto. + La billetera para todos + Descubra Tangem + Intercambie, compre NFT, haga préstamos y depósitos en más de 100 servicios descentralizados diferentes + Compatible con Web 3.0 + Intercambie más tokens a mejores tasas directamente en su billetera. + ¡Nuevo proveedor de intercambio disponible! + El monto incluye:\n• Tarifas del proveedor de servicios\n• Tarifas de red por enviar %s desde el intercambio a la dirección del usuario. + El importe incluye los honorarios del proveedor de servicios. + Tarifa + Todos los exchanges descentralizados requieren aprobaciones para evitar que los smart contracts accedan a su billetera sin su permiso. Por diseño, los smart contracts no pueden acceder a tus tokens a menos que lo apruebes. Al \"desbloquear\" sus tokens, autoriza al smart contract de 1-inch a gastarlos. Los mineros de la red reciben una tarifa de gas (pagada por voz) para registrar esta acción en la blockchain. Puede intercambiar su token después de dar la aprobación. + Aprobar + Error en la estimación de la tarifa. Envíe sus comentarios al servicio de asistencia. + Usted intercambia + Hacer swap de esta cantidad de tokens seleccionados causará un impacto significativo en el precio y reducirá su resultado. + Fondos insuficientes + Dar autorización + Intercambie + Usted recibe + Elige token + no disponible + Saldos ocultos + Saldos mostrados + Cancelar + Esta operación no está disponible actualmente. Por favor, inténtalo de nuevo más tarde. + Comprar %s no está disponible en este momento. Por favor revise sus actualizaciones. + No tiene fondos para vender. Recargue su cuenta para poder vender fondos desde ella. + No tienes fondos para enviar. Recarga tu cuenta para poder enviar fondos desde ella. + El servicio de intercambio %s no está disponible en este momento. Por favor consulte nuestras actualizaciones. + La venta de fondos estará disponible una vez que la(s) transacción(es) pendiente(s) en la red %s se complete + El envío de fondos estará disponible una vez que se completen las transacciones pendientes en la red %s. + La venta de %s no está disponible en este momento. Por favor consulte nuestras actualizaciones. + El staking %s no está disponible en este momento. Por favor consulte nuestras actualizaciones. + Generar XPUB + Ocultar + Está a punto de ocultar este token de la pantalla principal. Puede volver a agregarlo en cualquier momento a través de la página de gestión de tokens. + Ocultar %s + Ocultar el token + El staking le permite ganar %1$s y obtener recompensas cada %2$s días + Gane hasta %s recompensa del staking por año + Token de %1$s en la red %%image%% %2$s + Token en la %image% red %1$s + El token %1$s (%2$s) es la moneda principal en la red %3$s y no se puede ocultar mientras tengas otros tokens de esta red en la lista + No se puede ocultar %s + Cambie este token por otro por una tarifa de servicio de %1$s del %2$s al %3$s de febrero. + Intercambio con Changelly, %s tarifa + Intercambie ahora + contacto: %s + Aún no tiene ninguna transacción + Error al cargar el historial de transacciones.\nHaga clic en el botón de recarga para actualizar la información. + Múltiples direcciones + El historial de transacciones no está soportado actualmente para esta blockchain. Pero no se preocupe, ¡estamos trabajando en ello! Mientras tanto, puede consultarlo en el explorador. + Operación + desde: %s + a: %s + Inténtelo de nuevo + Ha escaneado la misma tarjeta. Para crear una billetera gemela, necesite escanear la tarjeta con no. %d + Ha escaneado la tarjeta gemela incorrecta. Por favor, intente con otra + Esta que tiene en sus manos y la otra con el número %s.\n\nAmbas tarjetas pueden usarse para extraer fondos de esta billetera. + Una billetera. Dos tarjetas. + Escanee la tarjeta no. %s + Creando la billetera + Escanear tarjeta gemela no. %s + Preparando la tarjeta + Tangem Twin + Esta acción es irreversible. No tendrá acceso la billetera antigua. + Toque la tarjeta gemela con el número %s y no la retira hasta el final de la operación + Use %s o escanee una tarjeta para tener acceso a su billetera + Manténgase actualizado con las últimas funciones y noticias + Sea el primero en enterarte de nuevas promociones + ¿Quiere utilizar\nnotificaciones push? + Agregar una nueva billetera + ¿Está seguro(a) de que quieres eliminar esta billetera? + Ha ocurrido un error, por favor escanee su tarjeta para iniciar sesión + Esta billetera ya se ha guardado, puede agregar otro + La billetera con el nombre %s ya existe + Nombre de la billetera + Renombrar la billetera + Desbloquear todo + Desbloquear todo con %s + La blockchain está Inaccesible. Inténtelo más tarde + Escanee la tarjeta + Solicitud para firmar un mensaje.\n\n%s + Dapp %1$s, solicitando\nfirmar transacción BNB.\n\n%2$s + Orden de intercambio por %1$s\nPrecio: %2$s\nCantidad a recibir: %3$s\nCantidad a pagar: %4$s + Detalles de la transacción:\nDesde: %1$s\nA: %2$s\nMontante: %3$s + El portapapeles contiene un código de WalletConnect. Use el valor copiado o escanee el código QR + Solicitud para crear una transacción para %1$s\n%2$s\n\nMontante: %3$s\nComisión: %4$s\nTotal: %5$s\nSaldo: %6$s + No se puede completar la transacción. Fondos insuficientes. + Error al establecer la sesión de WalletConnect. Por favor, inténtelo más tarde. + Error al firmar el mensaje.\nPor favor, inténtalo de nuevo + Error al establecer la sesión de WalletConnect: error de tiempo de espera. Por favor, inténtelo más tarde. + La solicitud de sesión contiene blockchains no compatibles para la conexión WalletConnect. Blockchains no compatibles:\n + La conexión con esta dApp no se puede establecer debido a su implementación técnica. + Hemos encontrado un error desconocido. Mensaje de error: %s. Si el problema persiste, no duda en contactar con nuestro soporte + Tarjeta incorrecta seleccionada en Tangem App + Error al crear la transacción a partir de los datos de la dApp. Código: %s + Hemos encontrado un error desconocido. Mensaje de error: %d. Si el problema persiste, contacte con nuestro soporte + No hay sesiones abiertas de WalletConnect + ¡Qué lástima! No hay sesiones. + Error al emparejar la sesión de WalletConnect: %1$s + Pegar desde el portapapeles + Mensaje para %1$s:\n%2$s + Solicitud para iniciar una sesión para\n%1$s\n\nRED: %2$s\n\nURL:%3$s + No se pudo completar la operación.\n\nYa ha establecido una sesión de WalletConnect con estos parámetros. + Escanear nuevo código + Esta tarjeta no se puede usar para establecer una sesión de WalletConnect + Esta red no es compatible. Por favor, seleccione otra red. + Seleccione una red + Sesiones de WalletConnect + Conectar a dApps + WalletConnect + La conexión puede tardar unos segundos. + %s Precio de mercado + últimas 24h + %s red + La dirección se copió correctamente + Sin conexión a internet + Ajusta de la wallet + Tangem + Use %s o escanee una tarjeta para desbloquear el acceso a su billetera + El proceso de obtención de permisos está actualmente en marcha y se completará pronto. + Aprobación en curso + Parece que la activación de la tarjeta no ha ido correctamente. Esto puede deberse a un problema con el módulo NFC de su dispositivo o a una mala conexión de la tarjeta de su dispositivo. Comuníquese con nuestro equipo de soporte para obtener ayuda. + Error de activación + Según los desarrolladores de la red BNB, el soporte para el estándar BEP-2\nfinalizará en junio de 2024. Para evitar perder activos con este estándar, por favor conviértalos al estándar BEP-20. Usa nuestro servicio de swap para transferirlos a la red BNB Smart Chain. + BNB Beacon Chain se cerrará + Podría ser mejor + Me gusta + Entendido + ¡Realmente genial! + Actualizar + Actualmente estás en el modo Demo + Modo demo activo + La tarjeta que ha escaneado es una tarjeta de desarrollador. No la use para crear su billetera. + ¡No para usuarios! + La red %1$s requiere un Depósito Existencial. Si su cuenta cae por debajo de %2$s, se desactivará y se destruirán los fondos restantes. + La red requiere Depósito Existencial + El intercambio estará disponible una vez que se complete la transacción de %s. + Tiene una transacción activa + La aprobación del intercambio está en progreso y se completará en breve + Aprobación en proceso + El monto mínimo de transacción es %1$s. Asegúrese de que el saldo restante después del canje no sea inferior a %2$s. + No tiene %s monedas negociables en su lista + No hay tokens disponibles para intercambiar + Para realizar una transacción necesita depositar %1$s %2$s + No se pueden cubrir %s tarifa + El monto a recibir debe ser de al menos %s + Esto puede suceder porque el proveedor actualmente no puede operar con el par que usted seleccionó. Espere un momento e inténtelo de nuevo. (Código %s) + El par seleccionado no está disponible temporalmente + Servicio no disponible temporalmente + La cantidad de tokens a intercambiar no debe exceder %s + El monto a cambiar debe ser de al menos %s + Por favor cambie la cantidad a cambiar + Esta tarjeta podría ser una muestra de producción o una falsificación + La verificación de autenticidad falló + Asociar + Este token debe estar asociado a tu cuenta de Hedera antes de que pueda recibirlo. Tarifa de asociación ~%1$s %2$s + Este token debe estar asociado con su cuenta de Hedera antes de poder recibirlo. + Asocie su token + No hay suficiente %s. Recargue su cuenta de Hedera para asociar este token + Solo quedan %s firmas en esta tarjeta. Deba retirar todos sus fondos. + Recuento de firmas bajo + Los tokens en diferentes redes pueden tener direcciones diferentes. Verifique que su dirección coincida con la red cuando transfieras fondos. + Actualmente, MATIC está migrando a POL. Sin embargo, no se ha fijado ninguna fecha límite y MATIC aún no está obsoleto. Puede seguir usando el token MATIC de forma segura o utilizar intercambios para cambiarlo por POL. + Migración de MATIC a POL + + Use su tarjeta para obtener una dirección para la red + Use su tarjeta para obtener direcciónes para la red + + Faltan algunas direcciones + La red no está disponible actualmente. Por favor, inténtalo de nuevo más tarde. + La red no está disponible + Recargue su billetera + Su billetera no ha sido respaldado. Realice este procedimiento para proteger sus activos ahora. + Falta backup + Esta tarjeta se ha utilizado previamente para transacciones. Si la recibió de una fuente no confiable, considere retirar todos los fondos. Si es su tarjeta, no se requiere ninguna acción. + La tarjeta ya ha firmado transacciones + Su opinión nos motiva a hacer Tangem Wallet aún mejor + ¿Disfrutando de Tangem? + Deba asociar su token antes de recibir tokens + Se requiere tarifa de alquiler de red + %1$s un activo en la red %2$s. Para realizar una transacción %3$s, deposite %4$s (%5$s) para cubrir la tarifa de red. + %1$s insuficiente para cubrir la tarifa de red + La red Solana está congestionada. Si su transacción no se procesa dentro de 2 minutos, repita la transacción. + Alerta de red Solana + La red Solana cobra un alquiler de %1$s cada %2$s días. Las cuentas que no pueden pagar el alquiler son eliminadas de la red. Deposite en su cuenta más de 2 para usarla de forma gratuita. + Algunas redes no están disponibles actualmente. Por favor, inténtalo de nuevo más tarde. + Algunas redes no están disponibles + Esta es una tarjeta de Testnet. No puede procesar transacciones y solo deba usarse para pruebas y desarrollo. + Solo para fines de prueba + Ignorar + Tiene un backup interrumpido. ¿Quiere reanudarlo? + Sí, reanudar + Descartar + Si descarta el backup ahora, tendrá que restablecer las tarjetas a los ajustes de fábrica para empezar de nuevo + Reanudar backup + Esta es una acción irreversible + Iniciar sesión con %s + Escanee la tarjeta + Use %s o escanee una tarjeta para acceder a la app + ¡Bienvenido de nuevo! + diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 32f1230b31..d2745aa359 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -39,6 +39,7 @@ Trop de tentatives Vous avez désactivé l\'authentification biométrique sur votre téléphone et ne pourrez pas enregistrer de portefeuilles dans l\'application. Pour enregistrer des portefeuilles, veuillez activer la fonction d\'authentification biométrique dans les paramètres de votre téléphone. Démarrer le processus de sauvegarde + Avec votre carte bancaire ou votre compte bancaire %d carte %d cartes @@ -65,10 +66,11 @@ ADA insuffisant pour le transfert de jetons Vous devez conserver un peu d\'ADA car vous avez des jetons sur la blockchain Cardano ADA insuffisant - J\'accepte + Accepter Accès refusé - Tout + Tous Permettre + Analytique Appliquer Approbation Approuver @@ -81,6 +83,8 @@ Aller à %1$s Vous n\'avez pas octroyé l\'accès à votre caméra, veuillez modifier vos paramètres de confidentialité Annuler + Choisissez une action + Réclamez des récompenses Fermer Continuer Copier @@ -90,7 +94,7 @@ Personnalisé %d jour - %d jours + %d jours Supprimer Désactivé @@ -98,10 +102,10 @@ Activer Activé Erreur + Échangez Explorez Explorez l\'historique des transactions Explorateur - Commissions Les frais de réseau sont des charges que les utilisateurs paient pour traiter et confirmer les transactions. Le montant des frais peut être affecté par la congestion du réseau, la taille de la transaction et la priorité d\'exécution. %s Rapide Marché @@ -109,8 +113,11 @@ Vitesse et frais Obtenir des adresses Aller au fournisseur + Aller au jeton Importez + En cours Plus tard + Il reste %1$s Verrouillé Réseau principal Commissions du réseau @@ -123,6 +130,7 @@ Carte principale Passphrase Coller + Politique de confidentialité %1$s-%2$s %1$s — %2$s En savoir plus @@ -130,7 +138,7 @@ Rejeter Recharger Renommer - Enregistrer + Enregistrez Sauvegarder les modifications Rechercher Rechercher des jetons @@ -142,14 +150,15 @@ Partager Signez Signez et envoyez - Enjeu + Stake Staking Démarrer Soumettre Avec succès Support - Échange + Échanger termes et conditions + Conditions d\'utilisation Aujourd\'hui La transaction a échoué Transactions @@ -157,6 +166,7 @@ Je comprends Il y avait une erreur. Veuillez réessayer. Inaccessible + Unstakez Oui Adresse du contrat copiée ! Réseaux disponibles @@ -164,7 +174,9 @@ Adresse du contrat L\'adresse du contrat n\'est pas valide Veuillez sélectionner le réseau - Les décimales doivent être un entier valide, jusqu\'à %li + Ce jeton a déjà été ajouté à la liste + Ce jeton existe déjà + Les décimales doivent être un entier valide, jusqu\'à %d Dérivation personnalisée Par exemple m/00\'/0000\'/0\'/0/0 Entrez une dérivation personnalisée @@ -205,7 +217,7 @@ Emetteur Signé Envoyer un commentaire - Référénces + Détails Vérifiez votre connexion Internet ou passez à un réseau différent Conditions d\'utilisation Vous avez utilisé une carte d\'un autre portefeuille. Appuyez sur la carte associée à ce portefeuille @@ -223,6 +235,8 @@ Échange par %s Visitez le site Web du fournisseur pour obtenir un remboursement Opération échouée par le fournisseur + Le montant de la transaction a été remboursé en %1$s sur votre portefeuille en raison des règles OKX ou du pont. %2$s + Le montant a été remboursé en %1$s (réseau %2$s) Visitez le site Web du fournisseur pour la vérification Vérification KYC requise par le fournisseur Annulé @@ -242,24 +256,25 @@ Envoyé Données fournies par le fournisseur. Le montant estimé est sujet à modification en raison des conditions du marché. Statut de l\'échange - Verification requise + Vérification requise + En attente du hachage de la transaction Liste de tous les jetons ajoutés à votre portefeuille En cherche des meilleurs taux Taux flottant En utilisant la fonctionnalité d\'échange, vous acceptez les %s - En utilisant la fonctionnalité d\'échange, vous acceptez les conditions %1$s et %2$s du fournisseur. + En utilisant la fonctionnalité d\'échange, vous acceptez les conditions %1$s et %2$s du fournisseur D\'autres fournisseurs arriveront bientôt.\nRestez branchés ! - Politique de confidentialité Fournisseur Meilleur taux Disponible jusqu\'à %s Disponible à partir de %s Indisponible pour cette paire Permission requise - Conditions d\'utilisation + Recommandé Aucun jeton trouvé. Veuillez essayer une autre demande ID : %s ID de transaction copié + Avec une autre devise dans votre portefeuille Les informations suivantes sont facultatives. Vous pouvez les effacer si vous ne souhaitez pas les partager. Dites-nous quelles fonctions vous manquent, et nous essaierons de vous aider. Veuillez nous dire quelle carte vous avez @@ -274,11 +289,14 @@ Le réseau facturera des frais d\'approbation de jeton pour vérifier que vous autorisez l\'utilisation de votre jeton pour l\'échange. Spécifiez la limite approuvée pour le jeton sélectionné Montant %s + La fonction Approuver est nécessaire pour accorder la permission à une autre adresse d\'utiliser une quantité spécifique de vos jetons. De par leur conception, les contrats intelligents ne peuvent pas accéder à vos jetons sans votre approbation. En « déverrouillant » vos jetons, vous autorisez le contrat intelligent StakeKit à les utiliser. Les mineurs du réseau reçoivent des frais de gaz (payés par vous) pour enregistrer cette action sur la blockchain. Vous pouvez staker votre jeton après avoir donné votre approbation. + Pour continuer, vous devez autoriser le contrat intelligent StakeKit à utiliser votre %s Pour continuer, accordez aux smart contracts de %1s l\'autorisation d\'utiliser votre %2s Donner l\'autorisation Illimité Commandez Scannez + Ces informations ont été générées avec l\'IA Touchez, pour modifier le code d\'accès Touchez, pour modifier le mot de passe Pour créer le portefeuille, appuyez sur la carte comme indiqué ci-dessus et ne la retirez pas jusqu\'à la fin de l\'opération @@ -332,20 +350,72 @@ Mon portfolio Marché Pour générer des adresses pour les réseaux sélectionnés, vous devez scanner votre carte Tangem + Pour ajouter des jetons, faites-le apparaître ou appuyez sur la barre de recherche + Les données de cette section proviennent des réseaux suivants : %s Impossible de charger les données… + Aucune donnée + Actions rapides Résultat Voir les jetons de moins de 100 000 $ de capitalisation boursière Afficher les jetons Aucun résultat Sélectionnez un réseau Sélectionnez un portefeuille + 1 mo + 1 an + 24h + 3mos + 6mos + 7j + Tout + Acheteurs expérimentés + Évaluation Trier par + Meilleurs Gagnants + Top Perdants + Tendances À propos de %s + + Basé sur %d évaluation + Basé sur %d évaluations + + Site web + Pression d\'achat + La différence entre le volume des acheteurs et le volume des vendeurs + Approvisionnement en circulation + Le nombre total de pièces disponibles pour le trading et en circulation sur le marché + Acheteurs expérimentés + Acheteurs nets avec l\'exigence supplémentaire d\'avoir au moins 100 transactions sortantes + Valorisation entièrement diluée + La valeur théorique totale d\'une crypto-monnaie si toutes les pièces qui pourraient exister étaient en circulation, y compris celles qui ne circulent pas actuellement + Date de la Genesis + Haut + Détenteurs + L\'évolution du nombre de détenteurs de jetons au cours d\'une période donnée Idées Liens + Liquidité + Le changement dans la quantité de liquidité disponible pour le jeton pendant la période spécifiée + Indice de liquidité + Faible + Cap. boursière + La valeur d\'une crypto-monnaie est calculée en multipliant son prix par le nombre de pièces en circulation + Évaluation du marché + Position dans le classement des crypto-monnaies entre toutes les pièces en fonction de la capitalisation boursière + Approvisionnement maximal Métriques + Official links Performance des prix + Dépôt Score de sécurité + Social + Approvisionnement total + Le nombre maximal de pièces ou de jetons pouvant exister pour une crypto-monnaie particulière + Volume des échanges (24h) + Le montant total d\'une crypto-monnaie qui a été échangé au cours des dernières 24 heures, indiquant le niveau d\'activité et de liquidité du marché + Tirez vers le haut ou appuyez sur la barre de recherche pour ajouter des jetons directement depuis le marché + Ajouter des jetons + NFC n\'est pas disponible sur votre appareil Vous devez définir un seul code d\'accès pour protéger toutes vos cartes Protéger Vous pourrez définir un code d\'accès individuel sur chaque carte plus tard @@ -389,12 +459,12 @@ Sauvegarde en cours En savoir plus sur les seed phrases - Empty + Écrivez ces %d mots dans l\'ordre indiqué ci-dessous et conservez-les dans un endroit sûr et secret. Votre seed phrase - Empty + %d mots Pour importer votre portefeuille, entrez votre seed phrase dans le champ ci-dessous @@ -417,6 +487,7 @@ Activation de la carte Carte de sauvegarde n°%d Aucune carte de sauvegarde + Notifications Une carte de sauvegarde ajoutée Préparez votre carte Deux cartes de sauvegarde ajoutées @@ -445,6 +516,7 @@ Accès à la caméra refusé %1$s (%2$s) sur le réseau %3$s Envoyez uniquement %s à cette adresse. L\'envoi de toute autre devise entraînera sa perte irréversible. + Affichez un code QR ou partagez votre adresse Participer Échec du chargement des informations sur le programme de parrainage. Veuillez réessayer plus tard. Échec du chargement des informations sur le programme de parrainage. Code d\'erreur : %s. Veuillez réessayer plus tard. @@ -508,18 +580,11 @@ Les frais requis pour l\'utilisation de chaque sortie de transaction non dépensée (UTXO) dans le réseau Kaspa. Plus vous utilisez d’UTXO dans une transaction, plus les frais seront élevés. KAS pour UTXO %1$s, %2$s - Adresse ID de destination Entrez l\'adresse L\'adresse est la même que celle de votre portefeuille - Tag invalide. Il ne sera pas ajouté à la transaction. Mémo invalide. Il ne sera pas ajouté à la transaction. - Tag - Memo - Inclure les commissions - Bas - Normal - Priorité + Mémo Vérifiez votre connexion réseau Informations sur les frais de réseau inaccessibles De @@ -530,11 +595,11 @@ Max Somme maximale Frais jusqu\'à - Memo invalide + Mémo invalide Couverture des frais de réseau Fonds insuffisants pour le transfert, car le total des frais et du montant du transfert dépasse le solde existant Le total dépasse le solde - Le compte sera effacé de la blockchain si un solde descend en dessous du dépôt existentiel. Veuillez en laisser %s sur votre solde. + Un solde d\'au moins %s est requis pour conserver votre compte sur la blockchain afin d\'éviter les risques de sécurité. Ce montant restera sur votre solde et ne pourra pas être retiré. Dépôt existentiel Le montant de la commission est %s fois le montant recommandé. Assurez-vous que les paramètres personnalisés sont corrects. Les frais de douane sont élevés @@ -568,48 +633,85 @@ Envoyer %s Vous envoyez **%1$s** incluant des frais de réseau de %2$s Vous envoyez **%1$s** et %2$s - Envoi de %s - Total - Sera envoyé %1$s et %2$s - ≈ %1$s (incl. les commissions : %2$s) - Sera envoyé %s La transaction a été signée avec succès et envoyée au nœud de blockchain. Le solde du portefeuille sera mis à jour après un certain temps %1$s est un actif du réseau Tron. Pour calculer les frais et effectuer une transaction, déposez du Tron (TRX) sur votre compte. - Adresse incorrecte Transaction envoyée Scannez la carte que vous souhaitez configurer. Oublier le portefeuille Cela supprimera le portefeuille de l\'application. Le portefeuille lui-même peut être ajouté à nouveau. Nom - Actif - Afin d\'unstaker vos actifs, cliquez ici. + Le montant à staker doit être au moins %s + Le montant du staking sera arrondi à %1$s TRX en raison des règles du réseau. + Réclamation déstakée + Pourcentage de rendement annuel Le pourcentage de rendement annuel que vous pouvez gagner en participant au staking. APR + Les récompenses s\'accumulent automatiquement sur votre solde de staking quotidiennement. Disponible Taux de récompense moyen + Qu\'est-ce que le Staking ? %s profit estimatif Cote du marché Métriques Minimum requis Aucune récompense à réclamer Réclamation de récompense - Un moyen de recevoir des récompenses de staking. Il peut être réclamé automatiquement ou manuellement. + Méthode de réception des récompenses de staking.\nElle peut être automatique, où la récompense est créditée sur votre adresse, ou manuelle, où vous devez retirer la récompense en créant une transaction pour la recevoir. Calendrier de récompenses Il s\'agit d\'un calendrier qui détermine le moment où les participants au staking reçoivent leurs récompenses. Récompenses à réclamer: %s Staking %s - Période de détachement + Période de dissociation La période que vous devez attendre après avoir demandé le retrait des fonds du staking avant que les jetons ne soient disponibles. Période d\'échauffement Le temps imparti pour activer la participation au staking. + En utilisant la fonctionnalité de staking, vous acceptez les %1$s et %2$s du fournisseur + Bloqué + Migrer Native staking + Récomp. gagnées vous seront envoyées et dispo pour utilisation immédiate + Le staking vous permet de gagner %1$s. Vos récompenses de staking arrivent tous les jours. + Le staking vous permet de gagner %1$s. Vos récompenses de staking arrivent toutes les heures. + Le staking vous permet de gagner %1$s. Vos récompenses de staking arrivent tous les mois. + Le staking vous permet de gagner %1$s. Vos récompenses de staking arrivent toutes les semaines. Gagnez des récompenses de staking + Vos fonds seront disponibles pour utilisation après la période de désengagement %s. + Vous pouvez désormais retirer vos fonds, ils seront disponibles à l\'utilisation immédiatement + Le staking dans le réseau Tron avec un nouveau validateur transférera automatiquement tous les fonds précédemment stakés vers ce validateur + Prêt à retirer + Réassocier + Restakez + Restakez des récompenses + Révoquer + Revoter + Auto + Manuel + Bloquer + Jour + Chaque jour + Époque + Ère + Heure + Mois + Semaine Récompenses Stake verrouillé Staker plus + Appuyez pour déverrouiller + Appuyez pour retirer + Stake %s + déstaker %s + La transaction est en cours de traitement ! La validation est actuellement en cours dans la blockchain. Cela peut prendre quelques minutes. + Dissociation + Débloquer Non-staké - Vérifiez non-stakés pour réclamer vos actifs + Unstaking Validateur + Validateurs + Voter + Vote bloqué + Retirer + Vos stakes Stockez vos actifs crypto en toute sécurité tout en conservant les clés privées contenues dans votre carte Portefeuille matériel révolutionnaire Jusqu\'à 3 cartes physiques pour un portefeuille @@ -621,6 +723,8 @@ Découvrez Tangem Échangez, achetez des NFT, faites des prêts et des dépôts dans plus de 100 services décentralisés différents Compatible avec Web 3.0 + Échangez plus de jetons à de meilleurs taux directement dans votre portefeuille. + Nouveau fournisseur d\'échange disponible ! Le montant comprend :\n• les frais du fournisseur de services\n• les frais de réseau pour l\'envoi de %s depuis l\'échange vers l\'adresse de l\'utilisateur. Le montant comprend les frais du fournisseur de services. Frais @@ -631,8 +735,7 @@ Échanger ce montant de jetons sélectionnés aura un impact significatif sur les prix et réduira votre résultat. Fonds insuffisants Donner l\'autorisation - En cours - Échanger + Échangez Vous recevez Choisir le jeton non disponible @@ -685,7 +788,7 @@ Utilisez %s ou scannez une carte pour avoir accès à votre portefeuille Restez à jour avec les dernières fonctionnalités et actualités Soyez le premier informé des nouvelles promotions - Souhaitez-vous utiliser les notifications push? + Souhaitez-vous utiliser les\nnotifications push? Ajouter un nouveau portefeuille Êtes-vous sûr de vouloir supprimer ce portefeuille ? Une erreur s\'est produite, veuillez scanner votre carte pour vous connecter @@ -736,6 +839,8 @@ Paramètres du portefeuille Tangem Utilisez %s ou scannez une carte pour déverrouiller l\'accès à votre portefeuille + Le processus d\'octroi des autorisations est actuellement en cours et sera bientôt terminé + Approbation en cours Il semble que l\'activation de la carte ne se soit pas déroulée correctement. Cela peut être dû à un problème avec le module NFC de votre appareil ou à une mauvaise connexion de la carte sur votre appareil. Veuillez contacter notre équipe de support pour obtenir de l’aide. Erreur d\'activation Selon les développeurs du réseau BNB, le support de la norme BEP-2\nprendra fin en juin 2024. Pour éviter de perdre des actifs avec cette norme, veuillez les convertir à la norme BEP-20. Utilisez notre service de d\'échange pour les transférer sur le réseau BNB Smart Chain. @@ -777,6 +882,8 @@ Il ne reste que %s signatures sur cette carte. Vous devez retirer tous vos fonds. Faible nombre de signatures Les jetons sur différents réseaux peuvent avoir des adresses différentes. Vérifiez bien que votre adresse correspond au réseau lorsque vous transférez des fonds. + MATIC est en cours de migration vers POL. Cependant, aucune date limite n\'a été fixée et MATIC n\'est pas encore obsolète. Vous pouvez continuer à utiliser le jeton MATIC en toute sécurité ou utiliser des échanges pour l\'échanger contre POL. + Migration de MATIC vers POL Utilisez votre carte pour obtenir une adresse pour le réseau %d Utilisez votre carte pour obtenir des adresses pour les réseaux %d diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index e7caa7829e..900ef94aa5 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -8,7 +8,6 @@ Rimuovere Fatto Errore - Commissione Costi della rete OK Mantieni le modifiche @@ -35,21 +34,10 @@ Avvicina la carta Crea portafoglio Importo - Indirizzo L\'indirizzo corrisponde all\'indirizzo del tuo portafoglio - Tag Memo - Includi commissione - Insufficiente - Normale - Prioritario Importo totale - Totale - Sarà inviato %1$s e %2$s - ≈ %1$s (inc. commissione: %2$s) - Sarà inviato %s La transazione è stata firmata con successo e inviata al nodo blockchain. Il saldo del portafoglio verrà aggiornato dopo un po\' di tempo - Indirizzo non valido Tangem Twin WalletConnect L\'indirizzo è stato copiato con successo diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 55189ebf68..ace34d86b3 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -39,7 +39,7 @@ 試行回数が多すぎます お使いの携帯電話で生体認証が無効になっているため、アプリにウォレットを保存できません。ウォレットを保存するには、携帯電話の設定で生体認証機能を有効にしてください。 バックアップ処理を開始する - 法定通貨カードまたは銀行口座から + 銀行カードまたは銀行口座から %d カード @@ -69,6 +69,7 @@ アクセスが拒否されました すべて 許可する + アナリティクス 適用する 承認 承認 @@ -81,6 +82,8 @@ %1$sへ移動 カメラへのアクセスを許可していません。プライバシー設定を調整してください。 キャンセル + アクションを選択 + 請求 報酬を受け取る 閉じる 続ける @@ -98,10 +101,10 @@ 有効にする 有効 エラー + 交換 移動する 取引履歴を調べる エクスプローラー - 手数料 ネットワーク手数料は、取引の処理と確認のためにユーザーが支払う料金です。手数料の額は、ネットワークの混雑さ、取引のサイズ、実行の優先度によって左右されます。 %s 速い マーケット @@ -111,7 +114,9 @@ プロバイダーへ移動 トークンへ移動 インポート + 進行中 後で + 残り%1$s ロックされています メインネットワーク ネットワーク手数料 @@ -124,6 +129,7 @@ プライマリーカード パスフレーズ ペースト + プライバシーポリシー %1$s-%2$s %1$s — %2$s 続きを読む @@ -151,6 +157,7 @@ サポート スワップ 利用規約 + 利用規約 今日 取引が失敗しました 取引 @@ -166,7 +173,9 @@ コントラクトアドレス コントラクトアドレスが無効です ネットワークを選択してください - 小数は%liまでの有効な整数である必要があります + このトークンはすでにリストに追加されています + トークンはすでに存在します + 小数は%dまでの有効な整数である必要があります カスタム派生パス 例:m/00\'/0000\'/0\'/0/0 カスタム派生パスを入力 @@ -254,7 +263,6 @@ スワップ機能を使用すると、プロバイダーの%sに同意したことになります。 スワップ機能を使用すると、プロバイダーの%1$sおよび%2$sに同意したことになります。 さらに多くのプロバイダーを追加予定です。 \nお楽しみに。 - プライバシーポリシー プロバイダー ベストレート 最大 %s まで使用可能 @@ -262,7 +270,6 @@ このペアは利用できません 許可が必要です 推奨 - 利用規約 トークンが見つかりません。別のリクエストをお試しください。 ID: %s 取引IDをコピーしました @@ -288,6 +295,7 @@ 無制限 カードを注文 スキャン + この情報はAIによって生成されました アクセスコードを変更するには、上図のようにカードをタップし、操作が終了するまで取り外さないでください。 パスコードを変更するには、上記のようにカードをタップし、操作が終了するまで取り外さないでください。 ウォレットを作成するには、上記のようにカードをタップし、操作が終了するまで取り外さないでください。 @@ -332,15 +340,18 @@ 賛成票を投じる ウォレットを選択 ウォレットは複数のネットワークをサポートしていません。 - このアセットの購入、交換、受け取りを開始するには、このトークンを少なくとも1つのネットワークに追加してください。 + このアセットの購入、交換、受け取りを開始するには、ポートフォリオに追加してください。 このアセットは利用できません ポートフォリオに追加 - トークンを追加 + 追加 利用可能なネットワーク 私のポートフォリオ マーケット 選択したネットワークのアドレスを生成するには、Tangemカードをスキャンする必要があります。 + トークンを追加するには、これをスワイプするか、検索バーをタップしてください。 + このセクションのデータは、次のネットワークから取得されています: %s データを読み込めません… + データなし クイックアクション 結果 時価総額10万ドル以下のトークンを見る @@ -348,13 +359,13 @@ 検索結果はありません ネットワークを選択 ウォレットを選択 - 1ヶ月 - 1年 - 24時間 - 3ヶ月 - 6ヶ月 - 7日 - すべて + 1m + 1y + 24h + 3m + 6m + 7d + 全部 経験豊富な買い手 格付け 並べ替え @@ -365,7 +376,7 @@ %d のレーティングに基づいて - ブロックチェーンサイト + ウェブサイト 買い圧力 買い手と売り手の取引量の差 循環供給量 @@ -399,6 +410,9 @@ 特定の暗号資産に存在しうるコインまたはトークンの最大数 取引量(24時間) 過去24時間以内に取引された暗号資産の合計額。市場の活発さと流動性を示します。 + これをドラッグするか、検索窓をタップして、マーケットから直接トークンを追加します + トークンを追加 + お使いのデバイスではNFCが使用できません すべてのカードを保護するには、単一のアクセスコードを設定する必要があります 保護する 後で各カードに個別のアクセスコードを設定できます @@ -446,7 +460,7 @@ あなたのシードフレーズ - %d 単語 + %d 単語 ウォレットをインポートするには、下のフィールドにシードフレーズを入力してください。 シードフレーズを生成する @@ -497,7 +511,7 @@ カメラへのアクセスが拒否されました %3$sネットワーク上の%1$s ( %2$s ) このアドレスには%sのみを送金してください。他のトークンを送信すると、取り返しのつかない損失が発生します。 - QRコードを表示するか、アドレスを共有します + QRコードを表示するか、アドレスを共有してください 参加する 紹介プログラムに関する情報を読み込めませんでした。しばらくしてからもう一度お試しください。 紹介プログラムに関する情報を読み込めませんでした。エラー コード: %s 。しばらくしてからもう一度お試しください。 @@ -559,18 +573,11 @@ Kaspaネットワークで未使用の取引出力(UTXO)を使用するために必要な手数料です。取引で使用するUTXOが多ければ多いほど、手数料は高くなります。 UTXOあたりのKAS %1$s 、 %2$s - アドレス 宛先タグ アドレスを入力 アドレスはウォレットアドレスと同じです - 無効なタグです。取引には追加されません。 無効なメモです。取引には追加されません。 - タグ メモ - 手数料込み - 低い - 普通 - 優先 ネットワーク接続を確認してください ネットワーク手数料についての情報にアクセスできません より @@ -585,7 +592,7 @@ ネットワーク手数料のカバー 手数料と送金額の合計が残高を超えているため、送金に必要な資金が不足しています。 合計が残高を超えています - 残高が最低量を下回ると、当アカウントはブロックチェーンから消去されます。残高に%sを残しておいてください。 + セキュリティリスクを防ぐため、ブロックチェーン上にアカウントを維持するには、少なくとも%s の残高が必要です。この金額は残高に残り、引き出すことはできません。 アカウント維持に必要な最低残高 手数料額が推奨額の%s倍となっています。カスタム設定が正しいことを再度確認してください。 カスタム手数料が高くなっています @@ -619,26 +626,24 @@ %sを送金する **%1$s** を送金する (ネットワーク手数料%2$sを含む) **%1$s** と %2$s を送金しています。 - %sを送信しています - 合計 - %1$sと%2$sが送信されます - ≈ %1$s (%2$s: 手数料を含む) - %sが送信されます + ** %1$s ** を送信しています + ネットワーク手数料は%1$sエネルギーを使用してカバーされます + %1$sエネルギーを使用するとネットワーク手数料が減額されます + ネットワーク手数料%1$sを含む 取引は正常に署名され、ブロックチェーンノードに送信されました。ウォレットの残高はしばらくして更新されます。 %1$sはTronネットワークのアセットです。手数料を計算して取引を行うには、アカウントにTron(TRX)を入金する必要があります。 - 無効なアドレス 取引が送信されました セットアップしたいカードをスキャンするために準備してください。 ウォレット削除 これにより、ウォレットがアプリから削除されます。ウォレットは再度追加できます。 名前 - アクティブ - 資産のステーキングを解除するには、ここをクリックしてください。 ステーキング金額は %s 以上である必要があります + ネットワークルールにより、ステーキング金額は%1$s TRX に切り上げられます。 ステーキング解除分を請求する 年率 ステーキングに参加することで得られる年間収益率。 APR + 報酬は毎日自動的にステーキング残高に蓄積されます。 利用可能 平均報酬率 ステーキングとは? @@ -646,25 +651,40 @@ 市場評価 指標 最低要件 - 請求できる報酬はありません + 報酬なし 請求中の報酬 - ステーキング報酬を受け取る方法。自動または手動で請求できます。 + ステーキング報酬の受け取り方法。\n自動であなたのアドレスに報酬が入金される方法と、手動で取引を生成して報酬を引き出す方法があります。 報酬スケジュール これは、ステーキングの参加者がいつ報酬を受け取るかを決定するスケジュールです。 - 受け取る報酬: %s + 報酬: %s ステーキング%s 解約完了までの期間 ステーキングから資金の引き出しを要求した後、トークンが利用可能になるまでの待機期間。 ウォームアップ期間 ステーキングへの参加を有効にするために割り当てられた時間。 + ステーキング機能を使用すると、プロバイダーの%1$sと%2$sに同意したことになります + ロック中 移行 ネイティブステーキング + 獲得した報酬はあなたのアドレスに直接送られ、すぐ使用可能です。 + 安全にステーキングして、報酬を毎日獲得しましょう + 安全にステーキングして、報酬を毎時間獲得しましょう + 安全にステーキングして、報酬を毎月獲得しましょう ステーキングにより%1$sを獲得できます。ステーキング報酬は毎日受け取れます。 - ステーキングにより%1$sを獲得できます。ステーキング報酬は1時間ごとに受け取れます。 + ステーキングにより%1$sを獲得できます。ステーキング報酬は毎時間受け取れます。 ステーキングにより%1$sを獲得できます。ステーキング報酬は毎月受け取れます。 ステーキングにより%1$sを獲得できます。ステーキング報酬は毎週受け取れます。 + 安全にステーキングして、報酬を毎週獲得しましょう ステーキング報酬を獲得 - ステーキング解除後、報酬の獲得はすぐに停止します。ステーキング解除プロセスには%sかかります。 + 新しいバリデーターで%1$sネットワークにステーキングすると、以前にステーキングされた資金はすべてこのバリデーターに自動的に転送されます。 + 獲得した報酬をステーキングに再投資し、潜在的な収益を増やします。 + 資金をステーキングから引き出すには、ロックを解除してください。ロック解除には%sかかります。 + 資金は、21日間のロック解除期間後に使用可能になります。報酬は、ロック解除後の資金とともに引き出されます。 + %sのステーキング解除期間後、資金はすぐ利用可能となります。 + 資金を引き出して、すぐに使用できます + Tronネットワークに新しいバリデーターでステーキングすると、以前にステーキングされた資金はすべてこのバリデーターに自動的に転送されます。 + 準備中 + 引き出し準備完了 再結束 再度ステーキングする 報酬をステーキングする @@ -683,17 +703,23 @@ 報酬 ステーキングはロックされています もっとステーキングする + %1$sをステーキングし、年間 %2$sを受け取ります + タップしてロック解除 + タップして引き出す %sをステーキングする %sのステーキング解除 - ステーキング解除はロックされています - スタックされていない - 資産を請求するために、unstakedを確認してください + 取引を処理中です。現在、ブロックチェーンで検証が行われています。これには数分かかる場合があります。 + ステーキング解約中 + ロック解除 + ステーキングされていない ステーキング解除 + ステーキング解除中の資産%s バリデーター バリデーター 投票する 投票はロックされています 引き出す + あなたのステーキング カード内に秘密鍵を保管しながら暗号資産を安全に保管します 革新的なハードウェアウォレット 1つのウォレットに最大3枚のカード @@ -717,7 +743,6 @@ 選択したトークンをこの量を交換すると、価格に大きな影響が生じ、結果が減少します。 残高不足 許可を与える - 進行中 スワップ 受け取る トークンを選択 @@ -822,6 +847,8 @@ ウォレット設定 Tangem %sを使用するか、カードをスキャンしてウォレットにアクセスしてください + 許可付与のプロセスは現在進行中であり、まもなく完了する予定です。 + 承認中 カードのアクティベーションが正しく完了しませんでした。デバイスの NFCモジュールに問題があるか、カードをデバイスに正しくタップしていないことが原因かもしれません。サポートチームにお問い合わせください。 アクティベーションに失敗しました BNBネットワーク開発者によると、BEP-2規格のサポートは2024年6月に終了します。この規格の資産を失わないために、BEP-20規格に変換してください。BNBスマートチェーンネットワークへ移行するには、Tangemのスワップサービスをご利用ください。 @@ -839,7 +866,7 @@ ネットワークには最低残高が必要です スワップは、%s の取引完了後に利用可能となります。 アクティブな取引があります - スワップの承認は現在進行中で、まもなく完了する予定です。 + スワップ承認は現在進行中で、まもなく完了する予定です。 承認が進行中 最低のスワップ金額は%1$s です。スワップ後の残金が%2$s を下回らないようにしてください。 あなたのリストには、交換可能な %s トークンがありません。 @@ -863,8 +890,8 @@ このカードには%sの署名のみが残っています。資金をすべて引き出す必要があります。 署名数が少ないです 異なるネットワーク上のトークンは、異なるアドレスを持つ場合があります。資金を送金する際には、アドレスがネットワークと一致していることを再確認してください。 - MATICからPOLへの移行 MATICはPOLに移行中です。ただし、期限は設定されておらず、MATICはまだ廃止されていません。MATICトークンを引き続き安全に使用することも、取引所でPOLに交換することもできます。 + MATICからPOLへの移行 %d ネットワークのアドレスを取得するために、カードを利用してください diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 324a1a41ba..fff0190f6d 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -71,6 +71,7 @@ Доступ запрещен Все Разрешить + Аналитика Применить Одобрение Подтвердить @@ -83,6 +84,7 @@ Перейти на %1$s Вы не предоставили доступ к камере, пожалуйста, измените настройки конфиденциальности. Отмена + Получить Вывести награду Закрыть Продолжить @@ -107,7 +109,6 @@ Обозреватель Посмотреть историю транзакций Обозреватель - Комиссия Сетевые комиссии – это плата пользователя за обработку и подтверждение транзакций. Размер комиссии зависит от нагрузки на сеть, объема транзакции и приоритета исполнения. %s Быстро По рынку @@ -117,7 +118,9 @@ К провайдеру Перейти в токен Импортировать + В процессе Позже + Осталось %1$s Заблокирован Основная сеть Сетевая комиссия @@ -130,6 +133,7 @@ Основная карта Парольная фраза Вставить + Политикой конфиденциальности %1$s-%2$s %1$s — %2$s Подробнее @@ -157,6 +161,7 @@ Поддержка Обмен условия участия + Условиями использования Сегодня Ошибка транзакции Транзакции @@ -172,7 +177,8 @@ Адрес контракта Адрес контракта некорректен Пожалуйста, выберите сеть - Десятичное число должно быть действительным целым числом, до %li + Токен уже существует + Десятичное число должно быть действительным целым числом, до %d Своя деривация Например m/00\'/0000\'/0\'/0/0 Введите свою деривацию @@ -260,7 +266,6 @@ Пользуясь сервисом, вы соглашаетесь с %s Пользуясь сервисом, вы соглашаетесь с %1$s и %2$s Больше провайдеров на подходе.\nСледите за обновлениями! - Политикой конфиденциальности Провайдер Лучший курс Доступно до %s @@ -268,7 +273,6 @@ Недоступно для этой пары Требуется разрешение Рекомендовано - Условиями использования Токены не найдены. Пожалуйста, попробуйте другой запрос ID: %s ID транзакции скопирован @@ -294,6 +298,7 @@ Безлимитно Купить Сканировать + Информация сгенерирована ИИ Чтобы изменить код доступа, приложите карту как показано выше и не убирайте до окончания операции Чтобы изменить пароль, приложите карту как показано выше и не убирайте до окончания операции Чтобы создать кошелек, приложите карту как показано выше и не убирайте до окончания операции @@ -347,21 +352,24 @@ Мой портфель Рынок Чтобы создать адреса для выбранных сетей, отсканируйте вашу карту Tangem кошелька + Данные раздела получены из следующих сетей: %s + Невозможно загрузить данные + Нет данных Быстрые действия Результат - Токены с капитализацией меньше 100к + Токены с капитализацией меньше 100к USD Показать токены Нет результата Выберите сеть Выберите кошелек - 1мин - 1год + + 24ч Все - Опытные покупатели + Опытные трейдеры Рейтинг Сортировать по Лидеры роста @@ -374,13 +382,14 @@ На основе %d оценок На основе %d оценок - Сайт блокчейна - Покупательское предпочтение - Разница между объемом покупателей и продавцов - Циркулирующее предложение + Веб-сайт + Покуп. предпоч. + Разница между объемом покупателей и продавцов + Циркулир. предл. Общее количество монет, доступных для торговли и находящихся в обращении на рынке - Опытные покупкатели - Полностью разбавленная капитализация + Опытные трейдеры + Покупатели, у которых было как минимум 100 исходящих транзакций. + Полн. разб. кап. Общая теоретическая стоимость криптовалюты, если все монеты, которые могут существовать, находятся в обращении, включая те, которые в настоящее время не обращаются Дата создания Высокий @@ -392,9 +401,10 @@ Изменение объема ликвидности, доступной для токена в течение указанного периода времени. Индекс ликвидности Низкий - Рыночная капитализация + Рын. кап. Общая рыночная стоимость криптовалюты, рассчитываемая путем умножения текущей цены монеты на общее количество монет в обращении. - Рыночный рейтинг + Рейтинг + Позиция в рейтинге криптовалют среди всех монет на основе рыночной капитализации. Максимальный объем Метрики Официальные ссылки @@ -406,6 +416,9 @@ Максимальное количество монет или токенов, которое может когда-либо существовать для выбранной криптовалюты. Объем торгов (24ч) Общая сумма криптовалюты, которая была продана за последние 24 часа, показывающая уровень активности и ликвидности на рынке. + Потяните вверх или коснитесь поисковой строки, чтобы добавить токены напрямую из маркета + Добавить токены + Функция NFC недоступна на вашем устройстве Вам необходимо установить единый код доступа для защиты всех ваших карт Защита Позже вы сможете установить индивидуальный код доступа для каждой карты @@ -577,18 +590,11 @@ Комиссия, которую нужно заплатить за использование каждого неиспользованного выхода транзакции (UTXO) в сети Kaspa. Чем больше UTXO вы используете в транзакции, тем выше будет комиссия. KAS за UTXO %1$s, %2$s - Адрес Код назначения Введите адрес Адрес совпадает с адресом кошелька - Недопустимый Tag. Он не будет добавлен в транзакцию. Недопустимый Memo. Он не будет добавлен в транзакцию. - Tag Memo - Включая комиссию - Низкая - Нормальная - Приоритетная Проверьте своё интернет соединение Информация о комиссии сети недоступна Из @@ -603,7 +609,7 @@ Покрытие сетевой комиссии Недостаточно средств для перевода, так как сумма комиссии и сумма перевода в совокупности больше имеющегося баланса Недостаточно средств - Аккаунт будет удален из блокчейна, если баланс упадет ниже экзистенциального депозита. Пожалуйста, оставьте %s на балансе. + Для сохранения вашего аккаунта в блокчейне и защиты от возможных рисков необходим баланс не менее %s. Эта сумма останется на вашем счете и не может быть снята. Экзистенциальный депозит Сумма комиссии в %s раз превышает рекомендованную. Убедитесь, что указанная комиссия верна. Установлена высокая комиссия @@ -637,24 +643,18 @@ Отправка %s Вы отправляете **%1$s**, включая комиссию сети %2$s Вы отправляете **%1$s** и %2$s - Отправка %s - Всего - %1$s и %2$s будет отправлено - ≈ %1$s (вкл. комиссию: %2$s) - %s будет отправлено + Вы отправляете **%1$s** + включая комиссию сети в размере %1$s Транзакция успешно подписана и отправлена в блокчейн. Баланс будет обновлен через некоторое время %1$s — это монета в сети Tron. Чтобы рассчитать комиссию и совершить транзакцию, вам необходимо внести немного Tron (TRX) на свой адрес. - Неверный адрес Транзакция отправлена Подготовьтесь к сканированию карты, которую вы хотите настроить. Забыть кошелек Это приведет к удалению кошелька из приложения. Сам кошелек можно добавить снова. Имя - Активно - Для завершения стейкинга нажмите сюда Сумма для стейкинга должна быть не менее %s Забрать средства - Годовая процентная ставка + Процентная ставка Годовой процентный доход, который вы можете получить от участия в стейкинге. APR Доступно @@ -665,29 +665,34 @@ Метрики Минимальное количество Нет вознаграждений к получению - Способ возраграждения + Способ вознаграждения Способ получения вознаграждений за стейкинг. Он может быть автоматическим, при котором вознаграждение само зачисляется вам на адрес или в ручную, когда вознаграждение нужно вывести, создав транзакцию на её получение. - Период возрагражения + Период вознагражения Это период, определяющий, когда участники стейкинга получат свои вознаграждения. Вознаграждение для получения: %s Стейкинг %s - Период вывода + Период отзыва Период, который необходимо подождать после запроса на вывод средств из стейкинга, прежде чем токены станут доступны. Период прогрева Время, необходимое для начала процесса стейкинга и активации процесса начисления наград + Пользуясь стейкинг сервисом, вы соглашаетесь с %1$s и %2$s + Заблокировано Переместить Нативный стейкинг + Полученная награда отправится на ваш кошелек и сразу станет доступна для использования Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждый день. Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждый час. Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждый месяц. Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждую неделю. Получите награду за стейкинг - Награда перестанет начисляться сразу после завершения стейкинга. Процесс завершения длится %s. - Повторный стейкинг + Разблокируйте свои средства, чтобы вывести их из стейкинга. Разблокировка займёт %s. + Ваши средства будут доступны после %s периода отзыва. + Вы можете вывести свои средства из стейкинга, они будут доступны для использования незамедлительно. + Сменить валидатора Застейкать вознаграждения Отозвать Переголосовать - Автоматически + Авто Вручную Блок День @@ -700,11 +705,15 @@ Вознаграждения Стейкинг закрыт Застейкать еще + Нажмите для разблокировки + Нажмите для вывода Застейкать %s + Вывести %s + Отзыв Разблокировать - Выведено из стейкинга - Проверьте процесс завершения стейкинга, чтобы вывести свои средства. + Вывод из стейкинга Завершение стейкинга + Завершение стейкинга %s Валидатор Валидаторы Проголосовать @@ -733,7 +742,6 @@ Обмен этой суммы выбранных токенов может вызвать значительные колебания цены и уменьшить получаемую сумму. Недостаточно средств Дать разрешение - В процессе Обменять Вы получите Выберите токен @@ -815,7 +823,7 @@ Произошла непредвиденная ошибка. Сообщение ошибки: %s Попробуйте, пожалуйста, позже. Если проблема будет продолжать возникать - обратитесь в службу поддержки. Неверная карта выбрана в приложении Tangem Не удалось создать транзакцию из данных Dapp. Код: %s - Произошла непредвиденная ошибка. Код ошибки: %d Попробуйте, пожалуйста, позже. Если проблема будет продолжать возникать - обратитесь в службу поддержки. + Произошла непредвиденная ошибка. Код ошибки: %d. Попробуйте, пожалуйста, позже. Если проблема будет продолжать возникать - обратитесь в службу поддержки. Нет открытых сессий WalletConnect Упс. Нет сессий. Не удалось создать пару WalletConnect: %1$s @@ -845,6 +853,8 @@ Ошибка активации По решению разработчиков сети BNB стандарт BEP-2 перестанет поддерживаться в июне 2024 года. Чтобы не потерять активы, их необходимо преобразовать в стандарт BEP-20. Используйте функцию обмена в приложении или сторонние сервисы, чтобы перевести средства в cеть BNB Smart Chain. Отключение сети BNB Beacon Chain + Внесите немного %1$s, чтобы покрыть комиссию сети + Недостаточно средств для оплаты комиссии сети Можно лучше Нравится Понятно! @@ -882,8 +892,8 @@ На этой карте осталось всего %s подписей. Вам следует вывести все ваши средства. Малое количество подписей Токены на разных сетях могут иметь разные адреса. Пожалуйста, убедитесь при переводе средств, что ваш адрес соответствует сети. - Миграция MATIC в POL Токен MATIC мигрирует на POL. Однако крайний срок не установлен, и MATIC пока не устарел. Вы можете спокойно продолжать использовать токен MATIC или воспользоваться биржами, чтобы обменять его на POL. + Миграция MATIC в POL Используйте вашу карту, чтобы получить адрес для %d сети Используйте вашу карту, чтобы получить адреса для %d сетей diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index bd027b279d..9ef0e3fb24 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -39,7 +39,7 @@ Забагато спроб Ви вимкнули біометричну автентифікацію на своєму телефоні і не зможете зберігати гаманці в додатку. Щоб зберегти гаманці, будь ласка, увімкніть функцію біометричної автентифікації в налаштуваннях телефону. Почніть процес резервного копіювання - З вашої фіатної картки або банківського рахунку + За допомогою банківської картки або банківського рахунку %d картка %d картки @@ -72,18 +72,21 @@ Доступ заборонено Усе Дозволити + Аналітика Застосовувати Затвердження Підтвердити Увага Баланс: %s Баланс - біометрична автентифікація - біометрією + біометричну автентифікацію + біометрії Купити Перейдіть до %1$s Ви не надали доступ до камери, будь ласка, змініть налаштування конфіденційності Скасувати + Виберіть дію + Отримати Отримати винагороди Закрити Продовжити @@ -104,10 +107,10 @@ Увімкнути Увімкнено Помилка + Обміняти Оглядач Переглянути історію транзакцій Оглядач - Комісія Мережеві комісії – це збори, які користувачі сплачують за обробку та підтвердження транзакцій. На розмір комісії може впливати перевантаження мережі, розмір транзакції та пріоритет виконання. %s Швидко За ринком @@ -117,7 +120,9 @@ Перейти до провайдера Перейти до токену Імпортувати + процесі Пізніше + Залишилося %1$s Заблокований Основна мережа Комісія мережі @@ -130,6 +135,7 @@ Основна картка Парольна фраза Вставити + Політикою конфіденційності %1$s-%2$s %1$s — %2$s Детальніше @@ -152,11 +158,12 @@ Застейкати Стейкінг Почати - Надіслати + Продовжити Успіх Підтримка Обмін умови участі + Умовами використання Сьогодні Помилка транзакції Транзакції @@ -172,7 +179,9 @@ Адреса контракту Адреса контракту недійсна Будь ласка, оберіть мережу - Десяткове число повинно бути дійсним цілим числом, до %li + Цей токен вже додано до списку + Токен вже існує + Десяткове число повинно бути дійсним цілим числом, до %d Власна деривація Наприклад, m/00\'/0000\'/0\'/0/0 Введіть власну деривацію @@ -260,7 +269,6 @@ Використовуючи сервіс обміну, ви погоджуєтеся з його %s Використовуючи сервіс обміну, ви погоджуєтеся з його %1$s та %2$s Незабаром з\'являться нові провайдери.\nСлідкуйте за новинами! - Політикою конфіденційності Провайдер Найкращий курс Доступно до %s @@ -268,7 +276,6 @@ Недоступно для цієї пари Потрібен дозвіл Рекомендовано - Умовами використання Токенів не знайдено. Будь ласка, спробуйте інший запит ID: %s ID транзакції скопійовано @@ -294,6 +301,7 @@ Необмежено Купити Сканувати + Ця інформація була створена за допомогою ШІ Щоб змінити код доступу, прикладіть картку, як показано вище, і не прибирайте її до закінчення операції Щоб змінити пароль, прикладіть картку, як показано вище, і не прибирайте її до закінчення операції Щоб створити гаманець, прикладіть картку, як показано вище, і не прибирайте її до завершення операції @@ -344,15 +352,17 @@ Щоб почати купувати, обмінювати або отримувати цей актив, додайте цей токен принаймні в 1 мережу Цей актив недоступний Додати в портфоліо - Додати токен + Додати Доступні мережі Моє портфоліо Маркет Щоб згенерувати адреси для обраних мереж, потрібно відсканувати свою картку Tangem + Дані цього розділу отримані з наступних мереж: %s Не вдалося завантажити дані... + Немає даних Швидкі дії Результат - Переглянути токени до 100к ринкової капіталізації + Переглянути токени до 100к USD ринкової капіталізації Показати токени Жодного результату Виберіть мережу @@ -362,7 +372,7 @@ 24 год. 3 міс. 6 міс. - 7 днів + Увесь Досвідчені покупці За рейтингом @@ -377,7 +387,7 @@ На основі %d оцінок На основі %d оцінок - Блокчейн сайт + Веб-сайт Давлення покупця Різниця між обсягом покупців та обсягом продавців Циркуляційний запас @@ -396,9 +406,9 @@ Зміна того, скільки ліквідності доступно для токена протягом зазначеного періоду часу Індекс ліквідності Низький - Ринкова капіталізація + Рин. кап. Загальна ринкова вартість криптовалюти, що розраховується шляхом множення поточної ціни монети на загальну кількість монет в обігу - Рейтинг ринку + Рейтинг Позиція в крипторейтингу між усіма монетами на основі ринкової капіталізації Максимальна пропозиція Метрики @@ -411,6 +421,9 @@ Максимальна кількість монет або токенів, яка може коли-небудь існувати для певної криптовалюти Обсяг торгів (24г) Загальна сума криптовалюти, якою торгували протягом останніх 24 годин, що вказує на рівень активності та ліквідності на ринку + Потягніть вгору або торкніться панелі пошуку, щоб додати токени безпосередньо з маркету + Додати токени + Функція NFC недоступна на вашому пристрої Вам потрібно встановити єдиний код доступу для захисту всіх ваших карток Захист Пізніше ви зможете налаштувати індивідуальний код доступу до кожної картки @@ -473,7 +486,7 @@ Використовувати seed-фразу Невірна seed-фраза. Будь ласка, перевірте порядок слів. Невірна seed-фраза. Будь ласка, перевірте правопис. - Застарілий + Застаріло Щоб перевірити чи правильно ви записали seed-фразу, введіть 2-е, 7-е та 11-те слова Отже, давайте перевіримо Щоб почати процес резервного копіювання, додайте одну або дві резервні картки. @@ -530,7 +543,7 @@ за %d гаманців за %d гаманців - Отримайте ^^%1$s^^ на вашу адресу в мережі %2$s%3$s ^^через 30 днів^^ за кожен гаманець, який придбає ваш друг + Отримаєте ^^%1$s^^ на вашу адресу в мережі %2$s %3$s ^^через 30 днів^^ за кожен гаманець, який придбає ваш друг Ви Отримає при купівлі гаманця на сайті tangem.com @@ -583,18 +596,11 @@ Комісія, необхідна за використання кожної невитраченої транзакції (UTXO) у мережі Kaspa. Чим більше UTXO ви використовуєте в транзакції, тим вищою буде комісія. KAS за UTXO %1$s, %2$s - Адреса Тег призначення Введіть адресу Адреса збігається з адресою гаманця - Недопустимий Tag. Він не буде доданий у транзакцію. Недопустимий Memo. Він не буде доданий до транзакції. - Tag Memo - Включаючи комісію - Низька - Нормальна - Пріоритетна Перевірте підключення до мережі Інформація щодо комісії в мережі недоступна Із @@ -609,7 +615,7 @@ Покриття мережевої комісії Недостатньо коштів для здійснення переказу, оскільки загальна сума комісії та переказу перевищує наявний баланс Сума перевищує баланс - Рахунок буде видалено з блокчейну, якщо баланс стане нижчим за екзистенційний депозит. Будь ласка, залиште %s на своєму балансі. + Для збереження вашого акаунту у блокчейні та захисту від можливих ризиків необхідний баланс не менше %s. Ця сума залишиться на вашому рахунку та не може бути знята. Екзистенційний депозит Сума комісії в %s разів перевищує рекомендовану. Переконайтеся, що користувацькі налаштування вірні. Встановлена комісія завелика @@ -643,25 +649,23 @@ Надіслати %s Ви надсилаєте **%1$s**, включно з комісію мережі %2$s Ви надсилаєте **%1$s** і %2$s - Надсилання %s - Всього - %1$s та %2$s буде надіслано - ≈ %1$s (вкл. комісію: %2$s ) - %s буде надіслано + Ви надсилаєте **%1$s** + комісія мережі буде покрита, використовуючи %1$s енергію + комісія мережі буде зменшена, витрачаючи %1$s + включно з комісією мережі %1$s Транзакція успішно підписана і відправлена до блокчейну. Баланс гаманця буде оновлено через деякий час %1$s — це монета у мережі Tron. Щоб розрахувати комісію та здійснити транзакцію, вам необхідно внести певну кількість Tron (TRX) на свій рахунок. - Недійсна адреса Трансакцію надіслано - Підготуйтеся до сканування картку, яку потрібно налаштувати. + Підготуйте до сканування картку, яку потрібно налаштувати. Забути гаманець Це призведе до видалення гаманця з застосунку. Сам гаманець можна додати знову. Ім\'я - Активний - Щоб вивести активи зі стейкінгу, натисніть тут. Сума для стейкінгу має бути не менше %s Зняти кошти + Процентна ставка Річний відсоток, який ви можете отримати, беручи участь у стейкінгу. APR + Винагороди автоматично накопичуються на вашому балансі щодня. Доступно Середня ставка винагороди Що таке стейкінг? @@ -680,14 +684,29 @@ Період, який ви повинні чекати після запиту на виведення коштів зі стейкінгу, перш ніж токени стануть доступними. Період блокування Відведений час для активації участі в стейкінгу. + Використовуючи функцію стейкінгу, ви погоджуєтесь з %1$s та %2$s + Заблоковано Перемістити Нативний стейкінг + Зароблені винагороди будуть надіслані на ваш гаманець і доступні для використання відразу + Стейкайте безпечно та почніть отримувати винагороди щоденно + Стейкайте безпечно та почніть отримувати винагороди щогодини + Стейкайте безпечно та почніть отримувати винагороди щомісяця Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг зараховуються щодня. Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг зараховуються кожну годину. Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг зараховуються щомісяця. Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг зараховуються щотиждня. + Стейкайте безпечно та почніть отримувати винагороди щотижня Отримуйте винагороду за стейкінг - Винагороди припиняють нараховуватися одразу після того, як ви знімаєте ставку. Процес зняття займає %s. + Стейкінг в мережі %1$s з новим валідатором автоматично переведе всі раніше застейкані кошти до цього валідатора + Реінвестуйте зароблені винагороди у суму стейкінгу, щоб збільшити потенційний прибуток. + Розблокуйте свої кошти, щоб вивести їх зі стейкінгу. Розблокування займе %s. + Ваші кошти будуть доступні для використання після закінчення 21-денного періоду розблокування. Винагорода буде отримана разом з вашими незастейканими коштами. + Ваші кошти будуть доступні після %s періоду розблокування. + Тепер ви можете вивести свої кошти, вони будуть доступні для використання відразу + Стейкінг в мережі Tron з новим валідатором автоматично переведе всі раніше застейкані кошти до цього валідатора + Підготовка + Готова до виведення Зʼєднати ще раз Повторно застейкати Застейкати винагороди @@ -697,24 +716,32 @@ Вручну Блок День + Щодня Епоха Ера Година Місяць Тиждень Винагороди - Застейкати + Стейкінг закрито Застейкати більше + Ви стейкаєте %1$s і будете отримувати %2$s щороку + Натисніть, щоб розблокувати + Натисніть, щоб вивести кошти Застейкати %s Зняти зі стейкінгу %s + Транзакція обробляється! Наразі триває перевірка в блокчейні. Це може зайняти кілька хвилин. + Розблокування Розблокувати - Скасовано зі стейкінгу - Перевірити незастейкані, щоб отримати свої активи + Вивід зі стейкінгу Зняти зі стейкінгу + Виведення зі стейкінгу %s Валідатор + Валідатори Проголосувати Голос заблоковано Зняти + Ваші стейкінги Тримайте свою криптовалюту в безпеці. Приватні ключі надійно зберігаються на картці. Революційний апаратний гаманець До трьох карток з одним гаманцем @@ -738,7 +765,6 @@ Обмін цієї кількості обраних токенів призведе до значного впливу на ціну і зменшить вашу кінцеву суму. Недостатньо коштів Надати дозвіл - В процесі Обміняти Ви отримаєте Оберіть токен @@ -843,6 +869,8 @@ Налаштування гаманця Tangem Використовуйте %s або відскануйте картку, щоб розблокувати доступ до гаманця + Процес підтвердження наразі триває і буде завершено найближчим часом + Підтвердження в процесі Схоже, що активація картки була виконана неправильно. Це може бути пов\'язано з проблемою з модулем NFC вашого пристрою або неправильним прикладанням картки до пристрою. Зверніться за допомогою до нашої служби підтримки. Помилка активації За рішенням розробників мережі BNB стандарт BEP-2 перестане підтримуватись у червні 2024 року. Щоб не втратити свої активи, їх необхідно конвертувати у стандарт BEP-20. Використовуйте функцію обміну, щоб перевести їх у мережу BNB Smart Chain. @@ -884,6 +912,8 @@ На цій картці залишається лише %s підписів. Ви повинні вивести всі свої кошти. Низька кількість підписів Токени в різних мережах можуть мати різні адреси. Переконайтеся, що ваша адреса відповідає мережі, коли переказуєте кошти. + Токен MATIC мігрує до POL. Однак кінцевого терміну не встановлено і MATIC ще не застарілий. Ви можете безпечно продовжувати використовувати токен MATIC або скористатися біржами, щоб обміняти його на POL. + Міграція MATIC у POL Використовуйте вашу картку, щоб отримати адресу для %d мережі Використовуйте вашу картку, щоб отримати адреси для %d мереж diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index eb5bc2de81..f144efa47f 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -57,12 +57,13 @@ 允許 啟用 錯誤 - 費用 導入 + 進行中 網路費 OK 主卡片 + %1$s-%2$s 拒絕 重新命名 保存設置 @@ -250,24 +251,11 @@ 掃描卡片以更改其設置。這些更改只會影響您掃描過的卡,不會影響綁定到您錢包的其他卡。 準備好您的卡! 數量 - 地址 地址與錢包地址相同 - 標籤無效。它不會被添加到交易中 Memo無效。 它不會被添加到交易中 - Tag Memo - 包含費用 - - 正常 - 優先 最大值 - 發送 %s - 總計 - %1$s 和 %2$s 將被發送 - ≈ %1$s (費用包含: %2$s) - %s 將被發送 交易已成功簽署並發送至區塊鏈節點。錢包餘額稍後更新 - 無效地址 安全地存儲您的加密貨幣,同時將私鑰保存在您的卡中 創新式的硬體錢包 最多 3張實體卡片 到一個錢包 @@ -284,7 +272,6 @@ 在此代幣交換的數量將對價格產生重大影響,並降低您收到的數量 餘額不足 賦予權限 - 進行中 交易 選擇代幣 無法使用 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 957150188b..12c3f39762 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -70,6 +70,7 @@ Access denied All Allow + Analytics Apply Approval Approve @@ -82,6 +83,8 @@ Go to %1$s You have not given access to your camera, please adjust your privacy settings Cancel + Choose action + Claim Claim rewards Close Continue @@ -104,7 +107,6 @@ Explore Explore transaction history Explorer - Fee Network fees are charges users pay to process and confirm transactions. The fee amount can be affected by network congestion, transaction size, and execution priority. %s Fast Market @@ -114,7 +116,9 @@ Go to provider Go to token Import + In progress Later + %1$s left Locked Main network Network fee @@ -127,6 +131,7 @@ Primary Card Passphrase Paste + Privacy Policy %1$s-%2$s %1$s — %2$s Read more @@ -154,6 +159,7 @@ Support Swap terms and conditions + Terms of Use Today Transaction failed Transactions @@ -169,7 +175,9 @@ Contract address Contract address is invalid Please select the network - Decimal must be a valid integer, up to %li + This token has already been added to the list + Token already exists + Decimal must be a valid integer, up to %d Custom derivation E. g. m/00\'/0000\'/0\'/0/0 Enter custom derivation @@ -257,7 +265,6 @@ By using swap functionality, you agree with provider’s %s By using swap functionality, you agree with provider’s %1$s and %2$s More providers are coming soon.\nStay tuned! - Privacy Policy Provider Best rate Available up to %s @@ -265,7 +272,6 @@ Unavailable for this pair Permission Required Recommended - Terms of Use No tokens found. Please try another request ID: %s Transaction ID copied @@ -291,6 +297,7 @@ Unlimited Order card Scan card + This information was generated with AI To change the access code tap the card as shown above and do not remove until the end of the operation To change the passcode tap the card as shown above and do not remove until the end of the operation To create the wallet tap the card as shown above and do not remove until the end of the operation @@ -344,10 +351,13 @@ My portfolio Market To generate addresses for selected networks, you must scan your Tangem Wallet card + To add tokens pull this up or tap the search bar + This section’s data is sourced from the following networks: %s Unable to load the data… + No data Quick actions Result - See tokens under 100k market cap + See tokens under 100k USD market cap Show tokens No result Select network @@ -370,7 +380,7 @@ Based on %d rating Based on %d ratings - Blockchain site + Website Buy pressure The difference between buyers volume and sellers volume Circulating supply @@ -389,7 +399,7 @@ The change in how much liquidity is available for the token during the specified timeframe Liquidity index Low - Market capitalization + Market cap The total market value of a cryptocurrency, calculated by multiplying the current price of the coin by the total number of coins in circulation Market rating Position in crypto rating between all coins based on market capitalization @@ -404,6 +414,9 @@ The maximum number of coins or tokens that can ever exist for a particular cryptocurrency Trading volume (24h) The total amount of a cryptocurrency that has been traded within the last 24 hours, indicating the level of activity and liquidity in the market + Pull this up or tap the search bar to add tokens directly from the market + Add tokens + NFC is not available on your device You have to set up a single access code to protect all your cards Protect You can set up an individual access code on each card later @@ -568,18 +581,11 @@ The fee required for using each unspent transaction output (UTXO) in the Kaspa network. The more UTXOs you use in a transaction, the higher the fee will be. KAS per UTXO %1$s, %2$s - Address Destination Tag Enter address Address is the same as wallet address - Invalid Tag. It won\'t be added to the transaction. Invalid Memo. It won\'t be added to the transaction. - Tag Memo - Include fee - Low - Normal - Priority Check your network connection Network fee info unreachable From @@ -594,7 +600,7 @@ Network fee coverage Insufficient funds for the transfer, as the total of the fee and transfer amount exceeds the existing balance Total exceeds balance - The account will be wiped from the blockchain if a balance goes below the existential deposit. Please leave %s on your balance. + A balance of at least %s is required to keep your account on the blockchain to prevent security risks. This amount will remain in your balance and cannot be withdrawn. Existential deposit The commission amount is %s times the recommended amount. Make sure that the custom settings are correct. Custom fee is high @@ -628,26 +634,24 @@ Send %s You are sending **%1$s** including a network fee of %2$s You are sending **%1$s** and %2$s - Sending %s - Total - %1$s and %2$s will be sent - ≈ %1$s (inc. fee: %2$s) - %s will be sent + You are sending **%1$s** + network fee will be covered by using %1$s energy + network fee will be reduced by spending %1$s energy + including a network fee of %1$s Transaction has been successfully signed and sent to the blockchain node. Wallet balance will be updated in a while %1$s is an asset in the Tron network. To calculate the fee and make a transaction you need to deposit some Tron (TRX) in your account. - Invalid address Transaction sent Prepare to scan card you want to setup. Forget wallet This will remove the wallet from the application. The wallet itself can be added again. Name - Active - To unstake your assets, click here. The amount to stake must be at least %s + Staking amount will be rounded to %1$s TRX due to network rules. Claim unstaked Annual percentage rate The annual percentage return you can earn from participating in staking. APR + Rewards automatically accumulate in your staking balance daily. Available Average Reward Rate What is Staking? @@ -655,25 +659,40 @@ Market rating Metrics Minimum Requirement - No rewards to claim + No rewards Reward claiming Method of receiving staking rewards.\nIt can be either automatic, where the reward is credited to your address, or manual, where you need to withdraw the reward by creating a transaction to receive it. Reward schedule This is a schedule that determines when participants in staking receive their rewards. - Rewards to claim: %s + Rewards: %s Staking %s Unbonding Period The period you must wait after requesting to withdraw funds from staking before the tokens become available. Warmup period The allocated time for activating participation in staking. + By using staking functionality, you agree with provider’s %1$s and %2$s + Locked Migrate Native staking - Staking allow you to earn %1$s. Your staking rewards arrive every day. - Staking allow you to earn %1$s. Your staking rewards arrive every hour. - Staking allow you to earn %1$s. Your staking rewards arrive every month. - Staking allow you to earn %1$s. Your staking rewards arrive every week. + Earned rewards will be sent to your wallet and available for use immediately + Stake securely and start earning daily rewards + Stake securely and start earning hourly rewards + Stake securely and start earning monthly rewards + Staking allows you to earn %1$s. Your staking rewards arrive every day. + Staking allows you to earn %1$s. Your staking rewards arrive every hour. + Staking allows you to earn %1$s. Your staking rewards arrive every month. + Staking allows you to earn %1$s. Your staking rewards arrive every week. + Stake securely and start earning weekly rewards Earn staking rewards - Rewards stop accruing immediately after you unstake. The unstaking process takes %s. + Staking in the %1$s network with a new validator will automatically transfer all previously staked funds to this validator + Reinvests your earned rewards in your staked amount, increasing potential earnings. + Unlock your money to withdraw it from staking process. Unlocking takes %s. + Your funds will be available for use after the 21-day unbonding period. Reward will be withdrawn along with your unstaking funds. + Your funds will be available for use after the %s unbonding period. + You can now withdraw your funds, it will be available to use immediately + Staking in the Tron network with a new validator will automatically transfer all previously staked funds to this validator + Preparing + Ready to withdraw Rebond Restake Restake rewards @@ -692,17 +711,23 @@ Rewards Stake locked Stake more + You stake %1$s and will be receiving %2$s yearly + Tap to unlock + Tap to withdraw Stake %s Unstake %s - Unlock locked + The transaction is being processed! Validation is currently underway in the blockchain. This may take a few minutes. + Unbonding + Unlock Unstaked - Check unstaked to claim your assets Unstaking + Unstaking assets %s Validator Validators Vote Vote locked Withdraw + Your stakes Store your crypto assets secure while keeping private keys contained in your card Revolutionary Hardware Wallet Up to 3 physical cards to one wallet @@ -726,7 +751,6 @@ Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome. Insufficient funds Give Permission - In progress Swap You receive Choose token @@ -838,6 +862,8 @@ Activation error According to BNB network developers, support for the BEP-2 standard will end in June 2024. To avoid losing assets with this standard, please convert them to the BEP-20 standard. Use our swap service or third-party services to transfer funds to the BNB Smart Chain network. BNB Beacon Chain will shut down + Please deposit some %1$s to cover the network fee + Insufficient funds to cover the network fee Could be better Like it Ok, Got it! @@ -875,8 +901,8 @@ Only %s signatures are left on this card. You must withdraw all of your funds. Low signature count Tokens on different networks can have different addresses. Double-check that your address matches the network when you transfer funds. - MATIC to POL Migration MATIC is being migrated to POL. However there is no deadline set and MATIC isn\'t being deprecated yet. You can safely continue using MATIC token or use exchanges to swap it for POL. + MATIC to POL Migration Use your card to get an address for %d network Use your card to get an addresses for %d networks diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index cb8f7f15ee..3781bde624 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { implementation(deps.compose.coil) implementation(deps.compose.navigation) implementation(deps.compose.navigation.hilt) + implementation(deps.compose.reorderable) /** Other libraries */ implementation(deps.compose.accompanist.systemUiController) diff --git a/core/ui/src/main/java/com/tangem/core/ui/coil/RotationTransformation.kt b/core/ui/src/main/java/com/tangem/core/ui/coil/RotationTransformation.kt new file mode 100644 index 0000000000..6a8019af1e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/coil/RotationTransformation.kt @@ -0,0 +1,22 @@ +package com.tangem.core.ui.coil + +import android.graphics.Bitmap +import android.graphics.Matrix +import coil.size.Size +import coil.transform.Transformation + +class RotationTransformation(private val angle: Float) : Transformation { + + override val cacheKey: String = "rotate:$angle" + + override suspend fun transform(input: Bitmap, size: Size): Bitmap { + val matrix = Matrix().apply { + val centerX = input.width / 2f + val centerY = input.height / 2f + + postRotate(angle, centerX, centerY) + } + + return Bitmap.createBitmap(input, 0, 0, input.width, input.height, matrix, true) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt index 97a3b692cc..e741b087fe 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt @@ -49,10 +49,10 @@ import kotlinx.collections.immutable.toImmutableList @Composable fun BasicDialog( message: String, - confirmButton: DialogButton, + confirmButton: DialogButtonUM, onDismissDialog: () -> Unit, title: String? = null, - dismissButton: DialogButton? = null, + dismissButton: DialogButtonUM? = null, isDismissable: Boolean = true, ) { TangemDialog( @@ -72,7 +72,7 @@ fun BasicDialog( fun SimpleOkDialog(message: String, onDismissDialog: () -> Unit) { TangemDialog( type = DialogType.Message(message), - confirmButton = DialogButton(onClick = onDismissDialog), + confirmButton = DialogButtonUM(onClick = onDismissDialog), onDismissDialog = onDismissDialog, title = null, dismissButton = null, @@ -97,12 +97,12 @@ fun SimpleOkDialog(message: String, onDismissDialog: () -> Unit) { @Composable fun TextInputDialog( fieldValue: TextFieldValue, - confirmButton: DialogButton, + confirmButton: DialogButtonUM, onDismissDialog: () -> Unit, onValueChange: (TextFieldValue) -> Unit, - textFieldParams: AdditionalTextInputDialogParams = remember { AdditionalTextInputDialogParams() }, + textFieldParams: AdditionalTextInputDialogUM = remember { AdditionalTextInputDialogUM() }, title: String? = null, - dismissButton: DialogButton? = null, + dismissButton: DialogButtonUM? = null, isDismissable: Boolean = true, ) { TangemDialog( @@ -125,12 +125,12 @@ fun TextInputDialog( @Composable fun TextInputDialog( fieldValue: String, - confirmButton: DialogButton, + confirmButton: DialogButtonUM, onDismissDialog: () -> Unit, onValueChange: (String) -> Unit, - textFieldParams: AdditionalTextInputDialogParams = remember { AdditionalTextInputDialogParams() }, + textFieldParams: AdditionalTextInputDialogUM = remember { AdditionalTextInputDialogUM() }, title: String? = null, - dismissButton: DialogButton? = null, + dismissButton: DialogButtonUM? = null, isDismissable: Boolean = true, ) { TangemDialog( @@ -154,7 +154,7 @@ fun TextInputDialog( fun SelectorDialog( selectedItemIndex: Int, items: ImmutableList, - confirmButton: DialogButton, + confirmButton: DialogButtonUM, onSelect: (index: Int) -> Unit, onDismissDialog: () -> Unit, title: String? = null, @@ -180,7 +180,7 @@ fun SelectorDialog( * @param enabled If false button will be disabled * @param onClick Button click callback */ -data class DialogButton( +data class DialogButtonUM( val title: String? = null, val warning: Boolean = false, val enabled: Boolean = true, @@ -190,7 +190,7 @@ data class DialogButton( /** * Additional params for dialog text field */ -data class AdditionalTextInputDialogParams( +data class AdditionalTextInputDialogUM( val label: String? = null, val placeholder: String? = null, val caption: String? = null, @@ -203,10 +203,10 @@ data class AdditionalTextInputDialogParams( @Composable private fun TangemDialog( type: DialogType, - confirmButton: DialogButton, + confirmButton: DialogButtonUM, onDismissDialog: () -> Unit, title: String? = null, - dismissButton: DialogButton? = null, + dismissButton: DialogButtonUM? = null, properties: DialogProperties = DialogProperties(), ) { Dialog(properties = properties, onDismissRequest = onDismissDialog) { @@ -304,7 +304,11 @@ private fun DialogContent(type: DialogType, modifier: Modifier = Modifier) { } @Composable -private fun DialogButtons(confirmButton: DialogButton, dismissButton: DialogButton?, modifier: Modifier = Modifier) { +private fun DialogButtons( + confirmButton: DialogButtonUM, + dismissButton: DialogButtonUM?, + modifier: Modifier = Modifier, +) { Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy( @@ -413,13 +417,13 @@ private sealed class DialogType { data class TextInput( val value: TextFieldValue, val onValueChange: (TextFieldValue) -> Unit, - val params: AdditionalTextInputDialogParams = AdditionalTextInputDialogParams(), + val params: AdditionalTextInputDialogUM = AdditionalTextInputDialogUM(), ) : DialogType() data class SimpleTextInput( val value: String, val onValueChange: (String) -> Unit, - val params: AdditionalTextInputDialogParams = AdditionalTextInputDialogParams(), + val params: AdditionalTextInputDialogUM = AdditionalTextInputDialogUM(), ) : DialogType() data class Selector( @@ -445,8 +449,8 @@ private fun BasicDialogPreview() { message = "All protected passwords will be deleted from the secure storage, you must enter the wallet " + "password to work with the app", title = "Attention", - confirmButton = DialogButton {}, - dismissButton = DialogButton {}, + confirmButton = DialogButtonUM {}, + dismissButton = DialogButtonUM {}, onDismissDialog = {}, ) } @@ -478,8 +482,8 @@ private fun WarningBasicDialogSample(modifier: Modifier = Modifier) { message = "All protected passwords will be deleted from the secure storage, you must enter the wallet " + "password to work with the app", title = "Attention", - confirmButton = DialogButton(warning = true) {}, - dismissButton = DialogButton {}, + confirmButton = DialogButtonUM(warning = true) {}, + dismissButton = DialogButtonUM {}, onDismissDialog = {}, ) } @@ -502,10 +506,10 @@ private fun TextInputDialogSample(modifier: Modifier = Modifier) { TextInputDialog( fieldValue = TextFieldValue(text = ""), title = "Rename Wallet", - confirmButton = DialogButton {}, + confirmButton = DialogButtonUM {}, onDismissDialog = {}, onValueChange = {}, - textFieldParams = AdditionalTextInputDialogParams( + textFieldParams = AdditionalTextInputDialogUM( label = "Wallet name", ), ) @@ -530,7 +534,7 @@ private fun SelectorDialogPreview(@PreviewParameter(SelctorDialogParamsProvider: title = param.title, items = param.items, selectedItemIndex = param.selectedItemIndex, - confirmButton = DialogButton(title = "Cancel", onClick = {}), + confirmButton = DialogButtonUM(title = "Cancel", onClick = {}), onSelect = {}, onDismissDialog = {}, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Keyboard.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Keyboard.kt index 72a9f3a11e..74029fd218 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Keyboard.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Keyboard.kt @@ -1,15 +1,14 @@ package com.tangem.core.ui.components +import android.os.Build import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.ime -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +@Immutable sealed interface Keyboard { val height: Dp @@ -30,6 +29,34 @@ val Keyboard.isOpened: Boolean @Composable fun keyboardAsState(): State { val bottom = WindowInsets.ime.getBottom(LocalDensity.current) - val isImeVisible = bottom > 0 - return rememberUpdatedState(if (isImeVisible) Keyboard.Opened(bottom.dp) else Keyboard.Closed) + val bottomDp = with(LocalDensity.current) { bottom.toDp() } + + val keyboardStateInternal by rememberUpdatedState( + if (bottom > 0) Keyboard.Opened(bottomDp) else Keyboard.Closed, + ) + + val keyboardState = remember { mutableStateOf(keyboardStateInternal) } + + LaunchedEffect(keyboardStateInternal) { + val falsePositive = Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q && + keyboardStateInternal is Keyboard.Opened && + keyboardStateInternal.height < 50.dp + // FIX android <=10 devices can randomly send ime paddings, + // which leads to a false positive keyboard opening ([REDACTED_TASK_KEY]) + if (falsePositive) return@LaunchedEffect + + keyboardState.value = keyboardStateInternal + } + + return keyboardState +} + +@Composable +fun rememberIsKeyboardVisible(): State { + val keyboard by keyboardAsState() + return remember(keyboard) { + derivedStateOf { + keyboard.isOpened + } + } } \ 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 6b497b4af0..cb5266dce9 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 @@ -16,7 +16,7 @@ import com.tangem.core.ui.res.TangemTheme /** * [Show in Figma](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=281-248&mode=design&t=bXqehWPHyATKcZEW-4) - * */ + **/ @Composable fun SimpleSettingsRow( title: String, @@ -26,10 +26,11 @@ fun SimpleSettingsRow( rowColors: RowColors = getDefaultRowColors(), enabled: Boolean = true, subtitle: String? = null, + redesign: Boolean = false, ) { Row( modifier = modifier - .height(TangemTheme.dimens.size56) + .height(if (redesign) TangemTheme.dimens.size48 else TangemTheme.dimens.size56) .fillMaxWidth() .clickable( onClick = { @@ -44,10 +45,16 @@ fun SimpleSettingsRow( Icon( painter = painterResource(id = icon), contentDescription = null, - modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing20), + modifier = Modifier + .padding(horizontal = if (redesign) TangemTheme.dimens.spacing12 else TangemTheme.dimens.spacing20), tint = rowColors.iconColor(enabled = enabled).value, ) - Column(modifier = Modifier.padding(end = TangemTheme.dimens.spacing20)) { + Column( + modifier = Modifier + .padding( + end = if (redesign) TangemTheme.dimens.spacing12 else TangemTheme.dimens.spacing20, + ), + ) { Text( text = title, style = TangemTheme.typography.subtitle1, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Shimmers.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Shimmers.kt index 5d09690136..e613aa253a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Shimmers.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Shimmers.kt @@ -17,14 +17,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.buttons.PrimarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.chip.Chip import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.* import com.valentinilk.shimmer.* @@ -101,7 +105,11 @@ fun TextShimmer( * Height and min width will be set automatically */ @Composable -fun SmallButtonShimmer(modifier: Modifier = Modifier, withIcon: Boolean = false) { +fun SmallButtonShimmer( + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(size = TangemTheme.dimens.radius16), + withIcon: Boolean = false, +) { PrimarySmallButton( config = SmallButtonConfig( text = stringReference("B"), @@ -113,11 +121,27 @@ fun SmallButtonShimmer(modifier: Modifier = Modifier, withIcon: Boolean = false) }, ), modifier = modifier - .clip(RoundedCornerShape(size = TangemTheme.dimens.radius16)) + .clip(shape) .shimmer(LocalTangemShimmer.current), ) } +/** + * Shimmer for Chip + * Height and min width will be set automatically + */ +@Composable +fun ChipShimmer(modifier: Modifier = Modifier) { + Chip( + modifier = modifier + .clip(RoundedCornerShape(size = 100.dp)) + .shimmer(LocalTangemShimmer.current), + text = TextReference.EMPTY, + iconResId = R.drawable.ic_plus_24, + onClick = {}, + ) +} + internal val TangemShimmer: Shimmer @Composable get() = rememberShimmer( @@ -190,6 +214,7 @@ private fun ShimmersPreview() { ) SmallButtonShimmer(withIcon = true) SmallButtonShimmer(withIcon = false) + ChipShimmer() } } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt b/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt index 7b3c924e95..972f347106 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -47,7 +47,7 @@ fun TangemSwitch( modifier = modifier .clickable( interactionSource = interactionSource, - indication = rememberRipple( + indication = ripple( bounded = false, color = Color.Transparent, ), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/atoms/radiobutton/TangemRadioButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/radiobutton/TangemRadioButton.kt index 777062e8bd..31d56ce3c5 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/atoms/radiobutton/TangemRadioButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/radiobutton/TangemRadioButton.kt @@ -8,8 +8,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon +import androidx.compose.material3.ripple import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.drawscope.Stroke @@ -39,7 +39,7 @@ fun TangemRadioButton( modifier = modifier.clickable( enabled = isEnabled, interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = TangemTheme.dimens.size16), + indication = ripple(bounded = false, radius = TangemTheme.dimens.size16), onClick = onClick, ), ) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlock.kt b/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlock.kt index 900aafe401..3c5b7a606d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlock.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlock.kt @@ -9,11 +9,14 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import com.tangem.core.ui.R import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.rows.NetworkTitle import com.tangem.core.ui.components.text.TooltipText import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference @@ -29,50 +32,23 @@ class InformationBlockContentScope(val scope: BoxScope) : BoxScope by scope fun InformationBlock( title: @Composable BoxScope.() -> Unit, modifier: Modifier = Modifier, + contentHorizontalPadding: Dp = TangemTheme.dimens.spacing12, + shape: Shape = TangemTheme.shapes.roundedCornersXMedium, action: (@Composable BoxScope.() -> Unit)? = null, content: (@Composable InformationBlockContentScope.() -> Unit)? = null, ) { Column( modifier = modifier - .clip(TangemTheme.shapes.roundedCornersXMedium) + .clip(shape) .background(color = TangemTheme.colors.background.action), horizontalAlignment = Alignment.Start, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = TangemTheme.dimens.size40) - .padding( - top = TangemTheme.dimens.spacing12, - bottom = TangemTheme.dimens.spacing6, - ) - .padding(horizontal = TangemTheme.dimens.spacing12), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .weight(weight = 1f) - .heightIn(min = TangemTheme.dimens.size20), - contentAlignment = Alignment.CenterStart, - content = title, - ) - if (action != null) { - Spacer(modifier = Modifier.size(TangemTheme.dimens.spacing8)) - Box( - modifier = Modifier - .weight(weight = 1f) - .heightIn(min = TangemTheme.dimens.size24), - contentAlignment = Alignment.CenterEnd, - content = action, - ) - } - } + NetworkTitle(title = title, action = action) if (content != null) { Box( modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing12) + .padding(horizontal = contentHorizontalPadding) .fillMaxWidth(), ) { val scope = InformationBlockContentScope(scope = this) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt index 6ef3e53d1f..d9b884cb75 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt @@ -1,5 +1,6 @@ package com.tangem.core.ui.components.bottomsheets +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet @@ -14,7 +15,6 @@ import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible -import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.WindowInsetsZero import kotlinx.coroutines.coroutineScope @@ -150,17 +150,15 @@ inline fun BasicBottomSheet( ) { val model = config.content as? T ?: return - val statusBarHeight = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() } val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } ModalBottomSheet( - // FIXME temporary solution to fix height of the bottom sheet - modifier = modifier.heightIn(max = LocalWindowSize.current.height - statusBarHeight), + modifier = modifier.statusBarsPadding(), onDismissRequest = config.onDismissRequest, sheetState = sheetState, containerColor = containerColor, shape = TangemTheme.shapes.bottomSheetLarge, - windowInsets = WindowInsetsZero, + contentWindowInsets = { WindowInsetsZero }, dragHandle = { TangemBottomSheetDraggableHeader(color = containerColor) }, ) { Column( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheetConfig.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheetConfig.kt index 0bb0162ccc..d2eb1f9935 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheetConfig.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheetConfig.kt @@ -11,4 +11,9 @@ data class TangemBottomSheetConfig( val isShow: Boolean, val onDismissRequest: () -> Unit, val content: TangemBottomSheetConfigContent, -) \ No newline at end of file +) { + + companion object { + val Empty = TangemBottomSheetConfig(false, {}, TangemBottomSheetConfigContent.Empty) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/SmallButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/SmallButton.kt index e02ae97109..195fae0923 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/SmallButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/SmallButton.kt @@ -33,6 +33,7 @@ data class SmallButtonConfig( val text: TextReference, val onClick: () -> Unit, val icon: TangemButtonIconPosition = TangemButtonIconPosition.None, + val enabled: Boolean = true, ) /** @@ -57,6 +58,7 @@ fun SecondarySmallButton(config: SmallButtonConfig, modifier: Modifier = Modifie SmallButton(config = config, isPrimary = false, modifier = modifier) } +@Suppress("LongMethod") @Composable private fun SmallButton(config: SmallButtonConfig, isPrimary: Boolean, modifier: Modifier = Modifier) { val shape = RoundedCornerShape(size = TangemTheme.dimens.radius16) @@ -77,7 +79,7 @@ private fun SmallButton(config: SmallButtonConfig, isPrimary: Boolean, modifier: color = backgroundColor, shape = shape, ) - .clickable(enabled = true, onClick = config.onClick) + .clickable(enabled = config.enabled, onClick = config.onClick) .padding( paddingValues = when (config.icon) { is TangemButtonIconPosition.None -> PaddingValues( @@ -100,7 +102,11 @@ private fun SmallButton(config: SmallButtonConfig, isPrimary: Boolean, modifier: iconPosition = config.icon, text = { val textColor by animateColorAsState( - targetValue = if (isPrimary) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.primary1, + targetValue = when { + !config.enabled -> TangemTheme.colors.text.disabled + isPrimary -> TangemTheme.colors.text.primary2 + else -> TangemTheme.colors.text.primary1 + }, label = "Update text color", ) @@ -116,7 +122,11 @@ private fun SmallButton(config: SmallButtonConfig, isPrimary: Boolean, modifier: Icon( modifier = Modifier.size(TangemTheme.dimens.size16), painter = painterResource(id = iconResId), - tint = TangemTheme.colors.icon.secondary, + tint = if (config.enabled) { + TangemTheme.colors.icon.secondary + } else { + TangemTheme.colors.icon.inactive + }, contentDescription = null, ) }, @@ -174,5 +184,12 @@ private fun ButtonsSample() { icon = TangemButtonIconPosition.Start(iconResId = R.drawable.ic_plus_24), ), ) + SecondarySmallButton( + config = config.copy( + text = TextReference.Str(value = "Add token"), + icon = TangemButtonIconPosition.Start(iconResId = R.drawable.ic_plus_24), + enabled = false, + ), + ) } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/chip/Chip.kt b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/chip/Chip.kt new file mode 100644 index 0000000000..3826991070 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/chip/Chip.kt @@ -0,0 +1,73 @@ +package com.tangem.core.ui.components.buttons.chip + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +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.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.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +fun Chip(text: TextReference, @DrawableRes iconResId: Int, onClick: () -> Unit, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .defaultMinSize( + minWidth = TangemTheme.dimens.size46, + minHeight = TangemTheme.dimens.size28, + ) + .clip(RoundedCornerShape(size = TangemTheme.dimens.size100)) + .clickable(onClick = onClick) + .background(color = TangemTheme.colors.background.tertiary) + .padding( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing6, + ), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = iconResId), + contentDescription = null, + modifier = Modifier.size(size = TangemTheme.dimens.size16), + tint = TangemTheme.colors.icon.secondary, + ) + + Text( + text = text.resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + Box(Modifier.background(TangemTheme.colors.background.action)) { + Chip( + text = stringReference("Chip Chip Chip"), + iconResId = R.drawable.ic_arrow_down_24, + onClick = {}, + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButton.kt index 11fcaeb099..3033f2221a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButton.kt @@ -1,5 +1,10 @@ package com.tangem.core.ui.components.buttons.common +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.Composable @@ -36,6 +41,7 @@ fun TangemButton( textStyle: TextStyle = TangemTheme.typography.button, shape: Shape = size.toShape(), iconPadding: Dp = size.toIconPadding(), + animateContentChange: Boolean = false, ) { val multipleClickPreventer = remember { MultipleClickPreventer.get() } @@ -56,6 +62,7 @@ fun TangemButton( buttonIcon = icon, iconPadding = iconPadding, showProgress = showProgress, + animateContentChange = animateContentChange, progressIndicator = { CircularProgressIndicator( modifier = Modifier.buttonContentSize(maxContentSize), @@ -108,24 +115,54 @@ private inline fun RowScope.ButtonContentContainer( buttonIcon: TangemButtonIconPosition, iconPadding: Dp, showProgress: Boolean, + animateContentChange: Boolean, progressIndicator: @Composable RowScope.() -> Unit, - text: @Composable RowScope.() -> Unit, - icon: @Composable RowScope.(Int) -> Unit, + crossinline text: @Composable RowScope.() -> Unit, + crossinline icon: @Composable RowScope.(Int) -> Unit, additionalText: @Composable () -> Unit, ) { if (showProgress) { progressIndicator() } else { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Row(horizontalArrangement = Arrangement.Center) { - if (buttonIcon is TangemButtonIconPosition.Start) { - icon(buttonIcon.iconResId) - Spacer(modifier = Modifier.requiredWidth(iconPadding)) + if (animateContentChange) { + AnimatedContent( + targetState = buttonIcon, + transitionSpec = { + fadeIn(tween(durationMillis = 220)) togetherWith + fadeOut(tween(durationMillis = 220)) + }, + label = "button text with icon", + ) { iconState -> + Row(horizontalArrangement = Arrangement.Center) { + when (iconState) { + is TangemButtonIconPosition.Start -> { + icon(iconState.iconResId) + Spacer(modifier = Modifier.requiredWidth(iconPadding)) + text() + } + is TangemButtonIconPosition.End -> { + text() + Spacer(modifier = Modifier.requiredWidth(iconPadding)) + icon(iconState.iconResId) + } + is TangemButtonIconPosition.None -> { + text() + } + } + } } - text() - if (buttonIcon is TangemButtonIconPosition.End) { - Spacer(modifier = Modifier.requiredWidth(iconPadding)) - icon(buttonIcon.iconResId) + } else { + Row(horizontalArrangement = Arrangement.Center) { + if (buttonIcon is TangemButtonIconPosition.Start) { + icon(buttonIcon.iconResId) + Spacer(modifier = Modifier.requiredWidth(iconPadding)) + } + text() + if (buttonIcon is TangemButtonIconPosition.End) { + Spacer(modifier = Modifier.requiredWidth(iconPadding)) + icon(buttonIcon.iconResId) + } } } additionalText() diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt index 85e49532fc..f19975ab9e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt @@ -38,7 +38,7 @@ sealed class CurrencyIconState { * Represents a token icon. * * @property url The URL where the token icon can be fetched from. May be `null` if not found. - * @property topBadgeIconResId The drawable resource ID for the network badge. + * @property topBadgeIconResId The drawable resource ID for the network badge. May be `null`. * @property isGrayscale Specifies whether to show the icon in grayscale. * @property showCustomBadge Specifies whether to show the custom token badge. * @property fallbackTint The color to be used for tinting the fallback icon. @@ -46,7 +46,7 @@ sealed class CurrencyIconState { */ data class TokenIcon( val url: String?, - @DrawableRes override val topBadgeIconResId: Int, + @DrawableRes override val topBadgeIconResId: Int?, override val isGrayscale: Boolean, override val showCustomBadge: Boolean, val fallbackTint: Color, @@ -81,4 +81,28 @@ sealed class CurrencyIconState { override val showCustomBadge: Boolean = false override val topBadgeIconResId: Int? = null } + + fun copySealed( + isGrayscale: Boolean = this.isGrayscale, + showCustomBadge: Boolean = this.showCustomBadge, + topBadgeIconResId: Int? = this.topBadgeIconResId, + ): CurrencyIconState = when (this) { + is CoinIcon -> copy( + isGrayscale = isGrayscale, + showCustomBadge = showCustomBadge, + ) + is CustomTokenIcon -> copy( + isGrayscale = isGrayscale, + showCustomBadge = showCustomBadge, + topBadgeIconResId = topBadgeIconResId ?: this.topBadgeIconResId, + ) + is TokenIcon -> copy( + isGrayscale = isGrayscale, + showCustomBadge = showCustomBadge, + topBadgeIconResId = topBadgeIconResId, + ) + is Loading, + is Locked, + -> this + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt index 3de373a0e3..9863c30ba0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.SoftwareKeyboardController @@ -69,6 +70,7 @@ fun SearchBar(state: SearchBarUM, modifier: Modifier = Modifier, colors: TextFie textStyle = TangemTheme.typography.body2.copy( color = TangemTheme.colors.text.primary1, ), + cursorBrush = SolidColor(TangemTheme.colors.icon.primary1), decorationBox = @Composable { innerTextField -> DecorationBox( state = state, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt index 0a17c85b13..ef0308abd3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt @@ -6,9 +6,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -86,7 +86,7 @@ fun InputRowBestRate( .padding(vertical = TangemTheme.dimens.spacing10) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false), + indication = ripple(bounded = false), onClick = onIconClick, ), ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowDefault.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowDefault.kt index 1eff068d9f..cf5d0d6153 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowDefault.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowDefault.kt @@ -8,9 +8,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically @@ -43,9 +43,9 @@ import com.tangem.core.ui.res.TangemThemePreview */ @Composable fun InputRowDefault( - title: TextReference, text: TextReference, modifier: Modifier = Modifier, + title: TextReference? = null, titleColor: Color = TangemTheme.colors.text.secondary, textColor: Color = TangemTheme.colors.text.primary1, iconRes: Int? = null, @@ -66,16 +66,18 @@ fun InputRowDefault( modifier = Modifier .weight(1f), ) { - Text( - text = title.resolveReference(), - style = TangemTheme.typography.subtitle2, - color = titleColor, - ) + title?.let { + Text( + text = title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = titleColor, + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing8), + ) + } Text( text = text.resolveReference(), style = TangemTheme.typography.body2, color = textColor, - modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), ) } iconRes?.let { @@ -92,7 +94,7 @@ fun InputRowDefault( .clickable( enabled = onIconClick != null, interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false), + indication = ripple(bounded = false), ) { onIconClick?.invoke() }, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt index 6a1b07ab53..6b8f360c17 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt @@ -6,9 +6,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -98,7 +98,7 @@ fun InputRowEnter( ) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false), + indication = ripple(bounded = false), ) { onIconClick?.invoke() }, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt index a4759a11e2..1e15b0b4dd 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt @@ -8,9 +8,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -104,7 +104,7 @@ fun InputRowEnterAmount( ) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false), + indication = ripple(bounded = false), ) { onIconClick?.invoke() }, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImage.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImage.kt index d816d77bf6..90abca3970 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImage.kt @@ -5,9 +5,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically @@ -111,7 +111,7 @@ fun InputRowImage( .align(CenterVertically) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false), + indication = ripple(bounded = false), ) { onIconClick?.invoke() }, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageBase.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageBase.kt index 0442dc06ce..0991dcdf5a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageBase.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageBase.kt @@ -1,7 +1,9 @@ package com.tangem.core.ui.components.inputrow import android.content.res.Configuration +import androidx.annotation.DrawableRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -42,11 +44,13 @@ import com.tangem.core.ui.res.TangemThemePreview @Composable internal fun InputRowImageBase( subtitle: TextReference, - imageUrl: String, modifier: Modifier = Modifier, caption: TextReference? = null, + imageUrl: String? = null, + @DrawableRes iconRes: Int? = null, subtitleColor: Color = TangemTheme.colors.text.primary1, captionColor: Color = TangemTheme.colors.text.tertiary, + iconTint: Color = TangemTheme.colors.icon.informative, isGrayscaleImage: Boolean = false, iconEndRes: Int? = null, extraContent: (@Composable RowScope.() -> Unit)? = null, @@ -55,14 +59,33 @@ internal fun InputRowImageBase( verticalAlignment = Alignment.CenterVertically, modifier = modifier, ) { - InputRowAsyncImage( - imageUrl = imageUrl, - isGrayscale = isGrayscaleImage, - modifier = Modifier - .size(TangemTheme.dimens.spacing36) - .clip(TangemTheme.shapes.roundedCornersXLarge), - ) - SpacerW12() + if (imageUrl != null) { + InputRowAsyncImage( + imageUrl = imageUrl, + isGrayscale = isGrayscaleImage, + modifier = Modifier + .size(TangemTheme.dimens.spacing36) + .clip(TangemTheme.shapes.roundedCornersXLarge), + ) + SpacerW12() + } + if (iconRes != null) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(TangemTheme.dimens.spacing36) + .clip(TangemTheme.shapes.roundedCornersXLarge) + .background(iconTint.copy(alpha = 0.08f)), + ) { + Icon( + painter = rememberVectorPainter(image = ImageVector.vectorResource(id = iconRes)), + tint = iconTint, + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size18), + ) + } + SpacerW12() + } Column { Text( text = subtitle.resolveReference(), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageInfo.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageInfo.kt index 967547e085..19bb005a00 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageInfo.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageInfo.kt @@ -1,6 +1,7 @@ package com.tangem.core.ui.components.inputrow import android.content.res.Configuration +import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -40,15 +41,17 @@ import com.tangem.core.ui.res.TangemThemePreview fun InputRowImageInfo( subtitle: TextReference, infoTitle: TextReference, - imageUrl: String, modifier: Modifier = Modifier, title: TextReference? = null, caption: TextReference? = null, infoSubtitle: TextReference? = null, + imageUrl: String? = null, + @DrawableRes iconRes: Int? = null, subtitleColor: Color = TangemTheme.colors.text.primary1, captionColor: Color = TangemTheme.colors.text.tertiary, + iconTint: Color = TangemTheme.colors.icon.informative, isGrayscaleImage: Boolean = false, - iconEndRes: Int? = null, + @DrawableRes iconEndRes: Int? = null, ) { Column( verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6), @@ -66,6 +69,8 @@ fun InputRowImageInfo( subtitle = subtitle, caption = caption, imageUrl = imageUrl, + iconRes = iconRes, + iconTint = iconTint, subtitleColor = subtitleColor, captionColor = captionColor, isGrayscaleImage = isGrayscaleImage, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageSelector.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageSelector.kt index e1aa081b50..6fcc6ef997 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageSelector.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageSelector.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -64,7 +64,7 @@ fun InputRowImageSelector( .clickable( onClick = onSelect, interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(), + indication = ripple(), ) .padding(TangemTheme.dimens.spacing12), ) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt index af8a6761b3..79c75c96e6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt @@ -9,9 +9,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -65,7 +65,7 @@ fun PasteButton(isPasteButtonVisible: Boolean, onClick: (String) -> Unit, modifi ) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(radius = TangemTheme.dimens.radius8), + indication = ripple(radius = TangemTheme.dimens.radius8), enabled = isPasteEnabled, onClick = { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) @@ -91,7 +91,7 @@ fun CrossIcon(onClick: (String) -> Unit, modifier: Modifier = Modifier) { .size(TangemTheme.dimens.size24) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(radius = TangemTheme.dimens.radius12), + indication = ripple(radius = TangemTheme.dimens.radius12), onClick = { onClick("") }, ), ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/list/InfiniteListHandler.kt b/core/ui/src/main/java/com/tangem/core/ui/components/list/InfiniteListHandler.kt new file mode 100644 index 0000000000..9d5c548b93 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/list/InfiniteListHandler.kt @@ -0,0 +1,26 @@ +package com.tangem.core.ui.components.list + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.* + +@Composable +fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Boolean, buffer: Int = 2) { + val loadMore by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItemsNumber = layoutInfo.totalItemsCount + val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + + lastVisibleItemIndex > totalItemsNumber - buffer + } + } + + val totalItemsCount by remember { derivedStateOf { listState.layoutInfo.totalItemsCount } } + var emitted by remember(totalItemsCount) { mutableStateOf(false) } + + LaunchedEffect(loadMore) { + if (loadMore && !emitted) { + emitted = onLoadMore() + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/list/RoundedListWithDividers.kt b/core/ui/src/main/java/com/tangem/core/ui/components/list/RoundedListWithDividers.kt index 336f35d454..afa890cb01 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/list/RoundedListWithDividers.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/list/RoundedListWithDividers.kt @@ -2,17 +2,23 @@ package com.tangem.core.ui.components.list import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R import com.tangem.core.ui.components.rows.CornersToRound import com.tangem.core.ui.components.rows.RoundableCornersRow 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.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -42,6 +48,7 @@ fun LazyListScope.roundedListWithDividersItems( rows: ImmutableList, headerContent: (@Composable () -> Unit)? = null, footerContent: (@Composable () -> Unit)? = null, + hideEndText: Boolean = false, ) { if (headerContent != null) { item(key = ROUNDED_LIST_WITH_DIVIDERS_HEADER_KEY) { @@ -55,13 +62,16 @@ fun LazyListScope.roundedListWithDividersItems( ) { index, row -> InitialInfoContentRow( startText = row.startText.resolveReference(), - endText = row.endText.resolveReference(), + endText = row.endText.orMaskWithStars(hideEndText && row.isEndTextHideable).resolveReference(), cornersToRound = getCornersToRound(index, rows.size), iconClick = row.iconClick, + endTextColor = if (row.isEndTextHighlighted) { + TangemTheme.colors.text.accent + } else { + TangemTheme.colors.text.tertiary + }, + showDivider = index < rows.lastIndex, ) - if (index < rows.lastIndex) { - RoundedListDivider() - } } if (footerContent != null) { @@ -76,41 +86,39 @@ private fun InitialInfoContentRow( startText: String, endText: String, cornersToRound: CornersToRound, + showDivider: Boolean, + endTextColor: Color = TangemTheme.colors.text.tertiary, iconClick: (() -> Unit)? = null, ) { - RoundableCornersRow( - startText = startText, - startTextColor = TangemTheme.colors.text.primary1, - startTextStyle = TangemTheme.typography.body2, - endText = endText, - endTextColor = TangemTheme.colors.text.tertiary, - endTextStyle = TangemTheme.typography.body2, - cornersToRound = cornersToRound, - iconResId = R.drawable.ic_information_24, - iconClick = iconClick, - ) + Box { + RoundableCornersRow( + startText = startText, + startTextColor = TangemTheme.colors.text.primary1, + startTextStyle = TangemTheme.typography.body2, + endText = endText, + endTextColor = endTextColor, + endTextStyle = TangemTheme.typography.body2, + cornersToRound = cornersToRound, + iconResId = R.drawable.ic_information_24, + iconClick = iconClick, + ) + if (showDivider) { + RoundedListDivider( + modifier = Modifier.align(Alignment.BottomEnd), + ) + } + } } @Composable -fun RoundedListDivider() { - Row( - modifier = Modifier +fun RoundedListDivider(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .padding(start = TangemTheme.dimens.spacing16) .fillMaxWidth() - .height(TangemTheme.dimens.size0_5), - ) { - Box( - modifier = Modifier - .width(TangemTheme.dimens.size16) - .height(TangemTheme.dimens.size0_5) - .background(TangemTheme.colors.background.primary), - ) - Box( - modifier = Modifier - .weight(1f) - .height(TangemTheme.dimens.size0_5) - .background(TangemTheme.colors.background.tertiary), - ) - } + .height(TangemTheme.dimens.size0_5) + .background(TangemTheme.colors.stroke.primary), + ) } private fun getCornersToRound(currentIndex: Int, listSize: Int): CornersToRound { @@ -125,7 +133,9 @@ data class RoundedListWithDividersItemData( val id: Int, val startText: TextReference, val endText: TextReference, + val isEndTextHighlighted: Boolean = false, val iconClick: (() -> Unit)? = null, + val isEndTextHideable: Boolean = false, ) @Composable diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeType.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeType.kt index de78fc1368..26aec82eab 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeType.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeType.kt @@ -1,6 +1,21 @@ package com.tangem.core.ui.components.marketprice +import java.math.BigDecimal +import java.math.RoundingMode + /** Price changing type */ enum class PriceChangeType { UP, DOWN, NEUTRAL, + ; + + companion object { + @Suppress("MagicNumber") + fun fromBigDecimal(priceChangePercent: BigDecimal): PriceChangeType { + return when { + priceChangePercent < BigDecimal.ZERO -> DOWN + priceChangePercent.setScale(4, RoundingMode.HALF_UP) > BigDecimal.ZERO -> UP + else -> NEUTRAL + } + } + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt index f25c4014cf..7a77b4cb67 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt @@ -30,6 +30,7 @@ import com.tangem.core.ui.components.* import com.tangem.core.ui.components.buttons.common.TangemButtonSize 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.core.ui.components.notifications.NotificationConfig.ButtonsState as NotificationButtonsState @@ -51,6 +52,7 @@ import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsSta fun Notification( config: NotificationConfig, modifier: Modifier = Modifier, + subtitleColor: Color = TangemTheme.colors.text.tertiary, containerColor: Color? = null, iconTint: Color? = null, isEnabled: Boolean = true, @@ -68,6 +70,7 @@ fun Notification( iconTint = iconTint, title = config.title, subtitle = config.subtitle, + subtitleColor = subtitleColor, isClickableComponent = isEnabled && config.onClick != null, ) } @@ -94,7 +97,7 @@ internal fun NotificationBaseContainer( Surface( onClick = onClick ?: {}, modifier = modifier - .defaultMinSize(minHeight = TangemTheme.dimens.size62) + .defaultMinSize(minHeight = TangemTheme.dimens.size44) .fillMaxWidth(), enabled = onClick != null && isEnabled, shape = TangemTheme.shapes.roundedCornersXMedium, @@ -119,15 +122,17 @@ internal fun NotificationBaseContainer( } } +@Suppress("LongParameterList") @Composable private fun MainContent( iconResId: Int, iconTint: Color?, - title: TextReference, + title: TextReference?, subtitle: TextReference, + subtitleColor: Color, isClickableComponent: Boolean, ) { - Row { + Row(verticalAlignment = Alignment.CenterVertically) { Icon( iconResId = iconResId, tint = iconTint, @@ -138,7 +143,7 @@ private fun MainContent( SpacerW(width = TangemTheme.dimens.spacing10) - TextsBlock(title = title, subtitle = subtitle) + TextsBlock(title = title, subtitle = subtitle, subtitleColor = subtitleColor) if (isClickableComponent) { SpacerWMax() @@ -174,17 +179,23 @@ private fun Icon(@DrawableRes iconResId: Int, tint: Color?, modifier: Modifier = } @Composable -private fun TextsBlock(title: TextReference, subtitle: TextReference) { - Column(verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing2)) { - Text( - text = title.resolveReference(), - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.button, - ) +private fun TextsBlock(title: TextReference?, subtitle: TextReference, subtitleColor: Color) { + Column { + val titleText = title?.resolveReference() + + if (titleText != null) { + Text( + text = titleText, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.button, + ) + + SpacerH(height = TangemTheme.dimens.spacing2) + } Text( text = subtitle.resolveReference(), - color = TangemTheme.colors.text.tertiary, + color = subtitleColor, style = TangemTheme.typography.caption2, ) } @@ -363,5 +374,9 @@ private class NotificationConfigProvider : CollectionPreviewParameterProvider Unit)? = null, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationWithBackground.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationWithBackground.kt index 34ae262e6d..a6031f054b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationWithBackground.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationWithBackground.kt @@ -7,9 +7,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ripple +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -33,8 +33,8 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.LocalIsInDarkTheme import com.tangem.core.ui.res.TangemColorPalette.Dark6 import com.tangem.core.ui.res.TangemColorPalette.Light4 -import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview /** * Custom notification with image background @@ -85,17 +85,22 @@ fun NotificationWithBackground(config: NotificationConfig, modifier: Modifier = linkTo(titleRef.top, subtitleRef.bottom, bias = 0.1f) }, ) - Text( - text = config.title.resolveReference(), - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.constantWhite, - modifier = Modifier.constrainAs(titleRef) { - top.linkTo(parent.top, spacing12) - start.linkTo(iconRef.end, spacing12) - end.linkTo(closeIconRef.start, spacing2) - width = Dimension.fillToConstraints - }, - ) + + val titleText = config.title?.resolveReference() + if (titleText != null) { + Text( + text = titleText, + style = TangemTheme.typography.button, + color = TangemTheme.colors.text.constantWhite, + modifier = Modifier.constrainAs(titleRef) { + top.linkTo(parent.top, spacing12) + start.linkTo(iconRef.end, spacing12) + end.linkTo(closeIconRef.start, spacing2) + width = Dimension.fillToConstraints + }, + ) + } + Text( text = config.subtitle.resolveReference(), style = TangemTheme.typography.caption2, @@ -120,7 +125,7 @@ fun NotificationWithBackground(config: NotificationConfig, modifier: Modifier = } .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false), + indication = ripple(bounded = false), ) { config.onCloseClick?.invoke() }, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/OkxPromoNotification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/OkxPromoNotification.kt index eacaa9567e..2888045227 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/OkxPromoNotification.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/OkxPromoNotification.kt @@ -5,9 +5,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -63,11 +63,16 @@ private fun Content(config: NotificationConfig) { .weight(1f) .padding(TangemTheme.dimens.spacing12), ) { - Text( - text = config.title.resolveReference(), - style = TangemTheme.typography.button, - color = OxkPromoColor, - ) + val titleText = config.title?.resolveReference() + + if (titleText != null) { + Text( + text = titleText, + style = TangemTheme.typography.button, + color = OxkPromoColor, + ) + } + Text( text = config.subtitle.resolveReference(), style = TangemTheme.typography.caption2, @@ -90,7 +95,7 @@ private fun Content(config: NotificationConfig) { .size(TangemTheme.dimens.size20) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(radius = TangemTheme.dimens.radius10), + indication = ripple(radius = TangemTheme.dimens.radius10), onClick = it, ), ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/progressbar/LinearProgressIndicator.kt b/core/ui/src/main/java/com/tangem/core/ui/components/progressbar/LinearProgressIndicator.kt new file mode 100644 index 0000000000..41d6020243 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/progressbar/LinearProgressIndicator.kt @@ -0,0 +1,126 @@ +@file:Suppress("UnnecessaryParentheses") + +package com.tangem.core.ui.components.progressbar + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.layout +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset +import com.tangem.core.ui.res.TangemTheme +import kotlin.math.abs + +/** + * Progress indicators express an unspecified wait time or display the length of a process. + * + * By default there is no animation between [progress] values. You can use + * + * @param progress The progress of this progress indicator, where 0.0 represents no progress and 1.0 + * represents full progress. Values outside of this range are coerced into the range. + * @param modifier the [Modifier] to be applied to this progress indicator + * @param color The color of the progress indicator. + * @param backgroundColor The color of the background behind the indicator, visible when the + * progress has not reached that area of the overall indicator yet. + * @param strokeCap stroke cap to use for the ends of this progress indicator + */ +@Composable +fun LinearProgressIndicator( + progress: () -> Float, + modifier: Modifier = Modifier, + color: Color = TangemTheme.colors.text.accent, + backgroundColor: Color = TangemTheme.colors.background.tertiary, + strokeCap: StrokeCap = StrokeCap.Round, +) { + val coercedProgress = { progress().coerceIn(0f, 1f) } + Canvas( + modifier + .increaseSemanticsBounds() + .semantics(mergeDescendants = true) { + progressBarRangeInfo = ProgressBarRangeInfo(coercedProgress(), 0f..1f) + } + .size(height = 4.dp, width = 24.dp), + ) { + val strokeWidth = size.height + drawLinearIndicatorBackground(backgroundColor, strokeWidth, strokeCap) + drawLinearIndicator(0f, coercedProgress(), color, strokeWidth, strokeCap) + } +} + +internal fun Modifier.increaseSemanticsBounds(): Modifier { + val padding = 10.dp + return this + .layout { measurable, constraints -> + val paddingPx = padding.roundToPx() + // We need to add vertical padding to the semantics bounds in other to meet + // screenreader green box minimum size, but we also want to + // preserve a visual appearance and layout size below that minimum + // in order to maintain backwards compatibility. This custom + // layout effectively implements "negative padding". + val newConstraint = constraints.offset(0, paddingPx * 2) + val placeable = measurable.measure(newConstraint) + + // But when actually placing the placeable, create the layout without additional + // space. Place the placeable where it would've been without any extra padding. + val height = placeable.height - paddingPx * 2 + val width = placeable.width + layout(width, height) { + placeable.place(0, -paddingPx) + } + } + .semantics(mergeDescendants = true) {} + .padding(vertical = padding) +} + +private fun DrawScope.drawLinearIndicator( + startFraction: Float, + endFraction: Float, + color: Color, + strokeWidth: Float, + strokeCap: StrokeCap, +) { + val width = size.width + val height = size.height + // Start drawing from the vertical center of the stroke + val yOffset = height / 2 + + val isLtr = layoutDirection == LayoutDirection.Ltr + val barStart = (if (isLtr) startFraction else 1f - endFraction) * width + val barEnd = (if (isLtr) endFraction else 1f - startFraction) * width + + // if there isn't enough space to draw the stroke caps, fall back to StrokeCap.Butt + if (strokeCap == StrokeCap.Butt || height > width) { + // Progress line + drawLine(color, Offset(barStart, yOffset), Offset(barEnd, yOffset), strokeWidth) + } else { + // need to adjust barStart and barEnd for the stroke caps + val strokeCapOffset = strokeWidth / 2 + val coerceRange = strokeCapOffset..(width - strokeCapOffset) + val adjustedBarStart = barStart.coerceIn(coerceRange) + val adjustedBarEnd = barEnd.coerceIn(coerceRange) + + if (abs(endFraction - startFraction) > 0) { + // Progress line + drawLine( + color, + Offset(adjustedBarStart, yOffset), + Offset(adjustedBarEnd, yOffset), + strokeWidth, + strokeCap, + ) + } + } +} + +private fun DrawScope.drawLinearIndicatorBackground(color: Color, strokeWidth: Float, strokeCap: StrokeCap) = + drawLinearIndicator(0f, 1f, color, strokeWidth, strokeCap) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/ArrowRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/ArrowRow.kt index 0e594fbd91..67ef1ac47d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/rows/ArrowRow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/ArrowRow.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.* @@ -68,6 +69,7 @@ private class ChildArrowScope( @Composable fun ChildArrow(childHeight: Dp, isLastChild: Boolean) { + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val figureWidth = TangemTheme.dimens.size40 val strokeColor = TangemTheme.colors.stroke.secondary @@ -86,18 +88,31 @@ fun ChildArrow(childHeight: Dp, isLastChild: Boolean) { ) val arrowHeadRectDp = DpRect( origin = DpOffset( - x = figureWidth - arrowHeadSize.width, + x = if (isLtr) { + figureWidth - arrowHeadSize.width + } else { + 0.dp + }, y = figureRectDp.size.center.y - arrowHeadSize.center.y, ), size = arrowHeadSize, ) - val curvedArrowRectDp = DpRect( - top = figureRectDp.top, - left = TangemTheme.dimens.size18, - right = figureRectDp.right - arrowHeadRectDp.width, - bottom = figureRectDp.size.center.y, - ) + val curvedArrowRectDp = if (isLtr) { + DpRect( + top = figureRectDp.top, + left = TangemTheme.dimens.size18, + right = figureRectDp.right - arrowHeadRectDp.width, + bottom = figureRectDp.size.center.y, + ) + } else { + DpRect( + top = figureRectDp.top, + left = arrowHeadRectDp.width, + right = TangemTheme.dimens.size18 + arrowHeadRectDp.width, + bottom = figureRectDp.size.center.y, + ) + } Canvas( modifier = Modifier @@ -114,20 +129,26 @@ fun ChildArrow(childHeight: Dp, isLastChild: Boolean) { drawScope = this, ) - scope.drawCurveArrow() - scope.drawArrowHead() + scope.drawCurveArrow(isLtr) + scope.drawArrowHead(isLtr) if (!isLastChild) { - scope.drawArrowLine() + scope.drawArrowLine(isLtr) } } } -private fun ChildArrowScope.drawArrowHead() { +private fun ChildArrowScope.drawArrowHead(isLtr: Boolean) { val arrowHeadPath = Path().apply { - moveTo(arrowHeadRect.centerRight) - lineTo(arrowHeadRect.topLeft) - lineTo(arrowHeadRect.bottomLeft) + if (isLtr) { + moveTo(arrowHeadRect.centerRight) + lineTo(arrowHeadRect.topLeft) + lineTo(arrowHeadRect.bottomLeft) + } else { + moveTo(arrowHeadRect.centerLeft) + lineTo(arrowHeadRect.topRight) + lineTo(arrowHeadRect.bottomRight) + } close() } val paint = Paint().apply { @@ -143,13 +164,21 @@ private fun ChildArrowScope.drawArrowHead() { } } -private fun ChildArrowScope.drawCurveArrow() { +private fun ChildArrowScope.drawCurveArrow(isLtr: Boolean) { val curveArrowPath = Path().apply { - moveTo(curvedArrowRect.topLeft) - quadraticBezierTo( - control = curvedArrowRect.bottomLeft, - end = curvedArrowRect.bottomRight, - ) + if (isLtr) { + moveTo(curvedArrowRect.topLeft) + quadraticBezierTo( + control = curvedArrowRect.bottomLeft, + end = curvedArrowRect.bottomRight, + ) + } else { + moveTo(curvedArrowRect.topRight) + quadraticBezierTo( + control = curvedArrowRect.bottomRight, + end = curvedArrowRect.bottomLeft, + ) + } } drawPath( path = curveArrowPath, @@ -158,11 +187,20 @@ private fun ChildArrowScope.drawCurveArrow() { ) } -private fun ChildArrowScope.drawArrowLine() { - drawLine( - color = strokeColor, - start = curvedArrowRect.topLeft, - end = Offset(curvedArrowRect.left, figureRect.bottom), - strokeWidth = arrowStrokeWidth, - ) +private fun ChildArrowScope.drawArrowLine(isLtr: Boolean) { + if (isLtr) { + drawLine( + color = strokeColor, + start = curvedArrowRect.topLeft, + end = Offset(curvedArrowRect.left, figureRect.bottom), + strokeWidth = arrowStrokeWidth, + ) + } else { + drawLine( + color = strokeColor, + start = curvedArrowRect.topRight, + end = Offset(curvedArrowRect.right, figureRect.bottom), + strokeWidth = arrowStrokeWidth, + ) + } } \ No newline at end of file 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 4f8edf75c4..df2cdf37a9 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 @@ -10,6 +10,7 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -20,6 +21,8 @@ import com.tangem.core.ui.components.rows.model.BlockchainRowUM import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +private const val DISABLED_ICON_ALPHA = 0.4f + /** * [Figma Component](https://www.figma.com/design/14ISV23YB1yVW1uNVwqrKv/Android?node-id=2737-2800&t=ewlXfWwbDnRhjw4B-4) * */ @@ -29,14 +32,16 @@ fun BlockchainRow(model: BlockchainRowUM, action: @Composable BoxScope.() -> Uni modifier = modifier .heightIn(min = TangemTheme.dimens.size52) .padding( - vertical = TangemTheme.dimens.spacing8, - horizontal = TangemTheme.dimens.spacing8, + top = TangemTheme.dimens.spacing8, + bottom = TangemTheme.dimens.spacing8, + start = TangemTheme.dimens.spacing8, ), icon = { RowIcon( resId = model.iconResId, - isColored = model.isSelected, + isColored = model.isSelected && model.isEnabled, showAccentBadge = model.isMainNetwork, + isEnabled = model.isEnabled, ) }, text = { @@ -45,6 +50,7 @@ fun BlockchainRow(model: BlockchainRowUM, action: @Composable BoxScope.() -> Uni secondText = model.type, accentMainText = model.isSelected, accentSecondText = model.isMainNetwork, + isEnabled = model.isEnabled, ) }, action = action, @@ -57,10 +63,9 @@ private fun RowIcon( isColored: Boolean, showAccentBadge: Boolean, modifier: Modifier = Modifier, + isEnabled: Boolean = true, ) { - Box( - modifier = modifier.size(TangemTheme.dimens.size24), - ) { + Box(modifier = modifier.size(TangemTheme.dimens.size24)) { if (isColored) { Image( modifier = Modifier @@ -77,6 +82,7 @@ private fun RowIcon( color = TangemTheme.colors.button.secondary, shape = CircleShape, ) + .alpha(if (isEnabled) 1f else DISABLED_ICON_ALPHA) .size(TangemTheme.dimens.size22), painter = painterResource(id = resId), tint = TangemTheme.colors.icon.informative, @@ -125,7 +131,7 @@ private fun Preview_BlockchainRow(@PreviewParameter(BlockchainRowParameterProvid BlockchainRow( model = state, action = { - TangemSwitch(onCheckedChange = { /* [REDACTED_TODO_COMMENT]*/ }, checked = true) + TangemSwitch(onCheckedChange = { }, checked = true, enabled = state.isEnabled) }, ) }, @@ -136,6 +142,7 @@ private fun Preview_BlockchainRow(@PreviewParameter(BlockchainRowParameterProvid private class BlockchainRowParameterProvider : CollectionPreviewParameterProvider( collection = listOf( BlockchainRowUM( + id = "0", name = "BNB BEACON CHAIN", type = "BEP20", iconResId = R.drawable.img_bsc_22, @@ -143,6 +150,7 @@ private class BlockchainRowParameterProvider : CollectionPreviewParameterProvide isSelected = true, ), BlockchainRowUM( + id = "1", name = "1234567890111213141516171819", type = "BEP20", iconResId = R.drawable.ic_bsc_16, @@ -150,12 +158,22 @@ private class BlockchainRowParameterProvider : CollectionPreviewParameterProvide isSelected = false, ), BlockchainRowUM( + id = "2", name = "BNB BEACON CHAIN", type = "1234567890111213141516171819", iconResId = R.drawable.ic_bsc_16, isMainNetwork = false, isSelected = false, ), + BlockchainRowUM( + id = "2", + name = "BNB BEACON CHAIN", + type = "1234567890111213141516171819", + iconResId = R.drawable.ic_bsc_16, + isMainNetwork = false, + isSelected = false, + isEnabled = false, + ), ), ) // endregion Preview \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/ChainRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/ChainRow.kt index c57b6ebc1a..7b8f2c9fff 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/rows/ChainRow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/ChainRow.kt @@ -25,15 +25,8 @@ import com.tangem.core.ui.res.TangemThemePreview * */ @Composable fun ChainRow(model: ChainRowUM, modifier: Modifier = Modifier, action: @Composable BoxScope.() -> Unit = {}) { - RowContentContainer( - modifier = modifier - .heightIn(min = TangemTheme.dimens.size68) - .padding(vertical = TangemTheme.dimens.spacing8) - .padding( - start = TangemTheme.dimens.spacing8, - end = TangemTheme.dimens.spacing12, - ), - horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ChainRowContainer( + modifier = modifier, icon = { CurrencyIcon( state = model.icon, @@ -57,6 +50,28 @@ fun ChainRow(model: ChainRowUM, modifier: Modifier = Modifier, action: @Composab ) } +@Composable +inline fun ChainRowContainer( + icon: @Composable BoxScope.() -> Unit, + text: @Composable BoxScope.() -> Unit, + action: @Composable BoxScope.() -> Unit, + modifier: Modifier = Modifier, +) { + RowContentContainer( + modifier = modifier + .heightIn(min = TangemTheme.dimens.size68) + .padding(vertical = TangemTheme.dimens.spacing8) + .padding( + start = TangemTheme.dimens.spacing8, + end = TangemTheme.dimens.spacing12, + ), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + icon = icon, + text = text, + action = action, + ) +} + // region Preview @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/NetworkTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/NetworkTitle.kt new file mode 100644 index 0000000000..383854fd65 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/NetworkTitle.kt @@ -0,0 +1,103 @@ +package com.tangem.core.ui.components.rows + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.R +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +/** + * Network title component. [title] and [action] composables are placed horizontally + * + * @param title title composable in [BoxScope] + * @param modifier modifier + * @param action optional action composable in [BoxScope] + * + * @see Figma component + * +[REDACTED_AUTHOR] + */ +@Composable +fun NetworkTitle( + title: @Composable BoxScope.() -> Unit, + modifier: Modifier = Modifier, + action: (@Composable BoxScope.() -> Unit)? = null, +) { + val minHeight = if (action == null) TangemTheme.dimens.size36 else TangemTheme.dimens.size40 + + val padding = if (action == null) { + PaddingValues( + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing4, + ) + } else { + PaddingValues( + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing11, + end = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing5, + ) + } + + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = minHeight) + .padding(paddingValues = padding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Box( + modifier = Modifier + .weight(weight = 1f) + .heightIn(min = TangemTheme.dimens.size20), + contentAlignment = Alignment.CenterStart, + content = title, + ) + + if (action != null) { + Spacer(modifier = Modifier.size(TangemTheme.dimens.spacing8)) + Box( + modifier = Modifier + .weight(weight = 1f) + .heightIn(min = TangemTheme.dimens.size24), + contentAlignment = Alignment.CenterEnd, + content = action, + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun NetworkTitlePreview(@PreviewParameter(NetworkTitleIconVisibilityProvider::class) isIconVisible: Boolean) { + TangemThemePreview { + NetworkTitle( + title = { Text(text = "Network") }, + action = { + if (isIconVisible) { + Icon( + painter = painterResource(id = R.drawable.ic_group_drop_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } + }, + ) + } +} + +private object NetworkTitleIconVisibilityProvider : CollectionPreviewParameterProvider(listOf(true, false)) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/RoundableCornersRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/RoundableCornersRow.kt index c2b1e97286..79b1cdb350 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/rows/RoundableCornersRow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/RoundableCornersRow.kt @@ -6,10 +6,10 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -20,6 +20,7 @@ import androidx.compose.ui.text.TextStyle 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.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.res.TangemTheme @@ -40,7 +41,7 @@ fun RoundableCornersRow( ) { Surface( shape = cornersToRound.getShape(), - color = TangemTheme.colors.background.primary, + color = TangemTheme.colors.background.action, ) { Row( modifier = Modifier @@ -66,7 +67,7 @@ fun RoundableCornersRow( .size(TangemTheme.dimens.size16) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = TangemTheme.dimens.radius10), + indication = ripple(bounded = false, radius = TangemTheme.dimens.radius10), onClick = iconClick, ), painter = painterResource(id = R.drawable.ic_alert_24), @@ -95,8 +96,7 @@ enum class CornersToRound { @Suppress("TopLevelComposableFunctions") @Composable - fun getShape(): RoundedCornerShape { - val radius = TangemTheme.dimens.radius12 + fun getShape(radius: Dp = TangemTheme.dimens.radius12): RoundedCornerShape { return when (this) { ALL_4 -> RoundedCornerShape(radius) TOP_2 -> RoundedCornerShape(topStart = radius, topEnd = radius) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/RowComponents.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/RowComponents.kt index f93f42b41d..78c7a724b7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/rows/RowComponents.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/RowComponents.kt @@ -12,7 +12,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme @Composable -internal inline fun RowContentContainer( +inline fun RowContentContainer( icon: @Composable BoxScope.() -> Unit, text: @Composable BoxScope.() -> Unit, action: @Composable BoxScope.() -> Unit, @@ -53,6 +53,7 @@ internal fun RowText( accentSecondText: Boolean, modifier: Modifier = Modifier, subtitle: TextReference? = null, + isEnabled: Boolean = true, ) { Column( modifier = modifier, @@ -67,16 +68,24 @@ internal fun RowText( modifier = Modifier.weight(weight = 10f, fill = false), text = mainText, style = TangemTheme.typography.subtitle2, - color = if (accentMainText) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary, + color = if (isEnabled) { + if (accentMainText) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary + } else { + TangemTheme.colors.text.disabled + }, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( - modifier = Modifier.weight(weight = 4f, fill = false), + modifier = Modifier.weight(weight = 5f, fill = false), text = secondText, style = TangemTheme.typography.body2, - color = if (accentSecondText) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary, + color = if (isEnabled) { + if (accentSecondText) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary + } else { + TangemTheme.colors.text.disabled + }, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/model/BlockchainRowUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/model/BlockchainRowUM.kt index f8e756a9ae..6cba8925ee 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/rows/model/BlockchainRowUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/model/BlockchainRowUM.kt @@ -4,9 +4,11 @@ import androidx.compose.runtime.Immutable @Immutable data class BlockchainRowUM( + val id: String, val name: String, val type: String, val iconResId: Int, val isMainNetwork: Boolean, val isSelected: Boolean, + val isEnabled: Boolean = true, ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/DraggableAnchorsUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/DraggableAnchorsUtils.kt new file mode 100644 index 0000000000..f40ba67a78 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/DraggableAnchorsUtils.kt @@ -0,0 +1,122 @@ +@file:Suppress("all") + +package com.tangem.core.ui.components.sheetscaffold + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntSize +import kotlin.math.roundToInt + +/** + * This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout + * node's size and offsetting it. It considers lookahead and reports the appropriate size and + * measurement for the appropriate phase. + * + * @param state The state the anchors should be attached to + * @param orientation The orientation the component should be offset in + * @param anchors Lambda to calculate the anchors based on this layout's size and the incoming + * constraints. These can be useful to avoid subcomposition. + */ +@OptIn(ExperimentalFoundationApi::class) +internal fun Modifier.draggableAnchors( + state: AnchoredDraggableState, + orientation: Orientation, + anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, +) = this then DraggableAnchorsElement(state, anchors, orientation) + +@OptIn(ExperimentalFoundationApi::class) +private class DraggableAnchorsElement( + private val state: AnchoredDraggableState, + private val anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, + private val orientation: Orientation, +) : ModifierNodeElement>() { + + @OptIn(ExperimentalFoundationApi::class) + override fun create() = DraggableAnchorsNode(state, anchors, orientation) + + override fun update(node: DraggableAnchorsNode) { + node.state = state + node.anchors = anchors + node.orientation = orientation + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + + if (other !is DraggableAnchorsElement<*>) return false + + if (state != other.state) return false + if (anchors !== other.anchors) return false + if (orientation != other.orientation) return false + + return true + } + + override fun hashCode(): Int { + var result = state.hashCode() + result = 31 * result + anchors.hashCode() + result = 31 * result + orientation.hashCode() + return result + } + + override fun InspectorInfo.inspectableProperties() { + debugInspectorInfo { + properties["state"] = state + properties["anchors"] = anchors + properties["orientation"] = orientation + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +private class DraggableAnchorsNode( + var state: AnchoredDraggableState, + var anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, + var orientation: Orientation, +) : Modifier.Node(), LayoutModifierNode { + private var didLookahead: Boolean = false + + override fun onDetach() { + didLookahead = false + } + + @OptIn(ExperimentalFoundationApi::class) + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { + val placeable = measurable.measure(constraints) + // If we are in a lookahead pass, we only want to update the anchors here and not in + // post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead), + // update the anchors in the main pass. + if (!isLookingAhead || !didLookahead) { + val size = IntSize(placeable.width, placeable.height) + val newAnchorResult = anchors(size, constraints) + state.updateAnchors(newAnchorResult.first, newAnchorResult.second) + } + didLookahead = isLookingAhead || didLookahead + return layout(placeable.width, placeable.height) { + // In a lookahead pass, we use the position of the current target as this is where any + // ongoing animations would move. If the component is in a settled state, lookahead + // and post-lookahead will converge. + val offset = if (isLookingAhead) { + state.anchors.positionOf(state.targetValue) + } else { + state.requireOffset() + } + + val xOffset = if (orientation == Orientation.Horizontal) offset else 0f + val yOffset = if (orientation == Orientation.Vertical) offset else 0f + + placeable.place(xOffset.roundToInt(), yOffset.roundToInt()) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt new file mode 100644 index 0000000000..aa68f71190 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt @@ -0,0 +1,334 @@ +@file:Suppress("all") + +package com.tangem.core.ui.components.sheetscaffold + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastMap +import androidx.compose.ui.util.fastMaxOfOrNull +import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue.* +import com.tangem.core.ui.res.TangemTheme +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** + * Material Design standard bottom sheet scaffold. + * + * Standard bottom sheets co-exist with the screen’s main UI region and allow for simultaneously + * viewing and interacting with both regions. They are commonly used to keep a feature or secondary + * content visible on screen when content in main UI region is frequently scrolled or panned. + * + * ![Bottom sheet + * image](https://developer.android.com/images/reference/androidx/compose/material3/bottom_sheet.png) + * + * This component provides API to put together several material components to construct your screen, + * by ensuring proper layout strategy for them and collecting necessary data so these components + * will work together correctly. + * + * @param sheetContent the content of the bottom sheet + * @param modifier the [Modifier] to be applied to this scaffold + * @param scaffoldState the state of the bottom sheet scaffold + * @param sheetPeekHeight the height of the bottom sheet when it is collapsed + * @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take. Pass in + * [Dp.Unspecified] for a sheet that spans the entire screen width. + * @param sheetShape the shape of the bottom sheet + * @param sheetContainerColor the background color of the bottom sheet + * @param sheetContentColor the preferred content color provided by the bottom sheet to its + * children. Defaults to the matching content color for [sheetContainerColor], or if that is not a + * color from the theme, this will keep the same content color set above the bottom sheet. + * @param sheetTonalElevation when [sheetContainerColor] is [ColorScheme.surface], a translucent + * primary color overlay is applied on top of the container. A higher tonal elevation value will + * result in a darker color in light theme and lighter color in dark theme. See also: [Surface]. + * @param sheetShadowElevation the shadow elevation of the bottom sheet + * @param sheetSwipeEnabled whether the sheet swiping is enabled and should react to the user's + * input + * @param topBar top app bar of the screen, typically a [SmallTopAppBar] + * @param snackbarHost component to host [Snackbar]s that are pushed to be shown via + * [SnackbarHostState.showSnackbar], typically a [SnackbarHost] + * @param containerColor the color used for the background of this scaffold. Use [Color.Transparent] + * to have no color. + * @param contentColor the preferred color for content inside this scaffold. Defaults to either the + * matching content color for [containerColor], or to the current [LocalContentColor] if + * [containerColor] is not a color from the theme. + * @param content content of the screen. The lambda receives a [PaddingValues] that should be + * applied to the content root via [Modifier.padding] and [Modifier.consumeWindowInsets] to + * properly offset top and bottom bars. If using [Modifier.verticalScroll], apply this modifier to + * the child of the scroll, and not on the scroll itself. + */ +@Composable +fun TangemBottomSheetScaffold( + sheetContent: @Composable ColumnScope.() -> Unit, + modifier: Modifier = Modifier, + scaffoldState: TangemBottomSheetScaffoldState = rememberTangemBottomSheetScaffoldState(), + sheetPeekHeight: Dp, + sheetMaxWidth: Dp = 640.dp, + sheetShape: Shape = TangemTheme.shapes.bottomSheetLarge, + sheetContainerColor: Color = Color.White, // FIXME + sheetContentColor: Color = contentColorFor(sheetContainerColor), + sheetTonalElevation: Dp = 0.dp, + sheetShadowElevation: Dp = 1.dp, + sheetSwipeEnabled: Boolean = true, + topBar: @Composable (() -> Unit)? = null, + snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, + containerColor: Color = TangemTheme.colors.background.secondary, + contentColor: Color = contentColorFor(containerColor), + content: @Composable (PaddingValues) -> Unit, +) { + BottomSheetScaffoldLayout( + modifier = modifier, + topBar = topBar, + body = { content(PaddingValues(bottom = sheetPeekHeight)) }, + snackbarHost = { snackbarHost(scaffoldState.snackbarHostState) }, + sheetOffset = { scaffoldState.bottomSheetState.requireOffset() }, + sheetState = scaffoldState.bottomSheetState, + containerColor = containerColor, + contentColor = contentColor, + bottomSheet = { + StandardBottomSheet( + state = scaffoldState.bottomSheetState, + peekHeight = sheetPeekHeight, + sheetMaxWidth = sheetMaxWidth, + sheetSwipeEnabled = sheetSwipeEnabled, + shape = sheetShape, + containerColor = sheetContainerColor, + contentColor = sheetContentColor, + tonalElevation = sheetTonalElevation, + shadowElevation = sheetShadowElevation, + content = sheetContent, + ) + }, + ) +} + +/** + * State of the [TangemBottomSheetScaffold] composable. + * + * @param bottomSheetState the state of the persistent bottom sheet + * @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold + */ +@Stable +class TangemBottomSheetScaffoldState( + val bottomSheetState: TangemSheetState, + val snackbarHostState: SnackbarHostState, +) + +/** + * Create and [remember] a [TangemBottomSheetScaffoldState]. + * + * @param bottomSheetState the state of the standard bottom sheet. See + * [rememberTangemStandardBottomSheetState] + * @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold + */ +@Composable +fun rememberTangemBottomSheetScaffoldState( + bottomSheetState: TangemSheetState = rememberTangemStandardBottomSheetState(), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, +): TangemBottomSheetScaffoldState { + return remember(bottomSheetState, snackbarHostState) { + TangemBottomSheetScaffoldState( + bottomSheetState = bottomSheetState, + snackbarHostState = snackbarHostState, + ) + } +} + +/** + * Create and [remember] a [TangemSheetState] for [TangemBottomSheetScaffold]. + * + * @param initialValue the initial value of the state. Should be either [PartiallyExpanded] or + * [Expanded] if [skipHiddenState] is true + * @param confirmValueChange optional callback invoked to confirm or veto a pending state change + * @param [skipHiddenState] whether Hidden state is skipped for [TangemBottomSheetScaffold] + */ +@Composable +fun rememberTangemStandardBottomSheetState( + initialValue: TangemSheetValue = PartiallyExpanded, + confirmValueChange: (TangemSheetValue) -> Boolean = { true }, + skipHiddenState: Boolean = true, +) = rememberSheetState( + confirmValueChange = confirmValueChange, + initialValue = initialValue, + skipHiddenState = skipHiddenState, +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun StandardBottomSheet( + state: TangemSheetState, + peekHeight: Dp, + sheetMaxWidth: Dp, + sheetSwipeEnabled: Boolean, + shape: Shape, + containerColor: Color, + contentColor: Color, + tonalElevation: Dp, + shadowElevation: Dp, + content: @Composable ColumnScope.() -> Unit, +) { + val scope = rememberCoroutineScope() + val orientation = Orientation.Vertical + + val peekHeightPx = with(LocalDensity.current) { peekHeight.toPx() } + val nestedScroll = + if (sheetSwipeEnabled) { + Modifier.nestedScroll( + remember(state.anchoredDraggableState) { + consumeSwipeWithinBottomSheetBoundsNestedScrollConnection( + sheetState = state, + orientation = orientation, + onFling = { scope.launch { state.settle(it) } }, + ) + }, + ) + } else { + Modifier + } + + Surface( + modifier = Modifier + .widthIn(max = sheetMaxWidth) + .fillMaxWidth() + .requiredHeightIn(min = peekHeight) + .then(nestedScroll) + .draggableAnchors( + state = state.anchoredDraggableState, + orientation = orientation, + anchors = { sheetSize, constraints -> + val layoutHeight = constraints.maxHeight.toFloat() + val sheetHeight = sheetSize.height.toFloat() + + val newAnchors = DraggableAnchors { + if (!state.skipPartiallyExpanded) { + PartiallyExpanded at (layoutHeight - peekHeightPx) + } + if (sheetHeight != peekHeightPx) { + Expanded at maxOf(layoutHeight - sheetHeight, 0f) + } + if (!state.skipHiddenState) { + Hidden at layoutHeight + } + } + val newTarget = + when (val oldTarget = state.anchoredDraggableState.targetValue) { + Hidden -> if (newAnchors.hasAnchorFor(Hidden)) Hidden else oldTarget + PartiallyExpanded -> + when { + newAnchors.hasAnchorFor(PartiallyExpanded) -> PartiallyExpanded + newAnchors.hasAnchorFor(Expanded) -> Expanded + newAnchors.hasAnchorFor(Hidden) -> Hidden + else -> oldTarget + } + Expanded -> + when { + newAnchors.hasAnchorFor(Expanded) -> Expanded + newAnchors.hasAnchorFor(PartiallyExpanded) -> PartiallyExpanded + newAnchors.hasAnchorFor(Hidden) -> Hidden + else -> oldTarget + } + } + newAnchors to newTarget + }, + ) + .anchoredDraggable( + state = state.anchoredDraggableState, + orientation = orientation, + enabled = sheetSwipeEnabled, + ), + shape = shape, + color = containerColor, + contentColor = contentColor, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation, + ) { + Column(Modifier.fillMaxWidth()) { + content() + } + } +} + +@Composable +private fun BottomSheetScaffoldLayout( + modifier: Modifier, + topBar: @Composable (() -> Unit)?, + body: @Composable () -> Unit, + bottomSheet: @Composable () -> Unit, + snackbarHost: @Composable () -> Unit, + sheetOffset: () -> Float, + sheetState: TangemSheetState, + containerColor: Color, + contentColor: Color, +) { + Layout( + contents = + listOf<@Composable () -> Unit>( + topBar ?: {}, + { + Surface( + modifier = modifier, + color = containerColor, + contentColor = contentColor, + content = body, + ) + }, + bottomSheet, + snackbarHost, + ), + ) { + (topBarMeasurables, bodyMeasurables, bottomSheetMeasurables, snackbarHostMeasurables), + constraints, + -> + val layoutWidth = constraints.maxWidth + val layoutHeight = constraints.maxHeight + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) + + val sheetPlaceables = bottomSheetMeasurables.fastMap { it.measure(looseConstraints) } + + val topBarPlaceables = topBarMeasurables.fastMap { it.measure(looseConstraints) } + val topBarHeight = topBarPlaceables.fastMaxOfOrNull { it.height } ?: 0 + + val bodyConstraints = looseConstraints.copy(maxHeight = layoutHeight - topBarHeight) + val bodyPlaceables = bodyMeasurables.fastMap { it.measure(bodyConstraints) } + + val snackbarPlaceables = snackbarHostMeasurables.fastMap { it.measure(looseConstraints) } + + layout(layoutWidth, layoutHeight) { + val sheetWidth = sheetPlaceables.fastMaxOfOrNull { it.width } ?: 0 + val sheetOffsetX = Integer.max(0, (layoutWidth - sheetWidth) / 2) + + val snackbarWidth = snackbarPlaceables.fastMaxOfOrNull { it.width } ?: 0 + val snackbarHeight = snackbarPlaceables.fastMaxOfOrNull { it.height } ?: 0 + val snackbarOffsetX = (layoutWidth - snackbarWidth) / 2 + val snackbarOffsetY = + when (sheetState.currentValue) { + PartiallyExpanded -> sheetOffset().roundToInt() - snackbarHeight + Expanded, + Hidden, + -> layoutHeight - snackbarHeight + } + + // Placement order is important for elevation + bodyPlaceables.fastForEach { it.placeRelative(0, topBarHeight) } + topBarPlaceables.fastForEach { it.placeRelative(0, 0) } + sheetPlaceables.fastForEach { it.placeRelative(sheetOffsetX, 0) } + snackbarPlaceables.fastForEach { it.placeRelative(snackbarOffsetX, snackbarOffsetY) } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemSheetState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemSheetState.kt new file mode 100644 index 0000000000..64dcea2dcb --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemSheetState.kt @@ -0,0 +1,339 @@ +@file:Suppress("all") + +package com.tangem.core.ui.components.sheetscaffold + +import androidx.compose.animation.core.* +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue.* +import kotlinx.coroutines.CancellationException + +/** + * State of a sheet composable + * + * Contains states relating to its swipe position as well as animations between state values. + * + * @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large enough, + * should be skipped. If true, the sheet will always expand to the [Expanded] state and move to + * the [Hidden] state if available when hiding the sheet, either programmatically or by user + * interaction. + * @param initialValue The initial value of the state. + * @param density The density that this state can use to convert values to and from dp. + * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. + * @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always + * expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either + * programmatically or by user interaction. + */ +@Stable +@OptIn(ExperimentalFoundationApi::class) +class TangemSheetState( + internal val skipPartiallyExpanded: Boolean, + density: Density, + initialValue: TangemSheetValue = Hidden, + confirmValueChange: (TangemSheetValue) -> Boolean = { true }, + internal val skipHiddenState: Boolean = false, +) { + init { + if (skipPartiallyExpanded) { + require(initialValue != PartiallyExpanded) { + "The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " + + "is set to true." + } + } + if (skipHiddenState) { + require(initialValue != Hidden) { + "The initial value must not be set to Hidden if skipHiddenState is set to true." + } + } + } + + /** + * The current value of the state. + * + * If no swipe or animation is in progress, this corresponds to the state the bottom sheet is + * currently in. If a swipe or an animation is in progress, this corresponds the state the sheet + * was in before the swipe or animation started. + */ + val currentValue: TangemSheetValue + get() = anchoredDraggableState.currentValue + + /** + * The target value of the bottom sheet state. + * + * If a swipe is in progress, this is the value that the sheet would animate to if the swipe + * finishes. If an animation is running, this is the target value of that animation. Finally, if + * no swipe or animation is in progress, this is the same as the [currentValue]. + */ + val targetValue: TangemSheetValue + get() = anchoredDraggableState.targetValue + + /** Whether the modal bottom sheet is visible. */ + val isVisible: Boolean + get() = anchoredDraggableState.currentValue != Hidden + + /** + * Require the current offset (in pixels) of the bottom sheet. + * + * The offset will be initialized during the first measurement phase of the provided sheet + * content. + * + * These are the phases: Composition { -> Effects } -> Layout { Measurement -> Placement } -> + * Drawing + * + * During the first composition, an [IllegalStateException] is thrown. In subsequent + * compositions, the offset will be derived from the anchors of the previous pass. Always prefer + * accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next + * frame, after layout. + * + * @throws IllegalStateException If the offset has not been initialized yet + */ + fun requireOffset(): Float = anchoredDraggableState.requireOffset() + + /** Whether the sheet has an expanded state defined. */ + val hasExpandedState: Boolean + get() = anchoredDraggableState.anchors.hasAnchorFor(Expanded) + + /** Whether the modal bottom sheet has a partially expanded state defined. */ + val hasPartiallyExpandedState: Boolean + get() = anchoredDraggableState.anchors.hasAnchorFor(PartiallyExpanded) + + /** + * Fully expand the bottom sheet with animation and suspend until it is fully expanded or + * animation has been cancelled. + * * + * + * @throws [CancellationException] if the animation is interrupted + */ + suspend fun expand() { + anchoredDraggableState.animateTo(Expanded) + } + + /** + * Animate the bottom sheet and suspend until it is partially expanded or animation has been + * cancelled. + * + * @throws [CancellationException] if the animation is interrupted + * @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true + */ + suspend fun partialExpand() { + check(!skipPartiallyExpanded) { + "Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" + + " skipPartiallyExpanded to false to use this function." + } + animateTo(PartiallyExpanded) + } + + /** + * Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined + * else [Expanded]. + * + * @throws [CancellationException] if the animation is interrupted + */ + suspend fun show() { + val targetValue = + when { + hasPartiallyExpandedState -> PartiallyExpanded + else -> Expanded + } + animateTo(targetValue) + } + + /** + * Hide the bottom sheet with animation and suspend until it is fully hidden or animation has + * been cancelled. + * + * @throws [CancellationException] if the animation is interrupted + */ + suspend fun hide() { + check(!skipHiddenState) { + "Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" + + " to false to use this function." + } + animateTo(Hidden) + } + + /** + * Animate to a [targetValue]. If the [targetValue] is not in the set of anchors, the + * [currentValue] will be updated to the [targetValue] without updating the offset. + * + * @param targetValue The target value of the animation + * @throws CancellationException if the interaction interrupted by another interaction like a + * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] + * call. + */ + internal suspend fun animateTo( + targetValue: TangemSheetValue, + velocity: Float = anchoredDraggableState.lastVelocity, + ) { + anchoredDraggableState.animateToWithDecay(targetValue, velocity) + } + + /** + * Snap to a [targetValue] without any animation. + * + * @param targetValue The target value of the animation + * @throws CancellationException if the interaction interrupted by another interaction like a + * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] + * call. + */ + internal suspend fun snapTo(targetValue: TangemSheetValue) { + anchoredDraggableState.snapTo(targetValue) + } + + /** + * Find the closest anchor taking into account the velocity and settle at it with an animation. + */ + internal suspend fun settle(velocity: Float) { + anchoredDraggableState.settle(velocity) + } + + internal var anchoredDraggableState = + AnchoredDraggableState( + initialValue = initialValue, + snapAnimationSpec = BottomSheetAnimationSpec, + decayAnimationSpec = exponentialDecay( + frictionMultiplier = 10f, + absVelocityThreshold = 0.5f, + ), + confirmValueChange = confirmValueChange, + positionalThreshold = { with(density) { 56.dp.toPx() } }, + velocityThreshold = { with(density) { 125.dp.toPx() } }, + ) + + internal val offset: Float + get() = anchoredDraggableState.offset + + companion object { + /** The default [Saver] implementation for [TangemSheetState]. */ + fun Saver( + skipPartiallyExpanded: Boolean, + confirmValueChange: (TangemSheetValue) -> Boolean, + density: Density, + skipHiddenState: Boolean, + ) = Saver( + save = { it.currentValue }, + restore = { savedValue -> + TangemSheetState( + skipPartiallyExpanded, + density, + savedValue, + confirmValueChange, + skipHiddenState, + ) + }, + ) + } +} + +/** Possible values of [TangemSheetState]. */ +enum class TangemSheetValue { + /** The sheet is not visible. */ + Hidden, + + /** The sheet is visible at full height. */ + Expanded, + + /** The sheet is partially visible. */ + PartiallyExpanded, +} + +@OptIn(ExperimentalFoundationApi::class) +internal fun consumeSwipeWithinBottomSheetBoundsNestedScrollConnection( + sheetState: TangemSheetState, + orientation: Orientation, + onFling: (velocity: Float) -> Unit, +): NestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.toFloat() + return if (delta < 0 && source == NestedScrollSource.UserInput) { + sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset() + } else { + Offset.Zero + } + } + + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + return if (source == NestedScrollSource.UserInput) { + sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = available.toFloat() + val currentOffset = sheetState.requireOffset() + val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor() + return if (toFling < 0 && currentOffset > minAnchor) { + onFling(toFling) + // since we go to the anchor with tween settling, consume all for the best UX + available + } else { + Velocity.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + onFling(available.toFloat()) + return available + } + + private fun Float.toOffset(): Offset = Offset( + x = if (orientation == Orientation.Horizontal) this else 0f, + y = if (orientation == Orientation.Vertical) this else 0f, + ) + + @JvmName("velocityToFloat") + private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y + + @JvmName("offsetToFloat") + private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y +} + +@Composable +internal fun rememberSheetState( + skipPartiallyExpanded: Boolean = false, + confirmValueChange: (TangemSheetValue) -> Boolean = { true }, + initialValue: TangemSheetValue = Hidden, + skipHiddenState: Boolean = false, +): TangemSheetState { + val density = LocalDensity.current + return rememberSaveable( + skipPartiallyExpanded, + confirmValueChange, + skipHiddenState, + saver = + TangemSheetState.Saver( + skipPartiallyExpanded = skipPartiallyExpanded, + confirmValueChange = confirmValueChange, + density = density, + skipHiddenState = skipHiddenState, + ), + ) { + TangemSheetState( + skipPartiallyExpanded, + density, + initialValue, + confirmValueChange, + skipHiddenState, + ) + } +} + +/** The default animation spec used by [TangemSheetState]. */ +private val BottomSheetAnimationSpec: AnimationSpec = + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium, + ) \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt similarity index 58% rename from features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt index 1917d4e4c8..0a519a13e1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt @@ -1,8 +1,8 @@ -package com.tangem.feature.wallet.presentation.common.component +package com.tangem.core.ui.components.token import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -16,15 +16,18 @@ 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.Constraints +import com.tangem.core.ui.R import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.components.token.internal.* +import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.rememberHapticFeedback +import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.wallet.presentation.common.WalletPreviewData -import com.tangem.feature.wallet.presentation.common.component.token.* -import com.tangem.feature.wallet.presentation.common.state.TokenItemState import org.burnoutcrew.reorderable.ReorderableLazyListState +import java.util.UUID import kotlin.math.max private const val TITLE_MIN_WIDTH_COEFFICIENT = 0.3 @@ -34,12 +37,51 @@ private enum class LayoutId { ICON, TITLE, FIAT_AMOUNT, CRYPTO_AMOUNT, CRYPTO_PRICE, NON_FIAT_CONTENT } +/** + * Token item for non reorderable list + * + * @param state token item state + * @param isBalanceHidden flag that shows/hides balance + * @param modifier modifier + * + * @see Figma Component + */ @Composable -internal fun TokenItem( +fun TokenItem( state: TokenItemState, isBalanceHidden: Boolean, modifier: Modifier = Modifier, - reorderableTokenListState: ReorderableLazyListState? = null, + itemPaddingValues: PaddingValues = PaddingValues(horizontal = TangemTheme.dimens.spacing12), +) { + TokenItem( + state = state, + isBalanceHidden = isBalanceHidden, + modifier = modifier, + reorderableTokenListState = null, + itemPaddingValues = itemPaddingValues, + ) +} + +/** + * Token item for reorderable list + * + * @param state token item state + * @param isBalanceHidden flag that shows/hides balance + * @param reorderableTokenListState reorderable token list state + * @param modifier modifier + * @param itemPaddingValues padding values + * + * @see Figma Component + */ +@Composable +fun TokenItem( + state: TokenItemState, + isBalanceHidden: Boolean, + reorderableTokenListState: ReorderableLazyListState?, + modifier: Modifier = Modifier, + itemPaddingValues: PaddingValues = PaddingValues(horizontal = TangemTheme.dimens.spacing12), ) { val betweenRowsMargin = TangemTheme.dimens.spacing2 @@ -47,7 +89,7 @@ internal fun TokenItem( state = state, modifier = modifier .tokenClickable(state = state) - .background(color = TangemTheme.colors.background.primary), + .padding(itemPaddingValues), ) { CurrencyIcon( state = state.iconState, @@ -73,7 +115,7 @@ internal fun TokenItem( ) TokenPrice( - state = state.cryptoPriceState, + state = state.subtitleState, modifier = Modifier .layoutId(layoutId = LayoutId.CRYPTO_PRICE) .padding(end = TangemTheme.dimens.spacing8), @@ -96,17 +138,22 @@ internal fun TokenItem( @OptIn(ExperimentalFoundationApi::class) private fun Modifier.tokenClickable(state: TokenItemState): Modifier = composed { when (state) { - is TokenItemState.Content -> { - val onLongClick = rememberHapticFeedback(state = state, onAction = state.onItemLongClick) - combinedClickable(onClick = state.onItemClick, onLongClick = onLongClick) - } - is TokenItemState.Unreachable -> { - val onLongClick = rememberHapticFeedback(state = state, onAction = state.onItemLongClick) - combinedClickable(onClick = state.onItemClick, onLongClick = onLongClick) - } - is TokenItemState.NoAddress -> { - val onLongClick = rememberHapticFeedback(state = state, onAction = state.onItemLongClick) - combinedClickable(onClick = {}, onLongClick = onLongClick) + is TokenItemState.Content, + is TokenItemState.NoAddress, + is TokenItemState.Unreachable, + -> { + val onClick = state.onItemClick + val onLongClick = state.onItemLongClick?.let { rememberHapticFeedback(state = state, onAction = it) } + + when { + onClick == null && onLongClick == null -> this + onClick == null && onLongClick != null -> combinedClickable(onClick = {}, onLongClick = onLongClick) + onClick != null && onLongClick == null -> combinedClickable(onClick = onClick) + onClick != null && onLongClick != null -> { + combinedClickable(onClick = onClick, onLongClick = onLongClick) + } + else -> this + } } is TokenItemState.Draggable, is TokenItemState.Loading, @@ -127,10 +174,7 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier Layout(content = content, modifier = modifier) { measurables, constraints -> val layoutWidth = constraints.maxWidth - val horizontalPadding = with(density) { dimens.size12.roundToPx() } val verticalPadding = with(density) { dimens.size15.roundToPx() } - val layoutWidthWithoutPaddings = layoutWidth - 2 * horizontalPadding - val titleMinWidth = (layoutWidth * TITLE_MIN_WIDTH_COEFFICIENT).toInt() val priceChangeMinWidth = (layoutWidth * PRICE_MIN_WIDTH_COEFFICIENT).toInt() @@ -166,32 +210,36 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier -> { fiatAmount = measurables.measureFiatAmount( state = state, - maxWidth = layoutWidthWithoutPaddings - icon.width - titleMinWidth, + maxWidth = layoutWidth - icon.width - titleMinWidth, defaultConstraints = constraints, ) cryptoAmount = measurables.measureCryptoAmount( state = state, - maxWidth = layoutWidthWithoutPaddings - icon.width - priceChangeMinWidth, + maxWidth = layoutWidth - icon.width - priceChangeMinWidth, defaultConstraints = constraints, ) - firstRowRemainingFreeSpace = layoutWidthWithoutPaddings - icon.width - fiatAmount.width - secondRowRemainingFreeSpace = layoutWidthWithoutPaddings - icon.width - cryptoAmount.width + firstRowRemainingFreeSpace = layoutWidth - icon.width - fiatAmount.width + secondRowRemainingFreeSpace = layoutWidth - icon.width - cryptoAmount.width } is TokenItemState.Draggable -> { cryptoAmount = measurables.measureCryptoAmount( state = state, - maxWidth = layoutWidthWithoutPaddings - icon.width - nonFiatContent.width, + maxWidth = layoutWidth - icon.width - nonFiatContent.width, defaultConstraints = constraints, ) - firstRowRemainingFreeSpace = layoutWidthWithoutPaddings - icon.width - nonFiatContent.width + firstRowRemainingFreeSpace = layoutWidth - icon.width - nonFiatContent.width } is TokenItemState.NoAddress, is TokenItemState.Unreachable, -> { - firstRowRemainingFreeSpace = layoutWidthWithoutPaddings - icon.width - nonFiatContent.width + firstRowRemainingFreeSpace = layoutWidth - icon.width - nonFiatContent.width + + if (state.subtitleState != null) { + secondRowRemainingFreeSpace = layoutWidth - icon.width - nonFiatContent.width + } } } @@ -222,35 +270,41 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier ) layout(width = constraints.maxWidth, height = layoutHeight) { - icon.placeRelative(x = horizontalPadding, y = (layoutHeight - icon.height).div(other = 2)) + icon.placeRelative(x = 0, y = (layoutHeight - icon.height).div(other = 2)) title.placeRelative( - x = horizontalPadding + icon.width, + x = icon.width, y = when (state) { is TokenItemState.NoAddress, is TokenItemState.Unreachable, - -> (layoutHeight - title.height).div(other = 2) + -> { + if (state.subtitleState == null) { + (layoutHeight - title.height).div(other = 2) + } else { + verticalPadding + } + } else -> verticalPadding }, ) - fiatAmount?.placeRelative(x = layoutWidth - fiatAmount.width - horizontalPadding, y = verticalPadding) + fiatAmount?.placeRelative(x = layoutWidth - fiatAmount.width, y = verticalPadding) priceChange?.placeRelative( - x = horizontalPadding + icon.width, + x = icon.width, y = layoutHeight - priceChange.height - verticalPadding, ) cryptoAmount?.placeRelative( x = when (state) { - is TokenItemState.Draggable -> horizontalPadding + icon.width - else -> layoutWidth - cryptoAmount.width - horizontalPadding + is TokenItemState.Draggable -> icon.width + else -> layoutWidth - cryptoAmount.width }, y = layoutHeight - cryptoAmount.height - verticalPadding, ) nonFiatContent.placeRelative( - x = layoutWidth - nonFiatContent.width - horizontalPadding, + x = layoutWidth - nonFiatContent.width, y = (layoutHeight - nonFiatContent.height).div(other = 2), ) } @@ -397,8 +451,8 @@ private fun Preview_TokenItem_InLight(@PreviewParameter(TokenItemStateProvider:: private class TokenItemStateProvider : CollectionPreviewParameterProvider( collection = listOf( - WalletPreviewData.tokenItemVisibleState.copy( - iconState = WalletPreviewData.coinIconState.copy(showCustomBadge = true), + tokenItemVisibleState.copy( + iconState = coinIconState.copy(showCustomBadge = true), titleState = TokenItemState.TitleState.Content( text = "PolygonPolygonPolygonPolygonPolygonPolygon", hasPending = true, @@ -408,19 +462,122 @@ private class TokenItemStateProvider : CollectionPreviewParameterProvider { CryptoAmountText( - amount = if (isBalanceHidden) StringsSigns.STARS else state.text, + amount = state.text.orMaskWithStars(isBalanceHidden), modifier = modifier, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/token/TokenFiatAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt similarity index 88% rename from features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/token/TokenFiatAmount.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt index 4c0c3f8009..e8317b0c26 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/token/TokenFiatAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.wallet.presentation.common.component.token +package com.tangem.core.ui.components.token.internal import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Row @@ -14,18 +14,18 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.R import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.res.TangemTheme -import com.tangem.feature.wallet.impl.R -import com.tangem.utils.StringsSigns -import com.tangem.feature.wallet.presentation.common.state.TokenItemState.FiatAmountState as TokenFiatAmountState +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState as TokenFiatAmountState @Composable internal fun TokenFiatAmount(state: TokenFiatAmountState?, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { when (state) { is TokenFiatAmountState.Content -> { FiatAmountText( - text = if (isBalanceHidden) StringsSigns.STARS else state.text, + text = state.text.orMaskWithStars(isBalanceHidden), hasStaked = state.hasStaked, modifier = modifier, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/token/TokenPrice.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenPrice.kt similarity index 67% rename from features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/token/TokenPrice.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenPrice.kt index 4df035cf66..aacba7c8b7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/token/TokenPrice.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenPrice.kt @@ -1,5 +1,6 @@ -package com.tangem.feature.wallet.presentation.common.component.token +package com.tangem.core.ui.components.token.internal +import android.content.res.Configuration import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -12,19 +13,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.R import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.SpacerW4 import com.tangem.core.ui.components.SpacerW6 import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.res.TangemTheme -import com.tangem.feature.wallet.impl.R +import com.tangem.core.ui.res.TangemThemePreview import com.tangem.utils.StringsSigns.DASH_SIGN -import com.tangem.feature.wallet.presentation.common.state.TokenItemState.CryptoPriceState as TokenPriceChangeState +import com.tangem.core.ui.components.token.state.TokenItemState.SubtitleState as TokenPriceState @Composable -internal fun TokenPrice(state: TokenPriceChangeState?, modifier: Modifier = Modifier) { +internal fun TokenPrice(state: TokenPriceState?, modifier: Modifier = Modifier) { when (state) { - is TokenPriceChangeState.Content -> { + is TokenPriceState.CryptoPriceContent -> { PriceBlock( modifier = modifier, price = state.price, @@ -32,13 +37,12 @@ internal fun TokenPrice(state: TokenPriceChangeState?, modifier: Modifier = Modi priceChangePercent = state.priceChangePercent, ) } - is TokenPriceChangeState.Unknown -> { - PriceText(text = DASH_SIGN, modifier = modifier) - } - is TokenPriceChangeState.Loading -> { + is TokenPriceState.TextContent -> PriceText(text = state.value, modifier = modifier) + is TokenPriceState.Unknown -> PriceText(text = DASH_SIGN, modifier = modifier) + is TokenPriceState.Loading -> { RectangleShimmer(modifier = modifier.placeholderSize(), radius = TangemTheme.dimens.radius4) } - is TokenPriceChangeState.Locked -> { + is TokenPriceState.Locked -> { LockedRectangle(modifier = modifier.placeholderSize()) } null -> Unit @@ -122,4 +126,37 @@ private fun Modifier.placeholderSize(): Modifier = composed { return@composed this .padding(vertical = TangemTheme.dimens.spacing2) .size(width = TangemTheme.dimens.size52, height = TangemTheme.dimens.size12) -} \ No newline at end of file +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(TokenPriceChangeStateProvider::class) state: TokenPriceState) { + TangemThemePreview { + TokenPrice(state = state) + } +} + +private class TokenPriceChangeStateProvider : CollectionPreviewParameterProvider( + collection = listOf( + TokenPriceState.CryptoPriceContent( + price = "1.234", + priceChangePercent = "2.5%", + type = PriceChangeType.UP, + ), + TokenPriceState.CryptoPriceContent( + price = "1.234", + priceChangePercent = "2.5%", + type = PriceChangeType.DOWN, + ), + TokenPriceState.CryptoPriceContent( + price = "1.234", + priceChangePercent = "2.5%", + type = PriceChangeType.NEUTRAL, + ), + TokenPriceState.TextContent(value = "Subtitle"), + TokenPriceState.Unknown, + TokenPriceState.Loading, + TokenPriceState.Locked, + ), +) \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/token/TokenTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenTitle.kt similarity index 92% rename from features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/token/TokenTitle.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenTitle.kt index e5b3050a53..706120f20e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/token/TokenTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenTitle.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.wallet.presentation.common.component.token +package com.tangem.core.ui.components.token.internal import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image @@ -13,10 +13,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.R import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.res.TangemTheme -import com.tangem.feature.wallet.impl.R -import com.tangem.feature.wallet.presentation.common.state.TokenItemState.TitleState as TokenTitleState +import com.tangem.core.ui.components.token.state.TokenItemState.TitleState as TokenTitleState @Composable internal fun TokenTitle(state: TokenTitleState?, modifier: Modifier = Modifier) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt new file mode 100644 index 0000000000..eb10290ab4 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt @@ -0,0 +1,206 @@ +package com.tangem.core.ui.components.token.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeType + +/** TokenItem component state */ +@Immutable +sealed class TokenItemState { + + /** Unique id */ + abstract val id: String + + /** Token icon state */ + abstract val iconState: CurrencyIconState + + /** Token title state (in one row with [fiatAmountState]) */ + abstract val titleState: TitleState + + /** Token subtitle state (under [titleState] and in one row with [cryptoAmountState]) */ + abstract val subtitleState: SubtitleState? + + /** Token fiat amount state (in one row with [titleState]) */ + abstract val fiatAmountState: FiatAmountState? + + /** Token crypto amount state (under [fiatAmountState] and in one row with [subtitleState]) */ + abstract val cryptoAmountState: CryptoAmountState? + + /** Callback which will be called when an item is clicked */ + abstract val onItemClick: (() -> Unit)? + + /** Callback which will be called when an item is long clicked */ + abstract val onItemLongClick: (() -> Unit)? + + /** + * Loading token state + * + * @property id unique id + * @property iconState token icon state + * @property titleState token title + * @property subtitleState token subtitle + */ + data class Loading( + override val id: String, + override val iconState: CurrencyIconState, + override val titleState: TitleState.Content, + override val subtitleState: SubtitleState = SubtitleState.Loading, + ) : TokenItemState() { + override val fiatAmountState: FiatAmountState = FiatAmountState.Loading + override val cryptoAmountState: CryptoAmountState = CryptoAmountState.Loading + override val onItemClick: (() -> Unit)? = null + override val onItemLongClick: (() -> Unit)? = null + } + + /** + * Locked token state + * + * @property id unique id + */ + data class Locked(override val id: String) : TokenItemState() { + override val iconState: CurrencyIconState = CurrencyIconState.Locked + override val titleState: TitleState = TitleState.Locked + override val subtitleState: SubtitleState = SubtitleState.Locked + override val fiatAmountState: FiatAmountState = FiatAmountState.Locked + override val cryptoAmountState: CryptoAmountState = CryptoAmountState.Locked + override val onItemClick: (() -> Unit)? = null + override val onItemLongClick: (() -> Unit)? = null + } + + /** + * Content token state + * + * @property id unique id + * @property iconState token icon state + * @property titleState token title + * @property subtitleState token subtitle + * @property fiatAmountState token fiat amount + * @property cryptoAmountState token crypto amount + * @property onItemClick callback which will be called when an item is clicked + * @property onItemLongClick callback which will be called when an item is long clicked + */ + data class Content( + override val id: String, + override val iconState: CurrencyIconState, + override val titleState: TitleState, + override val subtitleState: SubtitleState, + override val fiatAmountState: FiatAmountState, + override val cryptoAmountState: CryptoAmountState.Content, + override val onItemClick: (() -> Unit)?, + override val onItemLongClick: (() -> Unit)?, + ) : TokenItemState() + + /** + * Draggable token state + * + * @property id unique id + * @property iconState token icon state + * @property titleState token title + * @property cryptoAmountState token crypto amount + */ + data class Draggable( + override val id: String, + override val iconState: CurrencyIconState, + override val titleState: TitleState, + override val cryptoAmountState: CryptoAmountState, + ) : TokenItemState() { + override val subtitleState: SubtitleState? = null + override val fiatAmountState: FiatAmountState? = null + override val onItemClick: (() -> Unit)? = null + override val onItemLongClick: (() -> Unit)? = null + } + + /** + * Unreachable token state + * + * @property id unique id + * @property iconState token icon state + * @property titleState token title + * @property subtitleState token subtitle + * @property onItemClick callback which will be called when an item is clicked + * @property onItemLongClick callback which will be called when an item is long clicked + */ + data class Unreachable( + override val id: String, + override val iconState: CurrencyIconState, + override val titleState: TitleState, + override val subtitleState: SubtitleState? = null, + override val onItemClick: (() -> Unit)?, + override val onItemLongClick: (() -> Unit)?, + ) : TokenItemState() { + override val fiatAmountState: FiatAmountState? = null + override val cryptoAmountState: CryptoAmountState? = null + } + + /** + * No derivation address state + * + * @property id unique id + * @property iconState token icon state + * @property titleState token title + * @property subtitleState token subtitle + * @property onItemLongClick callback which will be called when an item is long clicked + */ + data class NoAddress( + override val id: String, + override val iconState: CurrencyIconState, + override val titleState: TitleState, + override val subtitleState: SubtitleState? = null, + override val onItemLongClick: (() -> Unit)?, + ) : TokenItemState() { + override val fiatAmountState: FiatAmountState? = null + override val cryptoAmountState: CryptoAmountState? = null + override val onItemClick: (() -> Unit)? = null + } + + @Immutable + sealed class TitleState { + + data class Content(val text: String, val hasPending: Boolean = false) : TitleState() + + data object Loading : TitleState() + + data object Locked : TitleState() + } + + @Immutable + sealed class SubtitleState { + + data class CryptoPriceContent( + val price: String, + val priceChangePercent: String, + val type: PriceChangeType, + ) : SubtitleState() + + data class TextContent(val value: String) : SubtitleState() + + data object Unknown : SubtitleState() + + data object Loading : SubtitleState() + + data object Locked : SubtitleState() + } + + @Immutable + sealed class FiatAmountState { + data class Content( + val text: String, + val hasStaked: Boolean = false, + ) : FiatAmountState() + + data object Loading : FiatAmountState() + + data object Locked : FiatAmountState() + } + + @Immutable + sealed class CryptoAmountState { + data class Content(val text: String) : CryptoAmountState() + + data object Unreachable : CryptoAmountState() + + data object Loading : CryptoAmountState() + + data object Locked : CryptoAmountState() + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt index 6a906295de..91efa949e6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt @@ -28,13 +28,9 @@ import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.transactions.state.TransactionState import com.tangem.core.ui.components.transactions.state.TransactionState.Content.Direction import com.tangem.core.ui.components.transactions.state.TransactionState.Content.Status -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.stringReference +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.utils.StringsSigns import java.util.UUID /** @@ -258,7 +254,7 @@ private fun Amount(state: TransactionState, isBalanceHidden: Boolean, modifier: when (state) { is TransactionState.Content -> { Text( - text = if (isBalanceHidden) StringsSigns.STARS else state.amount, + text = state.amount.orMaskWithStars(isBalanceHidden), modifier = modifier, textAlign = TextAlign.End, color = when (state.status) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt index aea7aaa822..0a5cc124d1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt @@ -1,7 +1,6 @@ package com.tangem.core.ui.components.transactions import android.content.res.Configuration -import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -13,22 +12,24 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R -import com.tangem.core.ui.res.TangemThemePreview +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.stringReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.utils.DateTimeFormatters -import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.core.ui.res.TangemThemePreview /** * Common transaction done screen title * - * @param titleRes title resource - * @param date transaction timestamp in millis + * @param title title resource + * @param subtitle subtitle text */ @Composable -fun TransactionDoneTitle(@StringRes titleRes: Int, date: Long, modifier: Modifier = Modifier) { +fun TransactionDoneTitle(title: TextReference, subtitle: TextReference, modifier: Modifier = Modifier) { Column( modifier = modifier .fillMaxWidth(), @@ -41,20 +42,17 @@ fun TransactionDoneTitle(@StringRes titleRes: Int, date: Long, modifier: Modifie .size(TangemTheme.dimens.size64), ) Text( - text = stringResource(id = titleRes), + text = title.resolveReference(), style = TangemTheme.typography.h3, color = TangemTheme.colors.text.primary1, modifier = Modifier .padding(top = TangemTheme.dimens.spacing16), ) Text( - text = stringResource( - id = R.string.send_date_format, - date.toTimeFormat(DateTimeFormatters.dateFormatter), - date.toTimeFormat(), - ), + text = subtitle.resolveReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, modifier = Modifier .padding(top = TangemTheme.dimens.spacing4), ) @@ -68,8 +66,8 @@ fun TransactionDoneTitle(@StringRes titleRes: Int, date: Long, modifier: Modifie private fun TransactionDoneTitlePreview() { TangemThemePreview { TransactionDoneTitle( - titleRes = R.string.sent_transaction_sent_title, - date = 0, + title = resourceReference(R.string.sent_transaction_sent_title), + subtitle = stringReference("0"), modifier = Modifier .background(TangemTheme.colors.background.tertiary) .padding(TangemTheme.dimens.spacing16), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionState.kt index 7df1982a29..57a1fd52ff 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionState.kt @@ -36,9 +36,9 @@ sealed interface TransactionState { ) : TransactionState { sealed class Status { - object Failed : Status() - object Confirmed : Status() - object Unconfirmed : Status() + data object Failed : Status() + data object Confirmed : Status() + data object Unconfirmed : Status() } enum class Direction { diff --git a/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableBottomSheetComponent.kt b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableBottomSheetComponent.kt new file mode 100644 index 0000000000..2e3d83710f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableBottomSheetComponent.kt @@ -0,0 +1,13 @@ +package com.tangem.core.ui.decompose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable + +@Stable +interface ComposableBottomSheetComponent { + + fun dismiss() + + @Composable + fun BottomSheet() +} \ 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 f59561c295..59fe1fa692 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 @@ -73,6 +73,8 @@ fun getActiveIconRes(blockchainId: String): Int { "blast", "blast/test" -> R.drawable.img_blast_22 "filecoin" -> R.drawable.img_filecoin_22 "cyber", "cyber/test" -> R.drawable.img_cyber_22 + "sei", "sei/test" -> R.drawable.img_sei_22 + "internet-computer" -> R.drawable.img_icp_22 else -> R.drawable.ic_alert_24 } } @@ -147,11 +149,13 @@ fun getActiveIconResByNetworkId(networkId: String): Int { "blast", "blast/test" -> R.drawable.img_blast_22 "filecoin" -> R.drawable.img_filecoin_22 "cyber", "cyber/test" -> R.drawable.img_cyber_22 + "sei", "sei/test" -> R.drawable.img_sei_22 + "internet-computer" -> R.drawable.img_icp_22 else -> R.drawable.ic_alert_24 } } -@Suppress("ComplexMethod") +@Suppress("ComplexMethod", "LongMethod") @DrawableRes fun getActiveIconResByCoinId(coinId: String): Int { return when (coinId) { @@ -218,6 +222,8 @@ fun getActiveIconResByCoinId(coinId: String): Int { "blast", "blast/test" -> R.drawable.img_blast_22 "filecoin" -> R.drawable.img_filecoin_22 "cyber", "cyber/test" -> R.drawable.img_cyber_22 + "sei", "sei/test" -> R.drawable.img_sei_22 + "internet-computer" -> R.drawable.img_icp_22 else -> R.drawable.ic_alert_24 } } @@ -292,6 +298,8 @@ fun getGreyedOutIconRes(blockchainId: String): Int { "blast", "blast/test" -> R.drawable.ic_blast_22 "filecoin" -> R.drawable.ic_filecoin_22 "cyber", "cyber/test" -> R.drawable.ic_cyber_22 + "sei", "sei/test" -> R.drawable.ic_sei_22 + "internet-computer" -> R.drawable.ic_icp_22 else -> R.drawable.ic_alert_24 } } @@ -366,6 +374,8 @@ fun getGreyedOutIconResByNetworkId(networkId: String): Int { "blast", "blast/test" -> R.drawable.ic_blast_22 "filecoin" -> R.drawable.ic_filecoin_22 "cyber", "cyber/test" -> R.drawable.ic_cyber_22 + "sei", "sei/test" -> R.drawable.ic_sei_22 + "internet-computer" -> R.drawable.ic_icp_22 else -> R.drawable.ic_alert_24 } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/String.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/String.kt index be264409bc..63fb8f00bd 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/String.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/String.kt @@ -31,4 +31,10 @@ fun String.toQrCode(sizePx: Int = 256, paddingPx: Int = 0): Bitmap { return bmp } -fun String.capitalize(): String = replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } \ No newline at end of file +fun String.capitalize(): String = replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + +fun String.orMaskWithStars(maskWithStars: Boolean): String { + return if (maskWithStars) THREE_STARS else this +} + +internal const val THREE_STARS = "\u2217\u2217\u2217" \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt index 4aab281036..c7a5af394e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt @@ -67,6 +67,9 @@ sealed interface TextReference { /** Empty string as [TextReference] */ val EMPTY: TextReference by lazy(mode = LazyThreadSafetyMode.NONE) { Str(value = "") } + + /** Stars string as [TextReference] */ + val STARS: TextReference by lazy(mode = LazyThreadSafetyMode.NONE) { Str(value = THREE_STARS) } } } @@ -250,4 +253,17 @@ private fun formatAnnotated(rawString: String): AnnotatedString { return buildAnnotatedString { appendMarkdown(markdownText = rawString, node = parsedTree) } +} + +/** + * Returns the TextReference itself if hide is false, otherwise returns a reference with STARS. + * + * @param maskWithStars A boolean flag that determines whether to hide the string. + * If true, the original TextReference will be replaced by reference with STARS. + * If false, the original TextReference will be returned. + * + * @return The original reference if hide is false, or reference with STARS if hide is true. + */ +fun TextReference.orMaskWithStars(maskWithStars: Boolean): TextReference { + return if (maskWithStars) stringReference(THREE_STARS) else this } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/pullToRefresh/PullToRefreshConfig.kt b/core/ui/src/main/java/com/tangem/core/ui/pullToRefresh/PullToRefreshConfig.kt new file mode 100644 index 0000000000..b6c6aea4b9 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/pullToRefresh/PullToRefreshConfig.kt @@ -0,0 +1,15 @@ +package com.tangem.core.ui.pullToRefresh + +/** + * Pull to refresh config data + * + * @property isRefreshing state is indicator visible + * @property onRefresh lambda be invoked when pulled to refresh + */ +data class PullToRefreshConfig(val isRefreshing: Boolean, val onRefresh: (ShowRefreshState) -> Unit) { + + @JvmInline + value class ShowRefreshState( + val value: Boolean = true, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/reordarable/ReorderableItem.kt b/core/ui/src/main/java/com/tangem/core/ui/reordarable/ReorderableItem.kt new file mode 100644 index 0000000000..7df813a2e5 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/reordarable/ReorderableItem.kt @@ -0,0 +1,102 @@ +package com.tangem.core.ui.reordarable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.zIndex +import org.burnoutcrew.reorderable.ReorderableState + +/** + * Fixed version of ReorderableItem from reorderable library. + * The original version of ReorderableItem is somehow causing crashes. + * Probably because of compose compiler's bug and new foundation `animateItem` API. + * @see [org.burnoutcrew.reorderable.ReorderableItem] + */ +@Suppress("ComposableFunctionName") +@Composable +inline fun LazyItemScope.ReorderableItem( + reorderableState: ReorderableState<*>, + key: Any?, + modifier: Modifier = Modifier, + index: Int? = null, + orientationLocked: Boolean = true, + content: @Composable BoxScope.(isDragging: Boolean) -> Unit, +) = ReorderableItem(reorderableState, key, modifier, Modifier.animateItem(), orientationLocked, index, content) + +/** + * Fixed version of ReorderableItem from reorderable library. + * The original version of ReorderableItem is somehow causing crashes. + * Probably because of compose compiler's bug and new foundation `animateItem` API. + * @see [org.burnoutcrew.reorderable.ReorderableItem] + */ +@Suppress("ComposableFunctionName") +@Composable +inline fun LazyGridItemScope.ReorderableItem( + reorderableState: ReorderableState<*>, + key: Any?, + modifier: Modifier = Modifier, + index: Int? = null, + content: @Composable BoxScope.(isDragging: Boolean) -> Unit, +) = ReorderableItem(reorderableState, key, modifier, Modifier.animateItem(), false, index, content) + +/** + * Fixed version of ReorderableItem from reorderable library. + * The original version of ReorderableItem is somehow causing crashes. + * Probably because of compose compiler's bug and new foundation `animateItem` API. + * @see [org.burnoutcrew.reorderable.ReorderableItem] + */ +@Composable +inline fun ReorderableItem( + state: ReorderableState<*>, + key: Any?, + modifier: Modifier = Modifier, + defaultDraggingModifier: Modifier = Modifier, + orientationLocked: Boolean = true, + index: Int? = null, + content: @Composable BoxScope.(isDragging: Boolean) -> Unit, +) { + val isDragging = if (index != null) { + index == state.draggingItemIndex + } else { + key == state.draggingItemKey + } + val draggingModifier = + if (isDragging) { + Modifier + .zIndex(1f) + .graphicsLayer { + translationX = if (!orientationLocked || !state.isVerticalScroll) state.draggingItemLeft else 0f + translationY = if (!orientationLocked || state.isVerticalScroll) state.draggingItemTop else 0f + } + } else { + val cancel = if (index != null) { + index == state.dragCancelledAnimation.position?.index + } else { + key == state.dragCancelledAnimation.position?.key + } + if (cancel) { + Modifier.zIndex(1f) + .graphicsLayer { + translationX = if (!orientationLocked || !state.isVerticalScroll) { + state.dragCancelledAnimation.offset.x + } else { + 0f + } + translationY = if (!orientationLocked || state.isVerticalScroll) { + state.dragCancelledAnimation.offset.y + } else { + 0f + } + } + } else { + defaultDraggingModifier + } + } + Box(modifier = modifier.then(draggingModifier)) { + content(isDragging) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt index 9b00a821e5..9617ee7825 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt @@ -74,6 +74,7 @@ data class TangemDimens internal constructor( val size56: Dp = 56.dp, val size60: Dp = 60.dp, val size62: Dp = 62.dp, + val size63: Dp = 63.dp, val size64: Dp = 64.dp, val size68: Dp = 68.dp, val size70: Dp = 70.dp, diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt index 0347ca4893..dd15fd2212 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection import com.tangem.core.ui.windowsize.rememberWindowSizePreview @Composable @@ -14,12 +16,14 @@ fun TangemThemePreview( typography: TangemTypography = TangemTheme.typography, dimens: TangemDimens = TangemTheme.dimens, alwaysShowBottomSheets: Boolean = true, + rtl: Boolean = false, content: @Composable () -> Unit, ) { val isDarkTheme = isDark ?: isSystemInDarkTheme() CompositionLocalProvider( LocalBottomSheetAlwaysVisible provides alwaysShowBottomSheets, + LocalLayoutDirection provides if (rtl) LayoutDirection.Rtl else LayoutDirection.Ltr, ) { BoxWithConstraints { TangemTheme( diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TestTags.kt index 8548896b31..d7de6de18e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/TestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TestTags.kt @@ -5,5 +5,16 @@ object TestTags { const val STORIES_SCREEN_SCAN_BUTTON = "STORIES_SCREEN_SCAN_BUTTON" const val STORIES_SCREEN_ORDER_BUTTON = "STORIES_SCREEN_ORDER_BUTTON" - const val WALLET_SCREEN = "WALLET_SCREEN_CONTAINER" + const val MAIN_SCREEN = "MAIN_SCREEN_CONTAINER" + const val MAIN_SCREEN_MORE_BUTTON = "MAIN_SCREEN_MORE_BUTTON" + const val MAIN_SCREEN_TOP_BAR = "MAIN_SCREEN_TOP_BAR" + + const val DETAILS_SCREEN = "DETAILS_SCREEN_CONTAINER" + const val DETAILS_SCREEN_ITEM = "DETAILS_SCREEN_ITEM" + + const val WALLET_SETTINGS_SCREEN = "WALLET_SETTINGS_SCREEN" + const val WALLET_SETTINGS_SCREEN_ITEM = "WALLET_SETTINGS_SCREEN_ITEM" + + const val DISCLAIMER_SCREEN_CONTAINER = "DISCLAIMER_SCREEN_CONTAINER" + const val DISCLAIMER_SCREEN_ACCEPT_BUTTON = "DISCLAIMER_SCREEN_ACCEPT_BUTTON" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt index 91ef672575..e6944920a6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt @@ -4,6 +4,7 @@ import android.icu.text.CompactDecimalFormat import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.StringsSigns.LOWER_SIGN +import com.tangem.utils.StringsSigns.TILDE_SIGN import timber.log.Timber import java.math.BigDecimal import java.math.RoundingMode @@ -26,13 +27,7 @@ object BigDecimalFormatter { private const val FIAT_MARKET_DEFAULT_DIGITS = 2 private const val FIAT_MARKET_EXTENDED_DIGITS = 6 - - private val bigDecimal01 = BigDecimal("0.1") - private val bigDecimal001 = BigDecimal("0.01") - private val bigDecimal0001 = BigDecimal("0.001") - private val bigDecimal00001 = BigDecimal("0.0001") - private val bigDecimal000001 = BigDecimal("0.00001") - private val bigDecimal0000001 = BigDecimal("0.000001") + private const val FRACTIONAL_PART_LENGTH_AFTER_LEADING_ZEROES = 4 fun formatCryptoAmount( cryptoAmount: BigDecimal?, @@ -167,6 +162,7 @@ object BigDecimalFormatter { fiatCurrencySymbol: String, decimals: Int = FIAT_MARKET_DEFAULT_DIGITS, locale: Locale = Locale.getDefault(), + withApproximateSign: Boolean = false, ): String { if (fiatAmount == null) return EMPTY_BALANCE_SIGN @@ -187,8 +183,17 @@ object BigDecimalFormatter { ) } } else { - formatter.format(fiatAmount) + val formattedAmount = formatter.format(fiatAmount) .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) + + if (withApproximateSign) { + buildString { + append(TILDE_SIGN) + append(formattedAmount) + } + } else { + formattedAmount + } } } @@ -226,19 +231,34 @@ object BigDecimalFormatter { if (fiatAmount == null) return EMPTY_BALANCE_SIGN val formatterCurrency = getCurrency(fiatCurrencyCode) - val decimals = getProperFiatPriceDecimals(fiatAmount) + val (formattedAmount, finalScale) = getFiatPriceUncappedWithScale(value = fiatAmount) val formatter = NumberFormat.getCurrencyInstance(locale).apply { currency = formatterCurrency - maximumFractionDigits = decimals + maximumFractionDigits = finalScale minimumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS roundingMode = RoundingMode.HALF_UP } - return formatter.format(fiatAmount) + return formatter.format(formattedAmount) .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) } + fun getFiatPriceUncappedWithScale(value: BigDecimal): Pair { + return if (value < BigDecimal.ONE) { + val leadingZeroes = value.scale() - value.precision() + val scale = leadingZeroes + FRACTIONAL_PART_LENGTH_AFTER_LEADING_ZEROES + + val amount = value + .setScale(scale, RoundingMode.HALF_UP) + .stripTrailingZeros() + + amount to amount.scale() + } else { + value to FIAT_MARKET_DEFAULT_DIGITS + } + } + fun formatFiatEditableAmount( fiatAmount: String?, fiatCurrencyCode: String, @@ -336,6 +356,15 @@ object BigDecimalFormatter { ): String { if (amount == null) return EMPTY_BALANCE_SIGN + if (amount < BigDecimal.ONE) { + return formatFiatPriceUncapped( + fiatAmount = amount, + fiatCurrencyCode = fiatCurrencyCode, + fiatCurrencySymbol = fiatCurrencySymbol, + locale = locale, + ) + } + val rawAmount = formatCompactAmount( amount = amount, locale = locale, @@ -398,18 +427,4 @@ object BigDecimalFormatter { private fun BigDecimal.checkFiatThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD private fun BigDecimal.checkCryptoThreshold() = this > BigDecimal.ZERO && this < CRYPTO_FEE_FORMAT_THRESHOLD - - @Suppress("MagicNumber") - fun getProperFiatPriceDecimals(price: BigDecimal): Int { - return when { - price >= BigDecimal.ONE -> 2 - price >= bigDecimal01 -> 3 - price >= bigDecimal001 -> 4 - price >= bigDecimal0001 -> 6 - price >= bigDecimal00001 -> 8 - price >= bigDecimal000001 -> 10 - price >= bigDecimal0000001 -> 12 - else -> price.stripTrailingZeros().scale() - } - } } \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_analytics_24.xml b/core/ui/src/main/res/drawable/ic_analytics_24.xml new file mode 100644 index 0000000000..743431578d --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_analytics_24.xml @@ -0,0 +1,18 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_connection_18.xml b/core/ui/src/main/res/drawable/ic_connection_18.xml new file mode 100644 index 0000000000..80d76d5158 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_connection_18.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_icp_22.xml b/core/ui/src/main/res/drawable/ic_icp_22.xml new file mode 100644 index 0000000000..cd67883760 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_icp_22.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_magic_28.xml b/core/ui/src/main/res/drawable/ic_magic_28.xml new file mode 100644 index 0000000000..c6f4bf1724 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_magic_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_sei_22.xml b/core/ui/src/main/res/drawable/ic_sei_22.xml new file mode 100644 index 0000000000..576382fca3 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_sei_22.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_tether_24.xml b/core/ui/src/main/res/drawable/ic_tether_24.xml new file mode 100644 index 0000000000..3c53fbab8e --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_tether_24.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_tether_28.xml b/core/ui/src/main/res/drawable/ic_tether_28.xml deleted file mode 100644 index e3f94b5dbb..0000000000 --- a/core/ui/src/main/res/drawable/ic_tether_28.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/core/ui/src/main/res/drawable/ic_transaction_history_staking.xml b/core/ui/src/main/res/drawable/ic_transaction_history_staking.xml new file mode 100644 index 0000000000..008a7841c7 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_transaction_history_staking.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_transaction_history_unstaking.xml b/core/ui/src/main/res/drawable/ic_transaction_history_unstaking.xml new file mode 100644 index 0000000000..3b3961cf9a --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_transaction_history_unstaking.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/img_card_wallet_2_gray_22_36.xml b/core/ui/src/main/res/drawable/img_card_wallet_2_gray_22_36.xml new file mode 100644 index 0000000000..977d693e60 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_card_wallet_2_gray_22_36.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/img_icp_22.xml b/core/ui/src/main/res/drawable/img_icp_22.xml new file mode 100644 index 0000000000..6210d2c900 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_icp_22.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_sei_22.xml b/core/ui/src/main/res/drawable/img_sei_22.xml new file mode 100644 index 0000000000..39ecb53ae8 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_sei_22.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt b/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt index 15d69db683..c563b399a8 100644 --- a/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt +++ b/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt @@ -2,11 +2,11 @@ package com.tangem.utils object StringsSigns { - const val STARS = "\u2217\u2217\u2217" const val DOT = "•" const val PLUS = "+" const val MINUS = "-" const val DASH_SIGN = "—" const val LOWER_SIGN = "<" const val TILDE_SIGN = "~" + const val NON_BREAKING_SPACE = '\u00A0' } \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt index 14e64f1b62..d50f9f295c 100644 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt @@ -24,4 +24,6 @@ class JobHolder { } } -fun Job.saveIn(jobHolder: JobHolder): Job = jobHolder.update(job = this) \ No newline at end of file +fun Job.saveIn(jobHolder: JobHolder): Job = jobHolder.update(job = this) + +suspend fun Job.saveInAndJoin(jobHolder: JobHolder) = saveIn(jobHolder).join() \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/extensions/BigDecimalExt.kt b/core/utils/src/main/java/com/tangem/utils/extensions/BigDecimalExt.kt index d09f3f6a6d..30820e1536 100644 --- a/core/utils/src/main/java/com/tangem/utils/extensions/BigDecimalExt.kt +++ b/core/utils/src/main/java/com/tangem/utils/extensions/BigDecimalExt.kt @@ -7,4 +7,6 @@ import java.math.BigDecimal * * If `BigDecimal?` is `null`, returns `BigDecimal.ZERO` */ -fun BigDecimal?.orZero(): BigDecimal = this ?: BigDecimal.ZERO \ No newline at end of file +fun BigDecimal?.orZero(): BigDecimal = this ?: BigDecimal.ZERO + +fun BigDecimal.isZero(): Boolean = this.compareTo(BigDecimal.ZERO) == 0 \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/extensions/List.kt b/core/utils/src/main/java/com/tangem/utils/extensions/List.kt index e0fbdb5d90..86bdfe2e6e 100644 --- a/core/utils/src/main/java/com/tangem/utils/extensions/List.kt +++ b/core/utils/src/main/java/com/tangem/utils/extensions/List.kt @@ -44,11 +44,24 @@ inline fun MutableList.replaceBy(item: T, predicate: (T) -> Boolean): Boo */ inline fun List.addOrReplace(item: T, predicate: (T) -> Boolean): List { val mutableList = this.toMutableList() - val isReplaced = mutableList.replaceBy(item, predicate) - if (!isReplaced) { - mutableList.add(item) - } + mutableList.addOrReplace(item, predicate) return mutableList +} + +/** + * Adds the specified element to the mutable list or replaces an existing element. + * + * !!!This function is not thread-safe!!! + * + * @param item The element to be added or replace the existing one. + * @param predicate The condition to replace an existing element. + */ +inline fun MutableList.addOrReplace(item: T, predicate: (T) -> Boolean) { + val isReplaced = replaceBy(item, predicate) + + if (!isReplaced) { + add(item) + } } \ 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 9af9e67943..38d8376ff2 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 @@ -12,6 +12,29 @@ import com.tangem.blockchain.common.Token as SdkToken // FIXME: Make internal class CryptoCurrencyFactory { + @Suppress("LongParameterList") // Yep, it's long + fun createToken( + network: Network, + rawId: String?, + name: String, + symbol: String, + decimals: Int, + contractAddress: String, + ): CryptoCurrency.Token { + val id = getTokenId(network, rawId, contractAddress) + + return CryptoCurrency.Token( + id = id, + network = network, + name = name, + symbol = symbol, + decimals = decimals, + iconUrl = rawId?.let(::getTokenIconUrlFromDefaultHost), + isCustom = isCustomToken(id, network), + contractAddress = contractAddress, + ) + } + fun createToken( sdkToken: SdkToken, blockchain: Blockchain, @@ -49,15 +72,7 @@ class CryptoCurrencyFactory { } val network = getNetwork(blockchain, extraDerivationPath, derivationStyleProvider) ?: return null - return CryptoCurrency.Coin( - id = getCoinId(network, blockchain.toCoinId()), - network = network, - name = blockchain.fullName, - symbol = blockchain.currency, - iconUrl = getCoinIconUrl(blockchain), - decimals = blockchain.decimals(), - isCustom = isCustomCoin(network), - ) + return createCoin(network) } fun createCoin( @@ -69,6 +84,20 @@ class CryptoCurrencyFactory { return createCoin(blockchain, extraDerivationPath, derivationStyleProvider) } + fun createCoin(network: Network): CryptoCurrency.Coin { + val blockchain = Blockchain.fromId(network.id.value) + + return CryptoCurrency.Coin( + id = getCoinId(network, blockchain.toCoinId()), + network = network, + name = blockchain.fullName, + symbol = blockchain.currency, + iconUrl = getCoinIconUrl(blockchain), + decimals = blockchain.decimals(), + isCustom = isCustomCoin(network), + ) + } + fun createToken( token: Token, networkId: String, diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/NetworkOperations.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/NetworkOperations.kt index 8f2e5b60ac..3666f5f012 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/NetworkOperations.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/NetworkOperations.kt @@ -11,6 +11,22 @@ fun getBlockchain(networkId: Network.ID): Blockchain { return Blockchain.fromId(networkId.value) } +fun getNetwork(networkId: Network.ID, derivationPath: Network.DerivationPath): Network { + val blockchain = getBlockchain(networkId) + + return Network( + id = networkId, + backendId = blockchain.toNetworkId(), + name = blockchain.getNetworkName(), + isTestnet = blockchain.isTestnet(), + derivationPath = derivationPath, + currencySymbol = blockchain.currency, + standardType = getNetworkStandardType(blockchain), + hasFiatFeeRate = blockchain.feePaidCurrency() !is FeePaidCurrency.FeeResource, + canHandleTokens = blockchain.canHandleTokens(), + ) +} + fun getNetwork( blockchain: Blockchain, extraDerivationPath: String?, @@ -30,10 +46,11 @@ fun getNetwork( currencySymbol = blockchain.currency, standardType = getNetworkStandardType(blockchain), hasFiatFeeRate = blockchain.feePaidCurrency() !is FeePaidCurrency.FeeResource, + canHandleTokens = blockchain.canHandleTokens(), ) } -private fun getNetworkDerivationPath( +fun getNetworkDerivationPath( blockchain: Blockchain, extraDerivationPath: String?, cardDerivationStyleProvider: DerivationStyleProvider?, diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt index 24db327e04..35b0736c8e 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt @@ -2,6 +2,7 @@ package com.tangem.data.common.currency import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token +import com.tangem.blockchainsdk.compatibility.l2BlockchainsList import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.toCoinId import com.tangem.datasource.api.tangemTech.models.UserTokensResponse @@ -91,22 +92,10 @@ class ResponseCryptoCurrenciesFactory { // get name and symbol from enum Blockchain until backend renamed // [REDACTED_JIRA] Blockchain.Dischain, - Blockchain.Arbitrum, - Blockchain.ArbitrumTestnet, - Blockchain.Aurora, - Blockchain.AuroraTestnet, - Blockchain.Manta, - Blockchain.MantaTestnet, - Blockchain.ZkSyncEra, - Blockchain.ZkSyncEraTestnet, - Blockchain.PolygonZkEVM, - Blockchain.PolygonZkEVMTestnet, - Blockchain.Base, - Blockchain.BaseTestnet, Blockchain.Telos, Blockchain.Cronos, Blockchain.TON, - Blockchain.Cyber, + in l2BlockchainsList, -> this.fullName else -> responseToken.name } 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 72018fc4ed..724649c959 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 @@ -4,7 +4,6 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.IconsUtil import com.tangem.blockchainsdk.utils.toCoinId import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency.ID import com.tangem.domain.tokens.model.Network import com.tangem.blockchain.common.Token as SdkToken @@ -65,10 +64,10 @@ fun getCoinIconUrl(blockchain: Blockchain): String? { return coinId?.let(::getTokenIconUrlFromDefaultHost) } -fun List.hasCoinForToken(token: CryptoCurrency.Token): Boolean { +fun List.hasCoinForToken(network: Network): Boolean { return any { - val blockchain = getBlockchain(networkId = token.network.id) - val tokenDerivation = token.network.derivationPath.value + val blockchain = getBlockchain(networkId = network.id) + val tokenDerivation = network.derivationPath.value it.id == blockchain.toCoinId() && it.derivationPath == tokenDerivation } } @@ -85,7 +84,7 @@ private fun getCurrencyIdBody(network: Network): CurrencyIdBody { } } -private fun getTokenIconUrlFromDefaultHost(tokenId: String): String { +fun getTokenIconUrlFromDefaultHost(tokenId: String): String { return buildString { append(DEFAULT_TOKENS_ICONS_HOST) append('/') diff --git a/data/common/src/main/kotlin/com/tangem/data/common/utils/RequestUtils.kt b/data/common/src/main/kotlin/com/tangem/data/common/utils/RequestUtils.kt index e7f3fbfe45..710a982835 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/utils/RequestUtils.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/utils/RequestUtils.kt @@ -7,8 +7,10 @@ import kotlinx.coroutines.yield import timber.log.Timber import kotlin.coroutines.cancellation.CancellationException -@Suppress("UnconditionalJumpStatementInLoop") -suspend fun retryOnError(priority: Boolean = false, call: suspend () -> T): T { +@Suppress("UnconditionalJumpStatementInLoop", "MagicNumber") +suspend fun retryOnError(priority: Boolean = false, startRetryDelay: Int = 500, call: suspend () -> T): T { + var currentDelay = startRetryDelay + var priorityCounter = 5 while (true) { return try { call() @@ -19,9 +21,12 @@ suspend fun retryOnError(priority: Boolean = false, call: suspend () -> T): Timber.e(e, "Error occurred during retryOnError block") - if (priority.not()) { + if (priority && priorityCounter > 0) { + --priorityCounter + } else { yield() - delay(timeMillis = 500) + delay(timeMillis = currentDelay.toLong()) + currentDelay *= 2 } continue diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt b/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt index cb2081979e..c1855597a2 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt @@ -1,6 +1,7 @@ package com.tangem.data.feedback.converters import com.tangem.domain.common.TapWorkarounds.isStart2Coin +import com.tangem.domain.common.util.getBackupCardsCount import com.tangem.domain.feedback.models.CardInfo import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse @@ -20,10 +21,7 @@ internal object CardInfoConverter : Converter { CardInfo( userWalletId = createUserWalletId(scanResponse = value), cardId = card.cardId, - cardsCount = when (val status = value.card.backupStatus) { - is CardDTO.BackupStatus.Active -> status.cardCount.toString() - else -> "0" - }, + cardsCount = value.getBackupCardsCount()?.toString() ?: "0", firmwareVersion = card.firmwareVersion.stringValue, cardBlockchain = walletData?.blockchain, signedHashesList = card.wallets.map { diff --git a/data/manage-tokens/build.gradle.kts b/data/manage-tokens/build.gradle.kts index 137a893c81..a6acc9a452 100644 --- a/data/manage-tokens/build.gradle.kts +++ b/data/manage-tokens/build.gradle.kts @@ -16,10 +16,12 @@ dependencies { implementation(projects.domain.manageTokens) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) + implementation(projects.domain.legacy) /** Project - Data */ implementation(projects.core.datasource) implementation(projects.data.common) + implementation(projects.data.tokens) /** Project - Utils */ implementation(projects.core.utils) diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt new file mode 100644 index 0000000000..3f637d2d33 --- /dev/null +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt @@ -0,0 +1,247 @@ +package com.tangem.data.managetokens + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.data.common.api.safeApiCall +import com.tangem.data.common.currency.CryptoCurrencyFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.data.common.currency.getNetwork +import com.tangem.data.managetokens.utils.TokenAddressesConverter +import com.tangem.data.tokens.utils.UserTokensBackwardCompatibility +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull +import com.tangem.datasource.local.preferences.utils.storeObject +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.common.extensions.canHandleBlockchain +import com.tangem.domain.common.extensions.supportedBlockchains +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.common.util.derivationStyleProvider +import com.tangem.domain.managetokens.model.AddCustomTokenForm +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.managetokens.repository.CustomTokensRepository +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import timber.log.Timber + +internal class DefaultCustomTokensRepository( + private val tangemTechApi: TangemTechApi, + private val userWalletsStore: UserWalletsStore, + private val appPreferencesStore: AppPreferencesStore, + private val walletManagersFacade: WalletManagersFacade, + private val dispatchers: CoroutineDispatcherProvider, +) : CustomTokensRepository { + + private val cryptoCurrencyFactory = CryptoCurrencyFactory() + private val userTokensResponseFactory = UserTokensResponseFactory() + private val tokenAddressConverter = TokenAddressesConverter() + private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility() + + override suspend fun validateContractAddress(contractAddress: String, networkId: Network.ID): Boolean = + withContext(dispatchers.io) { + when (val blockchain = Blockchain.fromId(networkId.value)) { + Blockchain.Unknown, + Blockchain.Binance, + Blockchain.BinanceTestnet, + -> true + Blockchain.Cardano -> blockchain.validateContractAddress(contractAddress.lowercase()) + else -> blockchain.validateAddress(contractAddress.lowercase()) + } + } + + override suspend fun isCurrencyNotAdded( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + contractAddress: String?, + ): Boolean { + return withContext(dispatchers.io) { + val storedCurrencies: UserTokensResponse = appPreferencesStore.getObjectSyncOrNull( + key = PreferencesKeys.getUserTokensKey(userWalletId.stringValue), + ) ?: error("User tokens not found") + + storedCurrencies.tokens.none { token -> + Blockchain.fromId(networkId.value).toNetworkId() == token.networkId && + derivationPath.value == token.derivationPath && + contractAddress.equals(token.contractAddress, ignoreCase = true) + } + } + } + + override suspend fun findToken( + userWalletId: UserWalletId, + contractAddress: String, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + ): CryptoCurrency.Token? = withContext(dispatchers.io) { + val userWallet = userWalletsStore.getSyncOrNull(userWalletId) + ?: error("User wallet not found") + val network = getNetwork(networkId, derivationPath) + val tokenAddress = tokenAddressConverter.convertTokenAddress( + networkId, + contractAddress, + symbol = null, + ) + + val supportedTokenNetworkIds = userWallet.scanResponse.card + .supportedBlockchains(userWallet.scanResponse.cardTypesResolver) + .filter(Blockchain::canHandleTokens) + .map(Blockchain::toNetworkId) + + val response = tangemTechApi.getCoins( + contractAddress = contractAddress, + networkIds = network.backendId, + active = true, + ).getOrThrow() + + response.coins.firstNotNullOfOrNull { coin -> + val coinNetwork = coin.networks.firstOrNull { network -> + (network.contractAddress != null || network.decimalCount != null) && + network.contractAddress.equals(tokenAddress, ignoreCase = true) && + network.networkId in supportedTokenNetworkIds + } + + if (coinNetwork != null) { + cryptoCurrencyFactory.createToken( + network = network, + rawId = coin.id, + name = coin.name, + symbol = coin.symbol, + decimals = coinNetwork.decimalCount!!.toInt(), + contractAddress = tokenAddress, + ) + } else { + null + } + } + } + + override fun createCoin(networkId: Network.ID, derivationPath: Network.DerivationPath): CryptoCurrency.Coin { + val network = getNetwork(networkId, derivationPath) + + return cryptoCurrencyFactory.createCoin(network) + } + + override fun createToken( + managedCryptoCurrency: ManagedCryptoCurrency.Token, + sourceNetwork: ManagedCryptoCurrency.SourceNetwork.Default, + rawId: String?, + ): CryptoCurrency.Token { + return cryptoCurrencyFactory.createToken( + network = sourceNetwork.network, + rawId = rawId, + name = managedCryptoCurrency.name, + symbol = managedCryptoCurrency.symbol, + decimals = sourceNetwork.decimals, + contractAddress = sourceNetwork.contractAddress, + ) + } + + override suspend fun createCustomToken( + networkId: Network.ID, + derivationPath: Network.DerivationPath, + formValues: AddCustomTokenForm.Validated.All, + ): CryptoCurrency.Token { + val network = getNetwork(networkId, derivationPath) + val tokenAddress = tokenAddressConverter.convertTokenAddress( + networkId, + formValues.contractAddress, + formValues.symbol, + ) + + return cryptoCurrencyFactory.createToken( + network = network, + rawId = null, + name = formValues.name, + symbol = formValues.symbol, + decimals = formValues.decimals, + contractAddress = tokenAddress, + ) + } + + override suspend fun removeCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom) = + withContext(dispatchers.io) { + val cryptoCurrency = when (currency) { + is ManagedCryptoCurrency.Custom.Coin -> createCoin(currency.network.id, currency.network.derivationPath) + is ManagedCryptoCurrency.Custom.Token -> cryptoCurrencyFactory.createToken( + network = currency.network, + rawId = currency.currencyId.rawCurrencyId, + name = currency.name, + symbol = currency.symbol, + decimals = currency.decimals, + contractAddress = currency.contractAddress, + ) + } + + val savedCurrencies = requireNotNull( + value = getSavedUserTokensResponseSync(key = userWalletId), + lazyMessage = { "Saved tokens empty. Can not perform remove currency action" }, + ) + val token = userTokensResponseFactory.createResponseToken(cryptoCurrency) + storeAndPushTokens( + userWalletId = userWalletId, + response = savedCurrencies.copy(tokens = savedCurrencies.tokens.filterNot { it == token }), + ) + when (cryptoCurrency) { + is CryptoCurrency.Coin -> walletManagersFacade.remove(userWalletId, setOf(cryptoCurrency.network)) + is CryptoCurrency.Token -> walletManagersFacade.removeTokens(userWalletId, setOf(cryptoCurrency)) + } + } + + override suspend fun getSupportedNetworks(userWalletId: UserWalletId): List = withContext(dispatchers.io) { + val userWallet = userWalletsStore.getSyncOrNull(userWalletId) + ?: error("User wallet not found") + val scanResponse = userWallet.scanResponse + + Blockchain.entries + .mapNotNull { blockchain -> + if (scanResponse.card.canHandleBlockchain(blockchain, scanResponse.cardTypesResolver)) { + getNetwork( + blockchain = blockchain, + extraDerivationPath = null, + derivationStyleProvider = scanResponse.derivationStyleProvider, + ) + } else { + null + } + } + } + + override fun createDerivationPath(rawPath: String): Network.DerivationPath { + val sdkPath = DerivationPath(rawPath) + + return Network.DerivationPath.Custom( + value = sdkPath.rawPath, + ) + } + + private suspend fun storeAndPushTokens(userWalletId: UserWalletId, response: UserTokensResponse) { + val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response) + appPreferencesStore.storeObject( + key = PreferencesKeys.getUserTokensKey(userWalletId = userWalletId.stringValue), + value = compatibleUserTokensResponse, + ) + + pushTokens(userWalletId, response) + } + + private suspend fun pushTokens(userWalletId: UserWalletId, response: UserTokensResponse) { + safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, response).bind() }) { + Timber.e(it, "Unable to save user tokens for: ${userWalletId.stringValue}") + } + } + + private suspend fun getSavedUserTokensResponseSync(key: UserWalletId): UserTokensResponse? { + return appPreferencesStore.getObjectSyncOrNull( + key = PreferencesKeys.getUserTokensKey(key.stringValue), + ) + } +} \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt index bb45239024..49b58cb102 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt @@ -1,8 +1,11 @@ package com.tangem.data.managetokens import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.blockchainsdk.compatibility.l2BlockchainsCoinIds import com.tangem.blockchainsdk.utils.isSupportedInApp import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.data.common.currency.getBlockchain import com.tangem.data.common.utils.retryOnError import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher import com.tangem.data.managetokens.utils.ManagedCryptoCurrencyFactory @@ -13,14 +16,16 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.common.extensions.canHandleBlockchain +import com.tangem.domain.common.extensions.canHandleToken import com.tangem.domain.common.extensions.supportedBlockchains +import com.tangem.domain.common.extensions.supportedTokens import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.common.util.derivationStyleProvider -import com.tangem.domain.managetokens.model.ManageTokensListBatchFlow -import com.tangem.domain.managetokens.model.ManageTokensListBatchingContext -import com.tangem.domain.managetokens.model.ManageTokensListConfig -import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.managetokens.model.* +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork import com.tangem.domain.managetokens.repository.ManageTokensRepository +import com.tangem.domain.tokens.model.Network import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.pagination.BatchFetchResult @@ -29,15 +34,18 @@ import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher import com.tangem.pagination.toBatchFlow import com.tangem.utils.coroutines.CoroutineDispatcherProvider +@Suppress("LongParameterList") internal class DefaultManageTokensRepository( private val tangemTechApi: TangemTechApi, private val userWalletsStore: UserWalletsStore, - private val managedCryptoCurrencyFactory: ManagedCryptoCurrencyFactory, private val manageTokensUpdateFetcher: ManageTokensUpdateFetcher, private val appPreferencesStore: AppPreferencesStore, private val dispatchers: CoroutineDispatcherProvider, ) : ManageTokensRepository { + private val managedCryptoCurrencyFactory = ManagedCryptoCurrencyFactory() + + // region getTokenListBatchFlow override fun getTokenListBatchFlow( context: ManageTokensListBatchingContext, batchSize: Int, @@ -58,7 +66,7 @@ internal class DefaultManageTokensRepository( prefetchDistance = batchSize, batchSize = batchSize, subFetcher = { request, _, isFirstBatchFetching -> - val userWallet = getUserWallet(request.params.userWalletId) + val userWallet = request.params.userWalletId?.let { getUserWallet(it) } val supportedBlockchains = getSupportedBlockchains(userWallet) val searchText = request.params.searchText?.takeIf { it.isNotBlank() } @@ -80,21 +88,25 @@ internal class DefaultManageTokensRepository( } else { retryOnError(call = call) } + // filter l2 coins + val updatedCoinsResponse = coinsResponse.copy( + coins = coinsResponse.coins.filterNot { l2BlockchainsCoinIds.contains(it.id) }, + ) - val tokensResponse = getStoredUserTokens(request.params.userWalletId) + val tokensResponse = request.params.userWalletId?.let { getSavedUserTokensResponseSync(it) } val items = if (isFirstBatchFetching && tokensResponse != null && userWallet != null && request.params.searchText.isNullOrBlank() ) { managedCryptoCurrencyFactory.createWithCustomTokens( - coinsResponse = coinsResponse, + coinsResponse = updatedCoinsResponse, tokensResponse = tokensResponse, derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider, ) } else { managedCryptoCurrencyFactory.create( - coinsResponse = coinsResponse, + coinsResponse = updatedCoinsResponse, tokensResponse = tokensResponse, derivationStyleProvider = userWallet?.scanResponse?.derivationStyleProvider, ) @@ -108,23 +120,9 @@ internal class DefaultManageTokensRepository( }, ) - private suspend fun getStoredUserTokens(userWalletId: UserWalletId?): UserTokensResponse? { - return if (userWalletId != null) { - appPreferencesStore.getObjectSyncOrNull( - key = PreferencesKeys.getUserTokensKey(userWalletId.stringValue), - ) - } else { - null - } - } - - private suspend fun getUserWallet(userWalletId: UserWalletId?): UserWallet? { - if (userWalletId == null) { - return null - } - + private suspend fun getUserWallet(userWalletId: UserWalletId): UserWallet { return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) { - "User wallet not found" + "Unable to find a user wallet with provided ID: $userWalletId" } } @@ -135,4 +133,114 @@ internal class DefaultManageTokensRepository( !it.isTestnet() && it.isSupportedInApp() } } + // endregion + + override suspend fun hasLinkedTokens( + userWalletId: UserWalletId, + network: Network, + tempAddedTokens: Map>, + tempRemovedTokens: Map>, + ): Boolean { + val addedTokens = tempAddedTokens.mapToResponseTokens() + val removedTokens = tempRemovedTokens.mapToResponseTokens() + + val storedTokens = requireNotNull( + value = getSavedUserTokensResponseSync(userWalletId), + lazyMessage = { "Unable to find tokens response for user wallet with provided ID: $userWalletId" }, + ) + val newTokensList = storedTokens.tokens + addedTokens - removedTokens.toSet() + + return newTokensList.any { + it.contractAddress != null && + it.networkId == network.backendId && + it.derivationPath == network.derivationPath.value + } + } + + private fun Map>.mapToResponseTokens(): List { + return flatMap { (token, networks) -> + token.availableNetworks + .filter { sourceNetwork -> networks.contains(sourceNetwork.network) } + .map { sourceNetwork -> + val blockchain = getBlockchain(sourceNetwork.network.id) + UserTokensResponse.Token( + id = token.id.value, + networkId = blockchain.toNetworkId(), + derivationPath = sourceNetwork.network.derivationPath.value, + name = token.name, + symbol = token.symbol, + decimals = sourceNetwork.decimals, + contractAddress = (sourceNetwork as? SourceNetwork.Default)?.contractAddress, + ) + } + } + } + + private suspend fun getSavedUserTokensResponseSync(key: UserWalletId): UserTokensResponse? { + return appPreferencesStore.getObjectSyncOrNull( + key = PreferencesKeys.getUserTokensKey(key.stringValue), + ) + } + + override suspend fun checkCurrencyUnsupportedState( + userWalletId: UserWalletId, + sourceNetwork: SourceNetwork, + ): CurrencyUnsupportedState? { + val userWallet = getUserWallet(userWalletId = userWalletId) + val blockchain = getBlockchain(sourceNetwork.id) + return when (sourceNetwork) { + is SourceNetwork.Default -> checkTokenUnsupportedState(userWallet = userWallet, blockchain = blockchain) + is SourceNetwork.Main -> checkBlockchainUnsupportedState(userWallet = userWallet, blockchain = blockchain) + } + } + + override suspend fun checkCurrencyUnsupportedState( + userWalletId: UserWalletId, + rawNetworkId: String, + isMainNetwork: Boolean, + ): CurrencyUnsupportedState? { + val userWallet = getUserWallet(userWalletId = userWalletId) + val blockchain = Blockchain.fromNetworkId(networkId = rawNetworkId) + ?: error("Can not create blockchain with given networkId -> $rawNetworkId") + return if (isMainNetwork) { + checkBlockchainUnsupportedState(userWallet, blockchain) + } else { + checkTokenUnsupportedState(userWallet, blockchain) + } + } + + private fun checkBlockchainUnsupportedState( + userWallet: UserWallet, + blockchain: Blockchain, + ): CurrencyUnsupportedState? { + val canHandleBlockchain = userWallet.scanResponse.card.canHandleBlockchain( + blockchain = blockchain, + cardTypesResolver = userWallet.cardTypesResolver, + ) + + return if (!canHandleBlockchain) { + CurrencyUnsupportedState.UnsupportedNetwork(networkName = blockchain.getNetworkName()) + } else { + null + } + } + + private fun checkTokenUnsupportedState( + userWallet: UserWallet, + blockchain: Blockchain, + ): CurrencyUnsupportedState.Token? { + val cardTypesResolver = userWallet.scanResponse.cardTypesResolver + val supportedTokens = userWallet.scanResponse.card.supportedTokens(cardTypesResolver) + + return when { + // refactor this later by moving all this logic in card config + blockchain == Blockchain.Solana && !supportedTokens.contains(Blockchain.Solana) -> { + CurrencyUnsupportedState.Token.NetworkTokensUnsupported(networkName = blockchain.getNetworkName()) + } + !userWallet.scanResponse.card.canHandleToken(supportedTokens, blockchain, cardTypesResolver) -> { + CurrencyUnsupportedState.Token.UnsupportedCurve(networkName = blockchain.getNetworkName()) + } + else -> null + } + } } \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt index 5cb10e4936..d5d9009ec3 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt @@ -1,12 +1,14 @@ package com.tangem.data.managetokens.di +import com.tangem.data.managetokens.DefaultCustomTokensRepository import com.tangem.data.managetokens.DefaultManageTokensRepository import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher -import com.tangem.data.managetokens.utils.ManagedCryptoCurrencyFactory import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.managetokens.repository.CustomTokensRepository import com.tangem.domain.managetokens.repository.ManageTokensRepository +import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -23,7 +25,6 @@ internal object ManageTokensDataModule { fun provideManageTokensRepository( tangemTechApi: TangemTechApi, userWalletsStore: UserWalletsStore, - managedCryptoCurrencyFactory: ManagedCryptoCurrencyFactory, manageTokensUpdateFetcher: ManageTokensUpdateFetcher, appPreferencesStore: AppPreferencesStore, dispatchers: CoroutineDispatcherProvider, @@ -31,10 +32,27 @@ internal object ManageTokensDataModule { return DefaultManageTokensRepository( tangemTechApi, userWalletsStore, - managedCryptoCurrencyFactory, manageTokensUpdateFetcher, appPreferencesStore, dispatchers, ) } + + @Provides + @Singleton + fun provideCustomTokensRepository( + tangemTechApi: TangemTechApi, + userWalletsStore: UserWalletsStore, + appPreferencesStore: AppPreferencesStore, + walletManagersFacade: WalletManagersFacade, + dispatchers: CoroutineDispatcherProvider, + ): CustomTokensRepository { + return DefaultCustomTokensRepository( + tangemTechApi, + userWalletsStore, + appPreferencesStore, + walletManagersFacade, + dispatchers, + ) + } } \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManageTokensUpdateFetcher.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManageTokensUpdateFetcher.kt index 6c23f37b0b..66bfc00bc5 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManageTokensUpdateFetcher.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManageTokensUpdateFetcher.kt @@ -31,9 +31,9 @@ internal class ManageTokensUpdateFetcher @Inject constructor() : is ManagedCryptoCurrency.Token -> { currency.copy( addedIn = if (updateRequest.isSelected) { - currency.addedIn + updateRequest.networkId + currency.addedIn + updateRequest.network } else { - currency.addedIn - updateRequest.networkId + currency.addedIn - updateRequest.network }, ) } diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt index de239a1f09..5a5b9fd6eb 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt @@ -1,6 +1,9 @@ package com.tangem.data.managetokens.utils import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.compatibility.applyL2Compatibility +import com.tangem.blockchainsdk.compatibility.getL2CompatibilityTokenComparison +import com.tangem.blockchainsdk.compatibility.l2BlockchainsList import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.isSupportedInApp import com.tangem.blockchainsdk.utils.toCoinId @@ -13,11 +16,8 @@ import com.tangem.domain.common.DerivationStyleProvider import com.tangem.domain.managetokens.model.ManagedCryptoCurrency import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork import com.tangem.domain.tokens.model.Network -import javax.inject.Inject -import javax.inject.Singleton -@Singleton -internal class ManagedCryptoCurrencyFactory @Inject constructor() { +internal class ManagedCryptoCurrencyFactory { fun create( coinsResponse: CoinsResponse, @@ -80,6 +80,7 @@ internal class ManagedCryptoCurrencyFactory @Inject constructor() { iconUrl = token.id?.let { getIconUrl(it, imageHost) }, contractAddress = contractAddress, network = network, + decimals = token.decimals, ) } } @@ -92,15 +93,16 @@ internal class ManagedCryptoCurrencyFactory @Inject constructor() { ): ManagedCryptoCurrency? { if (coinResponse.networks.isEmpty() || !coinResponse.active) return null + val updatedNetworks = coinResponse.networks.applyL2Compatibility(coinResponse.id) return ManagedCryptoCurrency.Token( id = ManagedCryptoCurrency.ID(coinResponse.id), name = coinResponse.name, symbol = coinResponse.symbol, iconUrl = getIconUrl(coinResponse.id, imageHost), - availableNetworks = coinResponse.networks.mapNotNull { network -> + availableNetworks = updatedNetworks.mapNotNull { network -> createSource(network, derivationStyleProvider) }, - addedIn = findAddedInNetworksIds(coinResponse.id, tokensResponse), + addedIn = findAddedInNetworks(coinResponse.id, tokensResponse, derivationStyleProvider), ) } @@ -119,26 +121,36 @@ internal class ManagedCryptoCurrencyFactory @Inject constructor() { return if (contractAddress.isNullOrBlank()) { SourceNetwork.Main( network = network, + decimals = blockchain.decimals(), + isL2Network = l2BlockchainsList.contains(blockchain), ) } else { SourceNetwork.Default( network = network, + decimals = requireNotNull(networkResponse.decimalCount?.toInt()), contractAddress = contractAddress, ) } } - private fun findAddedInNetworksIds(currencyId: String, tokensResponse: UserTokensResponse?): Set { + private fun findAddedInNetworks( + currencyId: String, + tokensResponse: UserTokensResponse?, + derivationStyleProvider: DerivationStyleProvider?, + ): Set { if (tokensResponse == null) return emptySet() return tokensResponse.tokens - .filter { it.id == currencyId } - .map { it.networkId } - .mapNotNullTo(mutableSetOf()) { networkId -> - val blockchain = Blockchain.fromNetworkId(networkId) + .filter { getL2CompatibilityTokenComparison(it, currencyId) } + .mapNotNullTo(mutableSetOf()) { token -> + val blockchain = Blockchain.fromNetworkId(token.networkId) if (blockchain != null && blockchain.isSupportedInApp()) { - Network.ID(blockchain.id) + getNetwork( + blockchain = blockchain, + extraDerivationPath = token.derivationPath, + derivationStyleProvider = derivationStyleProvider, + ) } else { null } diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/TokenAddressesConverter.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/TokenAddressesConverter.kt new file mode 100644 index 0000000000..9503e4a6c7 --- /dev/null +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/TokenAddressesConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.data.managetokens.utils + +import com.tangem.blockchain.blockchains.cardano.CardanoTokenAddressConverter +import com.tangem.blockchain.blockchains.hedera.HederaTokenAddressConverter +import com.tangem.blockchain.common.Blockchain +import com.tangem.data.common.currency.getBlockchain +import com.tangem.domain.tokens.model.Network + +internal class TokenAddressesConverter { + private val hederaTokenAddressConverter = HederaTokenAddressConverter() + private val cardanoTokenAddressConverter = CardanoTokenAddressConverter() + + fun convertTokenAddress(networkId: Network.ID, contractAddress: String, symbol: String?): String { + val convertedAddress = when (getBlockchain(networkId)) { + Blockchain.Hedera, + Blockchain.HederaTestnet, + -> hederaTokenAddressConverter.convertToTokenId(contractAddress) + Blockchain.Cardano -> { + // TODO: [REDACTED_JIRA] + cardanoTokenAddressConverter.convertToFingerprint(contractAddress, symbol) + } + else -> contractAddress + } + + return requireNotNull(convertedAddress) { + "Token contract address is invalid" + } + } +} \ No newline at end of file diff --git a/data/markets/build.gradle.kts b/data/markets/build.gradle.kts index 98b32e7c97..5378b4b964 100644 --- a/data/markets/build.gradle.kts +++ b/data/markets/build.gradle.kts @@ -14,10 +14,18 @@ dependencies { implementation(projects.core.datasource) implementation(projects.core.utils) implementation(projects.core.pagination) - implementation(projects.domain.tokens.models) + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + + implementation(projects.domain.legacy) implementation(projects.domain.markets) + implementation(projects.domain.models) + implementation(projects.domain.tokens.models) + implementation(projects.data.common) + implementation(projects.libs.blockchainSdk) + // region DI implementation(deps.hilt.android) kapt(deps.hilt.kapt) @@ -28,7 +36,6 @@ dependencies { implementation(deps.moshi) implementation(deps.moshi.kotlin) implementation(deps.timber) - - implementation(projects.libs.blockchainSdk) + implementation(deps.tangem.blockchain) // endregion } diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index de9edf3b4b..39637200e5 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -1,15 +1,28 @@ package com.tangem.data.markets +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.compatibility.applyL2Compatibility +import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.data.common.currency.CryptoCurrencyFactory +import com.tangem.data.common.currency.getNetwork import com.tangem.data.common.utils.retryOnError +import com.tangem.data.markets.analytics.MarketsDataAnalyticsEvent import com.tangem.data.markets.converters.TokenChartConverter import com.tangem.data.markets.converters.TokenMarketInfoConverter import com.tangem.data.markets.converters.TokenMarketListConverter import com.tangem.data.markets.converters.toRequestParam +import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.markets.TangemTechMarketsApi import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.common.util.derivationStyleProvider import com.tangem.domain.markets.* import com.tangem.domain.markets.repositories.MarketsTokenRepository +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId import com.tangem.pagination.* import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -19,7 +32,9 @@ import java.util.concurrent.atomic.AtomicLong internal class DefaultMarketsTokenRepository( private val marketsApi: TangemTechMarketsApi, private val tangemTechApi: TangemTechApi, + private val userWalletsStore: UserWalletsStore, private val dispatcherProvider: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, ) : MarketsTokenRepository { private fun createTokenMarketsFetcher(firstBatchSize: Int, nextBatchSize: Int) = LimitOffsetBatchFetcher( @@ -51,10 +66,14 @@ internal class DefaultMarketsTokenRepository( // we shouldn't infinitely retry on the first batch request val res = if (isFirstBatchFetching) { - requestCall() + catchApiErrorAndSendEvent(errorEvent = MarketsDataAnalyticsEvent.List.Error) { + requestCall() + } } else { retryOnError(priority = true) { - requestCall() + catchApiErrorAndSendEvent(errorEvent = MarketsDataAnalyticsEvent.List.Error) { + requestCall() + } } } @@ -81,6 +100,9 @@ internal class DefaultMarketsTokenRepository( val tokenMarketsUpdateFetcher = MarketsBatchUpdateFetcher( tangemTechApi = tangemTechApi, marketsApi = marketsApi, + onApiError = { + analyticsEventHandler.send(MarketsDataAnalyticsEvent.List.Error.toEvent()) + }, ) val atomicInteger = AtomicInteger(0) @@ -98,19 +120,53 @@ internal class DefaultMarketsTokenRepository( fiatCurrencyCode: String, interval: PriceChangeInterval, tokenId: String, + tokenSymbol: String, ): TokenChart { + val mappedTokenId = getTokenIdIfL2Network(tokenId) val response = marketsApi.getCoinChart( currency = fiatCurrencyCode, - coinId = tokenId, + coinId = mappedTokenId, interval = interval.toRequestParam(), ) - return TokenChartConverter.convert(interval, response.getOrThrow()) + val result = catchApiErrorAndSendEvent( + errorEvent = MarketsDataAnalyticsEvent.Details.Error( + request = MarketsDataAnalyticsEvent.Details.Error.Request.Chart, + tokenSymbol = tokenSymbol, + ), + ) { + response.getOrThrow() + } + + return TokenChartConverter.convert(interval, result) + } + + override suspend fun getChartPreview( + fiatCurrencyCode: String, + interval: PriceChangeInterval, + tokenId: String, + tokenSymbol: String, + ): TokenChart { + val mappedTokenId = getTokenIdIfL2Network(tokenId) + val response = marketsApi.getCoinsListCharts( + coinIds = mappedTokenId, + currency = fiatCurrencyCode, + interval = interval.toRequestParam(), + ) + + val chart = catchApiErrorAndSendEvent(errorEvent = MarketsDataAnalyticsEvent.List.Error) { + response.getOrThrow()[mappedTokenId] ?: error( + "No chart preview data for the token $mappedTokenId", + ) + } + + return TokenChartConverter.convert(interval, chart) } override suspend fun getTokenInfo( fiatCurrencyCode: String, tokenId: String, + tokenSymbol: String, languageCode: String, ): TokenMarketInfo { val response = marketsApi.getCoinMarketData( @@ -119,11 +175,22 @@ internal class DefaultMarketsTokenRepository( language = languageCode, ) - return TokenMarketInfoConverter.convert(response.getOrThrow()) + val result = catchApiErrorAndSendEvent( + errorEvent = MarketsDataAnalyticsEvent.Details.Error( + request = MarketsDataAnalyticsEvent.Details.Error.Request.Info, + tokenSymbol = tokenSymbol, + ), + ) { + response.getOrThrow() + } + + val resultResponse = result.applyL2Compatibility(tokenId) + return TokenMarketInfoConverter.convert(resultResponse) } override suspend fun getTokenQuotes(fiatCurrencyCode: String, tokenId: String): TokenQuotes { // TODO change method when backend is ready + // add error analytics event val response = marketsApi.getCoinMarketData( currency = fiatCurrencyCode, coinId = tokenId, @@ -132,4 +199,48 @@ internal class DefaultMarketsTokenRepository( return TokenMarketInfoConverter.convert(response.getOrThrow()).quotes } + + override suspend fun createCryptoCurrency( + userWalletId: UserWalletId, + token: TokenMarketParams, + network: TokenMarketInfo.Network, + ): CryptoCurrency? { + val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("UserWalletId [$userWalletId] not found") + val blockchain = Blockchain.fromNetworkId(network.networkId) ?: error("Unknown network [${network.networkId}]") + + return if (network.contractAddress == null) { + CryptoCurrencyFactory().createCoin( + blockchain = blockchain, + extraDerivationPath = null, + derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider, + ) + } else { + val currencyNetwork = getNetwork( + blockchain = blockchain, + extraDerivationPath = null, + derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider, + ) ?: return null + + CryptoCurrencyFactory().createToken( + network = currencyNetwork, + rawId = token.id, + name = token.name, + symbol = token.symbol, + decimals = network.decimalCount ?: error("Unknown decimal"), + contractAddress = network.contractAddress!!, + ) + } + } + + private inline fun catchApiErrorAndSendEvent(errorEvent: MarketsDataAnalyticsEvent, block: () -> T): T { + return try { + block() + } catch (e: ApiResponseError.HttpException) { + analyticsEventHandler.send(errorEvent.toEvent()) + throw e + } catch (e: ApiResponseError.TimeoutException) { + analyticsEventHandler.send(errorEvent.toEvent()) + throw e + } + } } \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt b/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt index 3a377ab901..95fb2cca25 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt @@ -4,6 +4,7 @@ import com.tangem.data.common.utils.retryOnError import com.tangem.data.markets.converters.TokenMarketChartsConverter import com.tangem.data.markets.converters.TokenQuotesShortConverter import com.tangem.data.markets.converters.toRequestParam +import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.markets.TangemTechMarketsApi import com.tangem.datasource.api.markets.models.response.TokenMarketChartListResponse @@ -20,6 +21,7 @@ import kotlinx.coroutines.launch internal class MarketsBatchUpdateFetcher( private val marketsApi: TangemTechMarketsApi, private val tangemTechApi: TangemTechApi, + private val onApiError: () -> Unit, ) : BatchUpdateFetcher, TokenMarketUpdateRequest> { override suspend fun BatchUpdateFetcher.UpdateContext>.fetchUpdateAsync( @@ -35,11 +37,13 @@ internal class MarketsBatchUpdateFetcher( val updateTasks = idsToUpdate.map { batchIds -> async { retryOnError { - marketsApi.getCoinsListCharts( - coinIds = batchIds.second.joinToString(separator = ","), - interval = updateRequest.interval.toRequestParam(), - currency = updateRequest.currency, - ).getOrThrow() + catchApiError(onApiError) { + marketsApi.getCoinsListCharts( + coinIds = batchIds.second.joinToString(separator = ","), + interval = updateRequest.interval.toRequestParam(), + currency = updateRequest.currency, + ).getOrThrow() + } } } } @@ -62,11 +66,13 @@ internal class MarketsBatchUpdateFetcher( } is TokenMarketUpdateRequest.UpdateQuotes -> { val quotesRes = retryOnError { - tangemTechApi.getQuotes( - currencyId = updateRequest.currencyId, - coinIds = idsToUpdate.map { it.second }.flatten().joinToString(separator = ","), - fields = quoteFields.joinToString(separator = ","), - ).getOrThrow() + catchApiError(onApiError) { + tangemTechApi.getQuotes( + currencyId = updateRequest.currencyId, + coinIds = idsToUpdate.map { it.second }.flatten().joinToString(separator = ","), + fields = quoteFields.joinToString(separator = ","), + ).getOrThrow() + } } update { @@ -106,6 +112,15 @@ internal class MarketsBatchUpdateFetcher( ) } + private inline fun catchApiError(onError: () -> Unit, block: () -> T): T { + return try { + block() + } catch (e: ApiResponseError) { + onError() + throw e + } + } + companion object { private val quoteFields = listOf( "price", diff --git a/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt b/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt new file mode 100644 index 0000000000..b58f04cb33 --- /dev/null +++ b/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt @@ -0,0 +1,42 @@ +package com.tangem.data.markets.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent + +sealed interface MarketsDataAnalyticsEvent { + + sealed class List( + event: String, + params: Map = mapOf(), + ) : AnalyticsEvent(category = "Markets", event = event, params = params), MarketsDataAnalyticsEvent { + + data object Error : List(event = "Data Error") + } + + sealed class Details( + event: String, + params: Map = mapOf(), + ) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params), MarketsDataAnalyticsEvent { + + data class Error( + val request: Request, + val tokenSymbol: String, + ) : Details( + event = "Data Error", + params = mapOf( + "Source" to request.source, + "Token" to tokenSymbol, + ), + ) { + + enum class Request(val source: String) { + Chart("Chart"), + Info("Blocks"), + } + } + } + + fun toEvent(): AnalyticsEvent = when (this) { + is List -> this + is Details -> this + } +} \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt index e9314b0d7e..535b4aca6c 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt @@ -1,9 +1,13 @@ package com.tangem.data.markets.converters +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.blockchainsdk.utils.isSupportedInApp import com.tangem.datasource.api.markets.models.response.TokenMarketInfoResponse import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenQuotes import com.tangem.utils.converter.Converter +import java.math.BigDecimal internal object TokenMarketInfoConverter : Converter { @@ -28,25 +32,45 @@ internal object TokenMarketInfoConverter : Converter BigDecimal?, + ): BigDecimal? { + return (intervalProvider() ?: allTime)?.movePointLeft(2) + } + @JvmName("convertNetwork") private fun List.convert(): List { - return map { - TokenMarketInfo.Network( - networkId = it.networkId, - exchangeable = it.exchangeable, - contractAddress = it.contractAddress, - decimalCount = it.decimalCount, - ) + return mapNotNull { network -> + val blockchain = Blockchain.fromNetworkId(network.networkId) + when { + network.contractAddress.isNullOrEmpty() -> { + TokenMarketInfo.Network( + networkId = network.networkId, + exchangeable = network.exchangeable, + contractAddress = network.contractAddress, + decimalCount = network.decimalCount, + ) + } + blockchain != null && blockchain.isSupportedInApp() && blockchain.canHandleTokens() -> { + TokenMarketInfo.Network( + networkId = network.networkId, + exchangeable = network.exchangeable, + contractAddress = network.contractAddress, + decimalCount = network.decimalCount, + ) + } + else -> null + } } } @@ -57,6 +81,7 @@ internal object TokenMarketInfoConverter : Converter(), ) + private val tronStakeKitTransactionAdapter by lazy { moshi.adapter(TronStakeKitTransaction::class.java) } + + override fun getIntegrationKey(cryptoCurrencyId: CryptoCurrency.ID): String = with(cryptoCurrencyId) { + rawNetworkId.plus(rawCurrencyId) + } + override fun isStakingSupported(integrationKey: String): Boolean { return integrationIdMap.containsKey(integrationKey) } @@ -114,7 +121,9 @@ internal class DefaultStakingRepository( key = YIELDS_STORE_KEY, skipCache = refresh, block = { - val stakingTokensWithYields = stakeKitApi.getMultipleYields().getOrThrow() + val stakingTokensWithYields = stakeKitApi.getMultipleYields(preferredValidatorsOnly = true) + .getOrThrow() + stakingYieldsStore.store(stakingTokensWithYields.data) }, ) @@ -141,24 +150,26 @@ internal class DefaultStakingRepository( val yield = getYield(cryptoCurrencyId, symbol) StakingEntryInfo( - interestRate = requireNotNull(yield.validators.maxByOrNull { it.apr.orZero() }?.apr), - periodInDays = yield.metadata.cooldownPeriod.days, + apr = requireNotNull(yield.validators.maxByOrNull { it.apr.orZero() }?.apr), + rewardSchedule = yield.metadata.rewardSchedule, tokenSymbol = yield.token.symbol, ) } } - override suspend fun getStakingAvailabilityForActions( - cryptoCurrencyId: CryptoCurrency.ID, - symbol: String, + override suspend fun getStakingAvailability( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, ): StakingAvailability { - val rawCurrencyId = cryptoCurrencyId.rawCurrencyId ?: return StakingAvailability.Unavailable + if (checkForInvalidCardBatch(userWalletId, cryptoCurrency)) return StakingAvailability.Unavailable + + val rawCurrencyId = cryptoCurrency.id.rawCurrencyId ?: return StakingAvailability.Unavailable return withContext(dispatchers.io) { val yields = getEnabledYields() ?: return@withContext StakingAvailability.Unavailable - val prefetchedYield = findPrefetchedYield(yields, rawCurrencyId, symbol) - val isSupported = isStakingSupported(cryptoCurrencyId.getIntegrationKey()) + val prefetchedYield = findPrefetchedYield(yields, rawCurrencyId, cryptoCurrency.symbol) + val isSupported = isStakingSupported(getIntegrationKey(cryptoCurrency.id)) when { prefetchedYield != null && isSupported -> { @@ -172,6 +183,21 @@ internal class DefaultStakingRepository( } } + private fun checkForInvalidCardBatch(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { + val userWallet = getUserWalletUseCase(userWalletId).getOrElse { + error("Failed to get user wallet") + } + + return when { + isSolana(cryptoCurrency.network.id.value) -> { + INVALID_BATCHES_FOR_SOLANA.contains(userWallet.scanResponse.card.batchId) + } + else -> { + false + } + } + } + override suspend fun createAction( userWalletId: UserWalletId, network: Network, @@ -261,29 +287,33 @@ internal class DefaultStakingRepository( override suspend fun fetchSingleYieldBalance( userWalletId: UserWalletId, - address: CryptoCurrencyAddress, + cryptoCurrency: CryptoCurrency, refresh: Boolean, ) = withContext(dispatchers.io) { if (!stakingFeatureToggle.isStakingEnabled) return@withContext - val cryptoCurrency = address.cryptoCurrency - val integrationId = integrationIdMap[cryptoCurrency.id.getIntegrationKey()] ?: return@withContext + val integrationId = integrationIdMap[getIntegrationKey(cryptoCurrency.id)] ?: return@withContext + + val address = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network).orEmpty() cacheRegistry.invokeOnExpire( key = getYieldBalancesKey(userWalletId), skipCache = refresh, block = { - val requestBody = getBalanceRequestData(address.address, integrationId) + val requestBody = getBalanceRequestData(address, integrationId) val result = stakeKitApi.getSingleYieldBalance( integrationId = requestBody.integrationId, body = requestBody, ).getOrThrow() stakingBalanceStore.store( + userWalletId, requestBody.integrationId, + address, YieldBalanceWrapperDTO( balances = result, integrationId = requestBody.integrationId, + addresses = requestBody.addresses, ), ) }, @@ -292,31 +322,25 @@ internal class DefaultStakingRepository( override fun getSingleYieldBalanceFlow( userWalletId: UserWalletId, - address: CryptoCurrencyAddress, + cryptoCurrency: CryptoCurrency, ): Flow = channelFlow { if (!stakingFeatureToggle.isStakingEnabled) { send(YieldBalance.Empty) } else { launch(dispatchers.io) { - val integrationId = integrationIdMap[address.cryptoCurrency.id.getIntegrationKey()] + val address = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network).orEmpty() + val integrationId = integrationIdMap[getIntegrationKey(cryptoCurrency.id)] ?: error("Could not get integrationId") - stakingBalanceStore.get(integrationId) + stakingBalanceStore.get(userWalletId, address, integrationId) .collectLatest { - send( - yieldBalanceConverter.convert( - YieldBalanceConverter.Data( - balance = it, - integrationId = integrationId, - ), - ), - ) + send(yieldBalanceConverter.convert(it)) } } withContext(dispatchers.io) { fetchSingleYieldBalance( userWalletId, - address, + cryptoCurrency, ) } } @@ -324,28 +348,28 @@ internal class DefaultStakingRepository( override suspend fun getSingleYieldBalanceSync( userWalletId: UserWalletId, - address: CryptoCurrencyAddress, + cryptoCurrency: CryptoCurrency, ): YieldBalance = withContext(dispatchers.io) { if (!stakingFeatureToggle.isStakingEnabled) { YieldBalance.Empty } else { - fetchSingleYieldBalance(userWalletId, address) + fetchSingleYieldBalance(userWalletId, cryptoCurrency) - val integrationId = integrationIdMap[address.cryptoCurrency.id.getIntegrationKey()] + val address = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network).orEmpty() + + val integrationId = integrationIdMap[getIntegrationKey(cryptoCurrency.id)] ?: error("Could not get integrationId") - val result = stakingBalanceStore.getSyncOrNull(integrationId) ?: return@withContext YieldBalance.Error - yieldBalanceConverter.convert( - YieldBalanceConverter.Data( - balance = result, - integrationId = integrationId, - ), - ) + + val result = stakingBalanceStore.getSyncOrNull(userWalletId, address, integrationId) + ?: return@withContext YieldBalance.Error + + yieldBalanceConverter.convert(result) } } override suspend fun fetchMultiYieldBalance( userWalletId: UserWalletId, - addresses: List, + cryptoCurrencies: List, refresh: Boolean, ) = withContext(dispatchers.io) { if (!stakingFeatureToggle.isStakingEnabled) return@withContext @@ -357,23 +381,25 @@ internal class DefaultStakingRepository( key = getYieldBalancesKey(userWalletId), skipCache = refresh, block = { - val result = stakeKitApi.getMultipleYieldBalances( - addresses - .mapNotNull { networkAddress -> - val cryptoCurrency = networkAddress.cryptoCurrency - val integrationId = integrationIdMap[cryptoCurrency.id.getIntegrationKey()] + val availableCurrencies = cryptoCurrencies + .mapNotNull { currency -> + val addresses = walletManagersFacade.getAddresses(userWalletId, currency.network) + val integrationId = integrationIdMap[getIntegrationKey(currency.id)] - if (integrationId != null) { - networkAddress.address to integrationId - } else { - null - } + if (integrationId != null) { + addresses to integrationId + } else { + null } - .distinct() - .map { getBalanceRequestData(it.first, it.second) }, - ).getOrThrow() + } + .flatMap { (addresses, integrationId) -> + addresses.map { address -> address to integrationId } + } + .map { getBalanceRequestData(it.first.value, it.second) } + .ifEmpty { return@invokeOnExpire } + val result = stakeKitApi.getMultipleYieldBalances(availableCurrencies).getOrThrow() - stakingBalanceStore.store(result) + stakingBalanceStore.store(userWalletId, result) }, ) } finally { @@ -385,20 +411,20 @@ internal class DefaultStakingRepository( override fun getMultiYieldBalanceFlow( userWalletId: UserWalletId, - addresses: List, + cryptoCurrencies: List, ): Flow = channelFlow { if (!stakingFeatureToggle.isStakingEnabled) { send(YieldBalanceList.Empty) } else { launch(dispatchers.io) { - stakingBalanceStore.get() + stakingBalanceStore.get(userWalletId) .collectLatest { send(yieldBalanceListConverter.convert(it)) } } withContext(dispatchers.io) { fetchMultiYieldBalance( userWalletId, - addresses, + cryptoCurrencies, ) } } @@ -406,14 +432,14 @@ internal class DefaultStakingRepository( override fun getMultiYieldBalanceLce( userWalletId: UserWalletId, - addresses: List, + cryptoCurrencies: List, ): LceFlow = lceFlow { if (!stakingFeatureToggle.isStakingEnabled) { send(YieldBalanceList.Empty) } else { launch(dispatchers.io) { combine( - stakingBalanceStore.get(), + stakingBalanceStore.get(userWalletId), isYieldBalanceFetching.map { it.getOrElse(userWalletId) { false } }, ) { result, isFetching -> val balances = yieldBalanceListConverter.convert(result) @@ -422,7 +448,7 @@ internal class DefaultStakingRepository( } withContext(dispatchers.io) { catch( - block = { fetchMultiYieldBalance(userWalletId, addresses, refresh = false) }, + block = { fetchMultiYieldBalance(userWalletId, cryptoCurrencies, refresh = false) }, catch = { raise(it) }, ) } @@ -431,63 +457,24 @@ internal class DefaultStakingRepository( override suspend fun getMultiYieldBalanceSync( userWalletId: UserWalletId, - addresses: List, + cryptoCurrencies: List, ): YieldBalanceList = withContext(dispatchers.io) { if (!stakingFeatureToggle.isStakingEnabled) { YieldBalanceList.Empty } else { - fetchMultiYieldBalance(userWalletId, addresses) - val result = stakingBalanceStore.getSyncOrNull() ?: return@withContext YieldBalanceList.Error + fetchMultiYieldBalance(userWalletId, cryptoCurrencies) + val result = stakingBalanceStore.getSyncOrNull(userWalletId) ?: return@withContext YieldBalanceList.Error yieldBalanceListConverter.convert(result) } } - override suspend fun submitHash(transactionId: String, transactionHash: String) { - withContext(dispatchers.io) { - stakeKitApi.submitTransactionHash( - transactionId = transactionId, - body = SubmitTransactionHashRequestBody( - hash = transactionHash, - ), - ) - } - } - - override suspend fun storeUnsubmittedHash(unsubmittedTransactionMetadata: UnsubmittedTransactionMetadata) { - withContext(dispatchers.io) { - appPreferencesStore.editData { preferences -> - val savedTransactions = preferences.getObjectListOrDefault( - key = PreferencesKeys.UNSUBMITTED_TRANSACTIONS_KEY, - default = emptyList(), - ) - - preferences.setObjectList( - key = PreferencesKeys.UNSUBMITTED_TRANSACTIONS_KEY, - value = savedTransactions + unsubmittedTransactionMetadata, - ) - } - } - } - - override suspend fun sendUnsubmittedHashes() { - withContext(NonCancellable) { - val savedTransactions = appPreferencesStore.getObjectListSync( - key = PreferencesKeys.UNSUBMITTED_TRANSACTIONS_KEY, - ) - - savedTransactions.forEach { - stakeKitApi.submitTransactionHash( - transactionId = it.transactionId, - body = SubmitTransactionHashRequestBody(hash = it.transactionHash), - ) - } - - appPreferencesStore.editData { mutablePreferences -> - mutablePreferences.setObjectList( - key = PreferencesKeys.UNSUBMITTED_TRANSACTIONS_KEY, - value = emptyList(), - ) - } + override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean { + return withContext(dispatchers.io) { + stakingBalanceStore.getSyncOrNull(userWalletId) + ?.let { + it.isNotEmpty() && it.any { yieldBalance -> yieldBalance.balances.isNotEmpty() } + } + ?: false } } @@ -506,6 +493,8 @@ internal class DefaultStakingRepository( amount = params.amount.toPlainString(), inputToken = tokenConverter.convertBack(params.token), validatorAddress = params.validatorAddress, + validatorAddresses = listOf(params.validatorAddress), // check on other networks + tronResource = getTronResource(network), ), ) } @@ -518,6 +507,7 @@ internal class DefaultStakingRepository( args = ActionRequestBodyArgs( amount = params.amount.toPlainString(), validatorAddress = params.validatorAddress, + validatorAddresses = listOf(params.validatorAddress), ), ) } @@ -539,16 +529,8 @@ internal class DefaultStakingRepository( } } - override fun isStakeMoreAvailable(networkId: Network.ID): Boolean { - val blockchain = Blockchain.fromId(networkId.value) - return when (blockchain) { - Blockchain.Solana -> false - else -> true - } - } - override fun getStakingApproval(cryptoCurrency: CryptoCurrency): StakingApproval { - return when (cryptoCurrency.id.getIntegrationKey()) { + return when (getIntegrationKey(cryptoCurrency.id)) { Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() -> { StakingApproval.Needed(ETHEREUM_POLYGON_APPROVE_SPENDER) } @@ -562,8 +544,14 @@ internal class DefaultStakingRepository( Blockchain.Solana, Blockchain.Cosmos, -> TransactionData.Compiled.Data.Bytes(unsignedTransaction.hexToBytes()) + Blockchain.BSC, Blockchain.Ethereum, -> TransactionData.Compiled.Data.RawString(unsignedTransaction) + Blockchain.Tron -> { + val tronStakeKitTransaction = tronStakeKitTransactionAdapter.fromJson(unsignedTransaction) + ?: error("Failed to parse Tron StakeKit transaction") + TransactionData.Compiled.Data.RawString(tronStakeKitTransaction.rawDataHex) + } else -> error("Unsupported blockchain") } } @@ -593,7 +581,15 @@ internal class DefaultStakingRepository( private fun getYieldBalancesKey(userWalletId: UserWalletId) = "yield_balance_${userWalletId.stringValue}" - private fun CryptoCurrency.ID.getIntegrationKey(): String = rawNetworkId.plus(rawCurrencyId) + private fun getTronResource(network: Network): TronResource? { + val blockchain = Blockchain.fromNetworkId(network.backendId) + + return if (blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet) { + TronResource.ENERGY + } else { + null + } + } private companion object { const val YIELDS_STORE_KEY = "yields" @@ -601,27 +597,29 @@ internal class DefaultStakingRepository( const val SOLANA_INTEGRATION_ID = "solana-sol-native-multivalidator-staking" const val COSMOS_INTEGRATION_ID = "cosmos-atom-native-staking" const val ETHEREUM_POLYGON_INTEGRATION_ID = "ethereum-matic-native-staking" + const val BINANCE_INTEGRATION_ID = "bsc-bnb-native-staking" const val POLKADOT_INTEGRATION_ID = "polkadot-dot-validator-staking" const val AVALANCHE_INTEGRATION_ID = "avalanche-avax-native-staking" const val TRON_INTEGRATION_ID = "tron-trx-native-staking" const val CRONOS_INTEGRATION_ID = "cronos-cro-native-staking" - const val BINANCE_INTEGRATION_ID = "bsc-bnb-native-staking" const val KAVA_INTEGRATION_ID = "kava-kava-native-staking" const val NEAR_INTEGRATION_ID = "near-near-native-staking" const val TEZOS_INTEGRATION_ID = "tezos-xtz-native-staking" const val ETHEREUM_POLYGON_APPROVE_SPENDER = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + val INVALID_BATCHES_FOR_SOLANA = listOf("AC01", "CB79") + // uncomment items as implementation is ready val integrationIdMap = mapOf( Blockchain.Solana.run { id + toCoinId() } to SOLANA_INTEGRATION_ID, Blockchain.Cosmos.run { id + toCoinId() } to COSMOS_INTEGRATION_ID, - Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID, + Blockchain.Tron.run { id + toCoinId() } to TRON_INTEGRATION_ID, + // Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID, + // Blockchain.BSC.run { id + toCoinId() } to BINANCE_INTEGRATION_ID, // Blockchain.Polkadot.run { id + toCoinId() } to POLKADOT_INTEGRATION_ID, // Blockchain.Avalanche.run { id + toCoinId() } to AVALANCHE_INTEGRATION_ID, - // Blockchain.Tron.run { id + toCoinId() } to TRON_INTEGRATION_ID, // Blockchain.Cronos.run { id + toCoinId() } to CRONOS_INTEGRATION_ID, - // Blockchain.BSC.run { id + toCoinId() } to BINANCE_INTEGRATION_ID, // Blockchain.Kava.run { id + toCoinId() } to KAVA_INTEGRATION_ID, // Blockchain.Near.run { id + toCoinId() } to NEAR_INTEGRATION_ID, // Blockchain.Tezos.run { id + toCoinId() } to TEZOS_INTEGRATION_ID, diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingTransactionHashRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingTransactionHashRepository.kt new file mode 100644 index 0000000000..0d12489648 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingTransactionHashRepository.kt @@ -0,0 +1,83 @@ +package com.tangem.data.staking + +import com.tangem.datasource.api.stakekit.StakeKitApi +import com.tangem.datasource.api.stakekit.models.request.* +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObjectListSync +import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata +import com.tangem.domain.staking.repositories.StakingTransactionHashRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import timber.log.Timber + +internal class DefaultStakingTransactionHashRepository( + private val stakeKitApi: StakeKitApi, + private val appPreferencesStore: AppPreferencesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : StakingTransactionHashRepository { + + override suspend fun submitHash(transactionId: String, transactionHash: String) { + withContext(dispatchers.io) { + stakeKitApi.submitTransactionHash( + transactionId = transactionId, + body = SubmitTransactionHashRequestBody( + hash = transactionHash, + ), + ) + } + } + + override suspend fun storeUnsubmittedHash(unsubmittedTransactionMetadata: UnsubmittedTransactionMetadata) { + withContext(dispatchers.io) { + appPreferencesStore.editData { preferences -> + val savedTransactions = preferences.getObjectListOrDefault( + key = PreferencesKeys.UNSUBMITTED_TRANSACTIONS_KEY, + default = emptyList(), + ) + + preferences.setObjectList( + key = PreferencesKeys.UNSUBMITTED_TRANSACTIONS_KEY, + value = savedTransactions + unsubmittedTransactionMetadata, + ) + } + } + } + + override suspend fun sendUnsubmittedHashes() { + withContext(dispatchers.io) { + val savedTransactions = appPreferencesStore.getObjectListSync( + key = PreferencesKeys.UNSUBMITTED_TRANSACTIONS_KEY, + ) + + savedTransactions.forEach { transaction -> + try { + stakeKitApi.submitTransactionHash( + transactionId = transaction.transactionId, + body = SubmitTransactionHashRequestBody(hash = transaction.transactionHash), + ) + + appPreferencesStore.editData { mutablePreferences -> + val updatedTransactions = + mutablePreferences.getObjectListOrDefault( + key = PreferencesKeys.UNSUBMITTED_TRANSACTIONS_KEY, + default = emptyList(), + ).filterNot { it.transactionId == transaction.transactionId } + + mutablePreferences.setObjectList( + key = PreferencesKeys.UNSUBMITTED_TRANSACTIONS_KEY, + value = updatedTransactions, + ) + } + } catch (e: Exception) { + val logMessage = buildString { + append("Error while submitting transaction with\n") + append("StakeKit id = ${transaction.transactionId} and\n") + append("transaction hash = ${transaction.transactionHash}") + } + Timber.e(logMessage) + } + } + } + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceConverter.kt index b1c9d7d47c..90b35354b7 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceConverter.kt @@ -1,32 +1,37 @@ package com.tangem.data.staking.converters import com.tangem.data.staking.converters.action.PendingActionConverter -import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO -import com.tangem.domain.staking.model.stakekit.BalanceItem -import com.tangem.domain.staking.model.stakekit.BalanceType -import com.tangem.domain.staking.model.stakekit.YieldBalance -import com.tangem.domain.staking.model.stakekit.YieldBalanceItem +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.domain.staking.model.stakekit.* +import com.tangem.domain.staking.model.stakekit.action.StakingActionType import com.tangem.utils.converter.Converter -internal class YieldBalanceConverter : Converter { +internal class YieldBalanceConverter : Converter { private val pendingActionConverter by lazy(LazyThreadSafetyMode.NONE) { PendingActionConverter() } - override fun convert(value: Data): YieldBalance { - return if (value.balance.isEmpty()) { + override fun convert(value: YieldBalanceWrapperDTO): YieldBalance { + return if (value.balances.isEmpty()) { YieldBalance.Empty } else { YieldBalance.Data( + address = value.addresses.address, balance = YieldBalanceItem( - items = value.balance.map { item -> + items = value.balances.map { item -> BalanceItem( + id = item.groupId, type = BalanceType.valueOf(item.type.name), amount = item.amount, pricePerShare = item.pricePerShare, rawCurrencyId = item.tokenDTO.coinGeckoId, rawNetworkId = item.tokenDTO.network.name, - validatorAddress = item.validatorAddress, - pendingActions = pendingActionConverter.convertList(item.pendingActions), + // tron-specific. operates validatorAddresses instead of validatorAddress + validatorAddress = item.validatorAddress ?: item.validatorAddresses?.get(0), + date = item.date?.toDateTime(), + pendingActions = pendingActionConverter + .convertList(item.pendingActions) + // temporarily exclude VOTE_LOCKED, it will be implemented in future iterations + .filterNot { it.type == StakingActionType.VOTE_LOCKED }, ) }, integrationId = value.integrationId, @@ -34,9 +39,4 @@ internal class YieldBalanceConverter : Converter, - val integrationId: String?, - ) } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceListConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceListConverter.kt index 0476368b30..6a3f3c793a 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceListConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceListConverter.kt @@ -4,25 +4,18 @@ import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrap import com.tangem.domain.staking.model.stakekit.YieldBalanceList import com.tangem.utils.converter.Converter -internal class YieldBalanceListConverter : Converter, YieldBalanceList> { +internal class YieldBalanceListConverter : Converter, YieldBalanceList> { internal val converter by lazy(LazyThreadSafetyMode.NONE) { YieldBalanceConverter() } - override fun convert(value: List): YieldBalanceList { + override fun convert(value: Set): YieldBalanceList { return if (value.isEmpty()) { YieldBalanceList.Empty } else { YieldBalanceList.Data( - balances = value.map { - converter.convert( - YieldBalanceConverter.Data( - balance = it.balances, - integrationId = it.integrationId, - ), - ) - }, + balances = value.map(converter::convert), ) } } diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt index e383d63f49..c08359c098 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt @@ -3,10 +3,13 @@ package com.tangem.data.staking.converters import com.tangem.datasource.api.stakekit.models.response.model.AddressArgumentDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardScheduleDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.ValidatorDTO.ValidatorStatusDTO import com.tangem.domain.staking.model.stakekit.AddressArgument import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.Yield.Metadata.RewardSchedule +import com.tangem.domain.staking.model.stakekit.Yield.Validator.ValidatorStatus import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList class YieldConverter( private val tokenConverter: TokenConverter, @@ -24,9 +27,11 @@ class YieldConverter( rewardType = convertRewardType(value.rewardType), metadata = convertMetadata(value.metadata), validators = value.validators - .filter { it.preferred } + .asSequence() + .filter { it.preferred && it.status == ValidatorStatusDTO.ACTIVE } + .sortedByDescending { it.apr } .map { convertValidator(it) } - .sortedByDescending { it.apr }, + .toImmutableList(), isAvailable = value.isAvailable, ) } @@ -109,7 +114,7 @@ class YieldConverter( private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO): Yield.Validator { return Yield.Validator( address = validatorDTO.address, - status = validatorDTO.status, + status = convertValidatorStatus(validatorDTO.status), name = validatorDTO.name, image = validatorDTO.image, website = validatorDTO.website, @@ -129,6 +134,16 @@ class YieldConverter( } } + private fun convertValidatorStatus(validatorStatusDTO: ValidatorStatusDTO): ValidatorStatus { + return when (validatorStatusDTO) { + ValidatorStatusDTO.ACTIVE -> ValidatorStatus.ACTIVE + ValidatorStatusDTO.DEACTIVATING -> ValidatorStatus.DEACTIVATING + ValidatorStatusDTO.INACTIVE -> ValidatorStatus.INACTIVE + ValidatorStatusDTO.JAILED -> ValidatorStatus.JAILED + else -> ValidatorStatus.UNKNOWN + } + } + private fun convertRewardSchedule(rewardTypeDTO: RewardScheduleDTO): RewardSchedule { return when (rewardTypeDTO) { RewardScheduleDTO.BLOCK -> RewardSchedule.BLOCK 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 d261cdece4..cb8d518895 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 @@ -4,6 +4,7 @@ import com.squareup.moshi.Moshi import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.staking.DefaultStakingErrorResolver import com.tangem.data.staking.DefaultStakingRepository +import com.tangem.data.staking.DefaultStakingTransactionHashRepository import com.tangem.data.staking.converters.error.StakeKitErrorConverter import com.tangem.datasource.api.stakekit.StakeKitApi import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorResponse @@ -13,7 +14,9 @@ import com.tangem.datasource.local.token.StakingBalanceStore import com.tangem.datasource.local.token.StakingYieldsStore 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.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.staking.api.featuretoggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -30,23 +33,39 @@ internal object StakingDataModule { @Singleton fun provideStakingRepository( stakeKitApi: StakeKitApi, - appPreferencesStore: AppPreferencesStore, - stakingTokenStore: StakingYieldsStore, + stakingYieldsStore: StakingYieldsStore, stakingBalanceStore: StakingBalanceStore, + cacheRegistry: CacheRegistry, dispatchers: CoroutineDispatcherProvider, stakingFeatureToggle: StakingFeatureToggles, - cacheRegistry: CacheRegistry, walletManagersFacade: WalletManagersFacade, + getUserWalletUseCase: GetUserWalletUseCase, + @NetworkMoshi moshi: Moshi, ): StakingRepository { return DefaultStakingRepository( stakeKitApi = stakeKitApi, - appPreferencesStore = appPreferencesStore, - stakingYieldsStore = stakingTokenStore, + stakingYieldsStore = stakingYieldsStore, stakingBalanceStore = stakingBalanceStore, - dispatchers = dispatchers, cacheRegistry = cacheRegistry, + dispatchers = dispatchers, stakingFeatureToggle = stakingFeatureToggle, walletManagersFacade = walletManagersFacade, + getUserWalletUseCase = getUserWalletUseCase, + moshi = moshi, + ) + } + + @Provides + @Singleton + fun StakingTransactionHashRepository( + stakeKitApi: StakeKitApi, + appPreferencesStore: AppPreferencesStore, + dispatchers: CoroutineDispatcherProvider, + ): StakingTransactionHashRepository { + return DefaultStakingTransactionHashRepository( + stakeKitApi = stakeKitApi, + appPreferencesStore = appPreferencesStore, + dispatchers = dispatchers, ) } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt index 59ddeeb01d..c37a3bc87f 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt @@ -8,7 +8,6 @@ import com.tangem.datasource.local.network.NetworksStatusesStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.quote.QuotesStore import com.tangem.datasource.local.token.ExpressAssetsStore -import com.tangem.datasource.local.token.UserTokensStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.tokens.repository.* import com.tangem.domain.walletmanager.WalletManagersFacade @@ -28,7 +27,7 @@ internal object TokensDataModule { fun provideCurrenciesRepository( tangemTechApi: TangemTechApi, tangemExpressApi: TangemExpressApi, - userTokensStore: UserTokensStore, + appPreferencesStore: AppPreferencesStore, userWalletsStore: UserWalletsStore, walletManagersFacade: WalletManagersFacade, expressAssetsStore: ExpressAssetsStore, @@ -38,7 +37,7 @@ internal object TokensDataModule { return DefaultCurrenciesRepository( tangemTechApi = tangemTechApi, tangemExpressApi = tangemExpressApi, - userTokensStore = userTokensStore, + appPreferencesStore = appPreferencesStore, walletManagersFacade = walletManagersFacade, userWalletsStore = userWalletsStore, expressAssetsStore = expressAssetsStore, @@ -71,7 +70,7 @@ internal object TokensDataModule { networksStatusesStore: NetworksStatusesStore, walletManagersFacade: WalletManagersFacade, userWalletsStore: UserWalletsStore, - userTokensStore: UserTokensStore, + appPreferencesStore: AppPreferencesStore, cacheRegistry: CacheRegistry, dispatchers: CoroutineDispatcherProvider, ): NetworksRepository { @@ -79,7 +78,7 @@ internal object TokensDataModule { networksStatusesStore = networksStatusesStore, walletManagersFacade = walletManagersFacade, userWalletsStore = userWalletsStore, - userTokensStore = userTokensStore, + appPreferencesStore = appPreferencesStore, cacheRegistry = cacheRegistry, dispatchers = dispatchers, ) 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 ec3720fe1b..f76a9353fb 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 @@ -2,6 +2,7 @@ package com.tangem.data.tokens.repository import arrow.core.raise.catch import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.compatibility.getL2CompatibilityTokenComparison import com.tangem.blockchainsdk.utils.toCoinId import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.data.common.api.safeApiCall @@ -18,8 +19,12 @@ import com.tangem.datasource.api.express.models.request.AssetsRequestBody import com.tangem.datasource.api.express.models.request.LeastTokenInfo import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObject +import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull +import com.tangem.datasource.local.preferences.utils.storeObject import com.tangem.datasource.local.token.ExpressAssetsStore -import com.tangem.datasource.local.token.UserTokensStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.common.util.derivationStyleProvider import com.tangem.domain.common.util.hasDerivation @@ -36,6 +41,7 @@ import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -46,11 +52,11 @@ import com.tangem.blockchain.common.FeePaidCurrency as FeePaidSdkCurrency internal class DefaultCurrenciesRepository( private val tangemTechApi: TangemTechApi, private val tangemExpressApi: TangemExpressApi, - private val userTokensStore: UserTokensStore, private val userWalletsStore: UserWalletsStore, private val walletManagersFacade: WalletManagersFacade, private val expressAssetsStore: ExpressAssetsStore, private val cacheRegistry: CacheRegistry, + private val appPreferencesStore: AppPreferencesStore, private val dispatchers: CoroutineDispatcherProvider, ) : CurrenciesRepository { @@ -86,7 +92,7 @@ internal class DefaultCurrenciesRepository( override suspend fun addCurrencies(userWalletId: UserWalletId, currencies: List) { return withContext(dispatchers.io) { val savedCurrencies = requireNotNull( - value = userTokensStore.getSyncOrNull(userWalletId), + value = getSavedUserTokensResponseSync(key = userWalletId), lazyMessage = { "Saved tokens empty. Can not perform add currencies action" }, ) @@ -127,7 +133,7 @@ internal class DefaultCurrenciesRepository( savedCurrencies: List, ): List { return newTokens - .filterNot { savedCurrencies.hasCoinForToken(it) } // tokens without coins + .filterNot { savedCurrencies.hasCoinForToken(it.network) } // tokens without coins .mapNotNull { cryptoCurrencyFactory.createCoin( blockchain = getBlockchain(networkId = it.network.id), @@ -141,29 +147,21 @@ internal class DefaultCurrenciesRepository( override suspend fun removeCurrency(userWalletId: UserWalletId, currency: CryptoCurrency) = withContext(dispatchers.io) { val savedCurrencies = requireNotNull( - value = userTokensStore.getSyncOrNull(userWalletId), + value = getSavedUserTokensResponseSync(key = userWalletId), lazyMessage = { "Saved tokens empty. Can not perform remove currency action" }, ) val token = userTokensResponseFactory.createResponseToken(currency) storeAndPushTokens( userWalletId = userWalletId, - response = savedCurrencies.copy( - tokens = savedCurrencies.tokens.filterNot { - // it's better to compare by fields, to support renaming and etc - it.contractAddress == token.contractAddress && - it.networkId == token.networkId && - it.derivationPath == token.derivationPath && - it.decimals == token.decimals - }, - ), + response = savedCurrencies.copy(tokens = savedCurrencies.tokens.filterNot { it == token }), ) } override suspend fun removeCurrencies(userWalletId: UserWalletId, currencies: List) { return withContext(dispatchers.io) { val savedCurrencies = requireNotNull( - value = userTokensStore.getSyncOrNull(userWalletId), + value = getSavedUserTokensResponseSync(key = userWalletId), lazyMessage = { "Saved tokens empty. Can not perform remove currencies action" }, ) @@ -276,13 +274,31 @@ internal class DefaultCurrenciesRepository( fetchTokensIfCacheExpired(userWallet, refresh) - val storedTokens = requireNotNull(userTokensStore.getSyncOrNull(userWallet.walletId)) { - "Unable to find tokens response for user wallet with provided ID: $userWalletId" - } + val storedTokens = requireNotNull( + value = getSavedUserTokensResponseSync(key = userWallet.walletId), + lazyMessage = { + "Unable to find tokens response for user wallet with provided ID: $userWalletId" + }, + ) responseCurrenciesFactory.createCurrencies(storedTokens, userWallet.scanResponse) } + override suspend fun getMultiCurrencyWalletCachedCurrenciesSync(userWalletId: UserWalletId) = + withContext(dispatchers.io) { + val userWallet = getUserWallet(userWalletId) + ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true) + + val storedTokens = requireNotNull( + value = getSavedUserTokensResponseSync(key = userWallet.walletId), + lazyMessage = { + "Unable to find tokens response for user wallet with provided ID: $userWalletId" + }, + ) + + responseCurrenciesFactory.createCurrencies(storedTokens, userWallet.scanResponse) + } + override suspend fun getMultiCurrencyWalletCurrency( userWalletId: UserWalletId, id: CryptoCurrency.ID, @@ -290,9 +306,12 @@ internal class DefaultCurrenciesRepository( val userWallet = getUserWallet(userWalletId) ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true) - val response = requireNotNull(userTokensStore.getSyncOrNull(userWalletId)) { - "Unable to find tokens response for user wallet with provided ID: $userWalletId" - } + val response = requireNotNull( + value = getSavedUserTokensResponseSync(key = userWalletId), + lazyMessage = { + "Unable to find tokens response for user wallet with provided ID: $userWalletId" + }, + ) responseCurrenciesFactory.createCurrency( currencyId = id, @@ -312,9 +331,12 @@ internal class DefaultCurrenciesRepository( fetchTokensIfCacheExpired(userWallet = userWallet, refresh = false) - val storedTokens = requireNotNull(userTokensStore.getSyncOrNull(userWallet.walletId)) { - "Unable to find tokens response for user wallet with provided ID: $userWalletId" - } + val storedTokens = requireNotNull( + value = getSavedUserTokensResponseSync(key = userWalletId), + lazyMessage = { + "Unable to find tokens response for user wallet with provided ID: $userWalletId" + }, + ) val blockchain = Blockchain.fromId(networkId.value) val blockchainNetworkId = blockchain.toNetworkId() val coinId = blockchain.toCoinId() @@ -335,7 +357,7 @@ internal class DefaultCurrenciesRepository( ensureIsCorrectUserWallet(userWalletId, isMultiCurrencyWalletExpected = true) launch(dispatchers.io) { - userTokensStore.get(userWalletId) + getSavedUserTokensResponse(userWalletId) .map { it.group == UserTokensResponse.GroupType.NETWORK } .collect(::send) } @@ -347,7 +369,7 @@ internal class DefaultCurrenciesRepository( ensureIsCorrectUserWallet(userWalletId, isMultiCurrencyWalletExpected = true) launch(dispatchers.io) { - userTokensStore.get(userWalletId) + getSavedUserTokensResponse(userWalletId) .map { it.sort == UserTokensResponse.SortType.BALANCE } .collect(::send) } @@ -457,23 +479,48 @@ internal class DefaultCurrenciesRepository( ) ?: error("Unable to create token") } - override suspend fun hasTokens(userWalletId: UserWalletId, network: Network): Boolean { - val userWallet = getUserWallet(userWalletId) - fetchTokensIfCacheExpired(userWallet, refresh = false) + @OptIn(ExperimentalCoroutinesApi::class) + override fun getAllWalletsCryptoCurrencies(currencyRawId: String): Flow>> { + return userWalletsStore.userWallets.flatMapLatest { userWallets -> + userWallets.forEach { fetchTokensIfCacheExpired(userWallet = it, refresh = false) } - val storedTokens = requireNotNull(userTokensStore.getSyncOrNull(userWallet.walletId)) { - "Unable to find tokens response for user wallet with provided ID: $userWalletId" - } + val userWalletsWithCurrencies = userWallets + .filterNot(UserWallet::isLocked) + .map { userWallet -> + if (userWallet.isMultiCurrency) { + getSavedUserTokensResponse(userWallet.walletId).map { storedTokens -> + val filterResponse = storedTokens.tokens.filter { + getL2CompatibilityTokenComparison(it, currencyRawId) + } - return storedTokens.tokens.any { - it.contractAddress != null && - it.networkId == network.backendId && - it.derivationPath == network.derivationPath.value + responseCurrenciesFactory.createCurrencies( + response = storedTokens.copy(tokens = filterResponse), + scanResponse = userWallet.scanResponse, + ) + } + } else { + flow { + val currency = getSingleCurrencyWalletPrimaryCurrency(userWalletId = userWallet.walletId) + + val currencies = if (currency.id.rawCurrencyId == currencyRawId) { + listOf(currency) + } else { + emptyList() + } + + emit(currencies) + } + } + .map { userWallet to it } + } + + combine(userWalletsWithCurrencies) { it.toMap() } + .onEmpty { emit(value = emptyMap()) } } } private fun getMultiCurrencyWalletCurrencies(userWallet: UserWallet): Flow> { - return userTokensStore.get(userWallet.walletId).map { storedTokens -> + return getSavedUserTokensResponse(userWallet.walletId).map { storedTokens -> responseCurrenciesFactory.createCurrencies( response = storedTokens, scanResponse = userWallet.scanResponse, @@ -517,17 +564,26 @@ internal class DefaultCurrenciesRepository( .let { customTokensMerger.mergeIfPresented(userWalletId, response) } .let(userTokensBackwardCompatibility::applyCompatibilityAndGetUpdated) - userTokensStore.store(userWallet.walletId, compatibleUserTokensResponse) + appPreferencesStore.storeObject( + key = PreferencesKeys.getUserTokensKey(userWalletId = userWallet.walletId.stringValue), + value = compatibleUserTokensResponse, + ) + fetchExchangeableUserMarketCoinsByIds(userWalletId, compatibleUserTokensResponse) } private suspend fun checkIsEmptyDemoWallet(userWallet: UserWallet): Boolean { - return demoConfig.isDemoCardId(userWallet.cardId) && userTokensStore.getSyncOrNull(userWallet.walletId) == null + val response = getSavedUserTokensResponseSync(key = userWallet.walletId) + + return demoConfig.isDemoCardId(userWallet.cardId) && response == null } private suspend fun storeAndPushTokens(userWalletId: UserWalletId, response: UserTokensResponse) { val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response) - userTokensStore.store(userWalletId, compatibleUserTokensResponse) + appPreferencesStore.storeObject( + key = PreferencesKeys.getUserTokensKey(userWalletId = userWalletId.stringValue), + value = compatibleUserTokensResponse, + ) pushTokens(userWalletId, response) } @@ -561,8 +617,9 @@ internal class DefaultCurrenciesRepository( private suspend fun handleFetchTokensError(userWallet: UserWallet, e: ApiResponseError): UserTokensResponse { val userWalletId = userWallet.walletId - val response = userTokensStore.getSyncOrNull(userWalletId) - ?: createDefaultUserTokensResponse(userWallet) + val response = appPreferencesStore.getObjectSyncOrNull( + key = PreferencesKeys.getUserTokensKey(userWalletId.stringValue), + ) ?: createDefaultUserTokensResponse(userWallet) if (e is ApiResponseError.HttpException && e.code == ApiResponseError.HttpException.Code.NOT_FOUND) { Timber.w(e, "Requested currencies could not be found in the remote store for: $userWalletId") @@ -622,4 +679,16 @@ internal class DefaultCurrenciesRepository( } private fun getTokensCacheKey(userWalletId: UserWalletId): String = "tokens_cache_key_${userWalletId.stringValue}" + + private fun getSavedUserTokensResponse(key: UserWalletId): Flow { + return appPreferencesStore + .getObject(PreferencesKeys.getUserTokensKey(userWalletId = key.stringValue)) + .filterNotNull() + } + + private suspend fun getSavedUserTokensResponseSync(key: UserWalletId): UserTokensResponse? { + return appPreferencesStore.getObjectSyncOrNull( + key = PreferencesKeys.getUserTokensKey(key.stringValue), + ) + } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt index de6ad111ab..3762b4ddee 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt @@ -8,8 +8,11 @@ import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.tokens.utils.CardCryptoCurrenciesFactory import com.tangem.data.tokens.utils.NetworkStatusFactory +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.network.NetworksStatusesStore -import com.tangem.datasource.local.token.UserTokensStore +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.core.lce.LceFlow @@ -33,7 +36,7 @@ internal class DefaultNetworksRepository( private val networksStatusesStore: NetworksStatusesStore, private val walletManagersFacade: WalletManagersFacade, private val userWalletsStore: UserWalletsStore, - private val userTokensStore: UserTokensStore, + private val appPreferencesStore: AppPreferencesStore, private val cacheRegistry: CacheRegistry, private val dispatchers: CoroutineDispatcherProvider, ) : NetworksRepository { @@ -300,9 +303,14 @@ internal class DefaultNetworksRepository( } return if (userWallet.isMultiCurrency) { - val response = requireNotNull(userTokensStore.getSyncOrNull(userWalletId)) { - "Unable to find tokens response for user wallet with provided ID: $userWalletId" - } + val response = requireNotNull( + value = appPreferencesStore.getObjectSyncOrNull( + key = PreferencesKeys.getUserTokensKey(userWalletId.stringValue), + ), + lazyMessage = { + "Unable to find tokens response for user wallet with provided ID: $userWalletId" + }, + ) responseCurrenciesFactory.createCurrencies(response, userWallet.scanResponse).asSequence() } else { diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt index f6c7ade8b4..13e1c3e368 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt @@ -37,14 +37,14 @@ internal class DefaultQuotesRepository( private val mutex = Mutex() @OptIn(ExperimentalCoroutinesApi::class) - override fun getQuotesUpdates(currenciesIds: Set): Flow> { + override fun getQuotesUpdates(currenciesIds: Set, refresh: Boolean): Flow> { return appPreferencesStore.getObject( key = PreferencesKeys.SELECTED_APP_CURRENCY_KEY, ) .distinctUntilChanged() .filterNotNull() .flatMapLatest { appCurrency -> - fetchExpiredQuotes(currenciesIds, appCurrency.id, refresh = false) + fetchExpiredQuotes(currenciesIds, appCurrency.id, refresh = refresh) quotesStore.get(currenciesIds).map(quotesConverter::convertSet) } .cancellable() @@ -143,7 +143,6 @@ internal class DefaultQuotesRepository( block = { acc.add(rawCurrencyId) }, ) } - acc } } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt index 5ae6618a2c..66f0ea8b64 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt @@ -1,6 +1,7 @@ package com.tangem.data.tokens.utils import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.compatibility.l2BlockchainsCoinIds import com.tangem.blockchainsdk.utils.toCoinId import com.tangem.datasource.api.tangemTech.models.QuotesResponse @@ -51,16 +52,6 @@ internal class QuotesUnsupportedCurrenciesIdAdapter { * Map that contains unsupported currencies and their replacement for request */ private val ethCoinId = Blockchain.Ethereum.toCoinId() - private val UNSUPPORTED_IDS_WITH_REPLACEMENTS = mapOf( - Blockchain.Optimism.toCoinId() to ethCoinId, - Blockchain.Arbitrum.toCoinId() to ethCoinId, - Blockchain.ZkSyncEra.toCoinId() to ethCoinId, - Blockchain.Manta.toCoinId() to ethCoinId, - Blockchain.PolygonZkEVM.toCoinId() to ethCoinId, - Blockchain.Aurora.toCoinId() to ethCoinId, - Blockchain.Base.toCoinId() to ethCoinId, - Blockchain.Blast.toCoinId() to ethCoinId, - Blockchain.Cyber.toCoinId() to ethCoinId, - ) + private val UNSUPPORTED_IDS_WITH_REPLACEMENTS = l2BlockchainsCoinIds.associateWith { ethCoinId } } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/UserTokensBackwardCompatibility.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/UserTokensBackwardCompatibility.kt index ec40d6e52c..a4b3f4116d 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/UserTokensBackwardCompatibility.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/UserTokensBackwardCompatibility.kt @@ -9,7 +9,7 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse * Helper to apply compatibility changes for [UserTokensResponse] to support old saved tokens * in new application with new IDs */ -internal class UserTokensBackwardCompatibility { +class UserTokensBackwardCompatibility { fun applyCompatibilityAndGetUpdated(userTokensResponse: UserTokensResponse): UserTokensResponse { return userTokensResponse.copy( diff --git a/data/transaction/build.gradle.kts b/data/transaction/build.gradle.kts index 41baa4a4ba..709f743912 100644 --- a/data/transaction/build.gradle.kts +++ b/data/transaction/build.gradle.kts @@ -32,5 +32,6 @@ dependencies { implementation(deps.hilt.android) kapt(deps.hilt.kapt) + /** Other */ implementation(deps.timber) } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt index 87f16d07be..0452da5a7c 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt @@ -158,6 +158,21 @@ internal class DefaultTransactionRepository( (walletManager as TransactionSender).send(txData, signer) } + override suspend fun sendMultipleTransactions( + txsData: List, + signer: CommonSigner, + userWalletId: UserWalletId, + network: Network, + ) = withContext(coroutineDispatcherProvider.io) { + val blockchain = Blockchain.fromId(network.id.value) + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) + (walletManager as TransactionSender).sendMultiple(txsData, signer) + } + override fun createTransactionDataExtras( data: String, network: Network, diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/HasMissedDerivationsUseCase.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/HasMissedDerivationsUseCase.kt new file mode 100644 index 0000000000..5d481fe1ca --- /dev/null +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/HasMissedDerivationsUseCase.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.card + +import com.tangem.domain.card.repository.DerivationsRepository +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Use case to check if user has missed derivations + * +[REDACTED_AUTHOR] + */ +class HasMissedDerivationsUseCase( + private val derivationsRepository: DerivationsRepository, +) { + + /** Check if user [userWalletId] has missed derivations using map of [Network.ID] with extraDerivationPath */ + suspend operator fun invoke( + userWalletId: UserWalletId, + networksWithDerivationPath: Map, + ): Boolean { + return derivationsRepository.hasMissedDerivations(userWalletId, networksWithDerivationPath) + } +} \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/DerivationsRepository.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/DerivationsRepository.kt index d6b474839a..83ea2e4b48 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/DerivationsRepository.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/DerivationsRepository.kt @@ -3,6 +3,7 @@ package com.tangem.domain.card.repository import com.tangem.common.extensions.ByteArrayKey import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network import com.tangem.domain.wallets.models.UserWalletId import com.tangem.operations.derivation.ExtendedPublicKeysMap @@ -11,9 +12,20 @@ interface DerivationsRepository { @Throws suspend fun derivePublicKeys(userWalletId: UserWalletId, currencies: List) + suspend fun derivePublicKeysByNetworkIds(userWalletId: UserWalletId, networkIds: List) + + @Throws + suspend fun derivePublicKeysByNetworks(userWalletId: UserWalletId, networks: List) + @Throws suspend fun derivePublicKeys( userWalletId: UserWalletId, derivations: Map>, ): Map + + /** Check if user [userWalletId] has missed derivations using map of [Network.ID] with extraDerivationPath */ + suspend fun hasMissedDerivations( + userWalletId: UserWalletId, + networksWithDerivationPath: Map, + ): Boolean } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt index a3cbd7d4f8..91f1f6b990 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt @@ -86,6 +86,15 @@ internal class FeedbackDataBuilder { builder.appendKeyValue("Fee", error.fee ?: "Unable to receive") } + fun addStakingInfo(validatorName: String?, transactionTypes: List, unsignedTransactions: List) { + builder.appendKeyValue("Validator", validatorName ?: "unknown") + builder.appendKeyValue("Action", transactionTypes.joinToString(separator = "\n").ifEmpty { "unknown" }) + builder.appendKeyValue( + key = "Unsigned transaction", + value = unsignedTransactions.joinToString(separator = "\n") { it ?: "unknown" }.ifEmpty { "unknown" }, + ) + } + fun addDelimiter(): StringBuilder = builder.appendDelimiter() fun build(): String = builder.trimEnd().toString() diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/SaveBlockchainErrorUseCase.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/SaveBlockchainErrorUseCase.kt index 596699a073..687a9859d1 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/SaveBlockchainErrorUseCase.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/SaveBlockchainErrorUseCase.kt @@ -14,7 +14,7 @@ class SaveBlockchainErrorUseCase( private val feedbackRepository: FeedbackRepository, ) { - fun invoke(error: BlockchainErrorInfo) { + operator fun invoke(error: BlockchainErrorInfo) { feedbackRepository.saveBlockchainErrorInfo(error = error) } } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt index 1e4971e4cf..735365aeac 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt @@ -22,4 +22,12 @@ sealed interface FeedbackEmailType { /** User has problem with sending transaction */ data class TransactionSendingProblem(override val cardInfo: CardInfo) : FeedbackEmailType + + /** User has problem with staking */ + data class StakingProblem( + override val cardInfo: CardInfo, + val validatorName: String?, + val transactionTypes: List, + val unsignedTransactions: List, + ) : FeedbackEmailType } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt index a9bb1a64dd..ae17e1da30 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt @@ -23,6 +23,12 @@ internal class EmailMessageBodyResolver( is FeedbackEmailType.RateCanBeBetter -> addCardAndPhoneInfo(type.cardInfo) is FeedbackEmailType.ScanningProblem -> addScanningProblemBody() is FeedbackEmailType.TransactionSendingProblem -> addTransactionSendingProblemBody(type.cardInfo) + is FeedbackEmailType.StakingProblem -> addStakingProblemBody( + type.cardInfo, + type.validatorName, + type.transactionTypes, + type.unsignedTransactions, + ) } return build() @@ -72,6 +78,36 @@ internal class EmailMessageBodyResolver( addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo()) } + private suspend fun FeedbackDataBuilder.addStakingProblemBody( + cardInfo: CardInfo, + validatorName: String?, + transactionTypes: List, + unsignedTransactions: List, + ) { + addCardInfo(cardInfo) + addDelimiter() + + val userWalletId = requireNotNull(cardInfo.userWalletId) { "UserWalletId must be not null" } + val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) + val blockchainInfo = blockchainError?.let { + feedbackRepository.getBlockchainInfo( + userWalletId = userWalletId, + blockchainId = blockchainError.blockchainId, + derivationPath = blockchainError.derivationPath, + ) + } + + if (blockchainInfo != null) { + addBlockchainError(blockchainInfo, blockchainError) + addDelimiter() + } + + addStakingInfo(validatorName, transactionTypes, unsignedTransactions) + addDelimiter() + + addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo()) + } + private fun FeedbackDataBuilder.addCardAndPhoneInfo(cardInfo: CardInfo) { addCardInfo(cardInfo) addDelimiter() diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt index 6ab001b775..bfa9cfdede 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt @@ -19,7 +19,9 @@ internal class EmailMessageTitleResolver(private val resources: Resources) { is FeedbackEmailType.DirectUserRequest -> R.string.feedback_preface_support is FeedbackEmailType.RateCanBeBetter -> R.string.feedback_preface_rate_negative is FeedbackEmailType.ScanningProblem -> R.string.feedback_preface_scan_failed - is FeedbackEmailType.TransactionSendingProblem -> R.string.feedback_preface_tx_failed + is FeedbackEmailType.TransactionSendingProblem, + is FeedbackEmailType.StakingProblem, + -> R.string.feedback_preface_tx_failed } .let(resources::getString) } diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt index beb89739bd..503f1f514b 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt @@ -26,6 +26,7 @@ internal class EmailSubjectResolver(private val resources: Resources) { is FeedbackEmailType.RateCanBeBetter -> R.string.feedback_subject_rate_negative is FeedbackEmailType.ScanningProblem -> R.string.feedback_subject_scan_failed is FeedbackEmailType.TransactionSendingProblem -> R.string.feedback_subject_tx_failed + is FeedbackEmailType.StakingProblem -> R.string.feedback_subject_tx_failed } .let(resources::getString) } diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/CardTypesResolver.kt b/domain/legacy/src/main/java/com/tangem/domain/common/CardTypesResolver.kt index 55cc073069..29ce08c107 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/CardTypesResolver.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/CardTypesResolver.kt @@ -10,54 +10,8 @@ interface CardTypesResolver { fun isTangemWallet(): Boolean - fun isWhiteWallet2(): Boolean - - fun isAvroraWallet(): Boolean - - fun isTraillantWallet(): Boolean - fun isShibaWallet(): Boolean - fun isTronWallet(): Boolean - - fun isKaspaWallet(): Boolean - - fun isKaspa2Wallet(): Boolean - - fun isKaspaResellerWallet(): Boolean - - fun isBadWallet(): Boolean - - fun isJrWallet(): Boolean - - fun isGrimWallet(): Boolean - - fun isSatoshiFriendsWallet(): Boolean - - fun isBitcoinPizzaDayWallet(): Boolean - - fun isVeChainWallet(): Boolean - - fun isNewWorldEliteWallet(): Boolean - - fun isRedPandaWallet(): Boolean - - fun isCryptoSethWallet(): Boolean - - fun isKishuInuWallet(): Boolean - - fun isBabyDogeWallet(): Boolean - - fun isCOQWallet(): Boolean - - fun isCoinMetricaWallet(): Boolean - - fun isVoltInuWallet(): Boolean - - fun isVividWallet(): Boolean - - fun isPastelWallet(): Boolean - fun isWhiteWallet(): Boolean fun isWallet2(): Boolean diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/TangemCardTypesResolver.kt b/domain/legacy/src/main/java/com/tangem/domain/common/TangemCardTypesResolver.kt index 6a4afdcfd9..85f6165a23 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/TangemCardTypesResolver.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/TangemCardTypesResolver.kt @@ -26,60 +26,10 @@ internal class TangemCardTypesResolver( card.firmwareVersion >= FirmwareVersion.MultiWalletAvailable } - override fun isWhiteWallet2(): Boolean = card.batchId == WHITE_WALLET2_BATCH_ID - - override fun isAvroraWallet(): Boolean = card.batchId == AVRORA_WALLET_BATCH_ID - - override fun isTraillantWallet(): Boolean = card.batchId == TRILLIANT_WALLET_BATCH_ID - override fun isShibaWallet(): Boolean { return card.firmwareVersion.compareTo(FirmwareVersion.KeysImportAvailable) == 0 } - override fun isTronWallet(): Boolean = card.batchId == TRON_WALLET_BATCH_ID - - override fun isKaspaWallet(): Boolean = card.batchId == KASPA_WALLET_BATCH_ID - - override fun isKaspa2Wallet(): Boolean = card.batchId == KASPA2_WALLET_BATCH_ID - - override fun isKaspaResellerWallet(): Boolean = card.batchId == KASPA_RESELLER_WALLET_BATCH_ID - - override fun isBadWallet(): Boolean = card.batchId == BAD_WALLET_BATCH_ID - - override fun isJrWallet(): Boolean = card.batchId == JR_WALLET_BATCH_ID - - override fun isGrimWallet(): Boolean = card.batchId == GRIM_WALLET_BATCH_ID - - override fun isSatoshiFriendsWallet(): Boolean = card.batchId == SATOSHI_WALLET_BATCH_ID - - override fun isBitcoinPizzaDayWallet(): Boolean = card.batchId == BITCOIN_PIZZA_DAY_WALLET_BATCH_ID - - override fun isVeChainWallet(): Boolean = card.batchId == VECHAIN_WALLET_BATCH_ID - - override fun isNewWorldEliteWallet(): Boolean = card.batchId == NEW_WORLD_ELITE_WALLET_BATCH_ID - - override fun isRedPandaWallet(): Boolean = card.batchId == RED_PANDA_WALLET_BATCH_ID - - override fun isCryptoSethWallet(): Boolean = card.batchId == CRYPTO_SETH_WALLET_BATCH_ID - - override fun isKishuInuWallet(): Boolean = card.batchId == KISHU_INU_WALLET_BATCH_ID - - override fun isBabyDogeWallet(): Boolean = card.batchId == BABY_DOGE_WALLET_BATCH_ID - - override fun isCOQWallet(): Boolean = card.batchId == COQ_WALLET_BATCH_ID - - override fun isCoinMetricaWallet(): Boolean = card.batchId == COIN_METRICA_WALLET_BATCH_ID - - override fun isVoltInuWallet(): Boolean = card.batchId == VOLT_INU_WALLET_BATCH_ID - - override fun isVividWallet(): Boolean = card.batchId == VIVID_LEMON_WALLET_BATCH_ID || - card.batchId == VIVID_AQUA_WALLET_BATCH_ID || - card.batchId == VIVID_GRAPEFRUIT_WALLET_BATCH_ID - - override fun isPastelWallet(): Boolean = card.batchId == PASTEL_PEACH_WALLET_BATCH_ID || - card.batchId == PASTEL_GRASS_WALLET_BATCH_ID || - card.batchId == PASTEL_AIR_WALLET_BATCH_ID - override fun isWhiteWallet(): Boolean { return walletData == null && card.firmwareVersion <= FirmwareVersion.HDWalletAvailable } @@ -183,34 +133,5 @@ internal class TangemCardTypesResolver( private companion object { const val DEV_KIT_CARD_BATCH_ID = "CB83" - const val TRON_WALLET_BATCH_ID = "AF07" - const val KASPA_WALLET_BATCH_ID = "AF08" - const val KASPA2_WALLET_BATCH_ID = "AF25" - const val KASPA_RESELLER_WALLET_BATCH_ID = "AF31" - const val BAD_WALLET_BATCH_ID = "AF09" - const val JR_WALLET_BATCH_ID = "AF14" - const val GRIM_WALLET_BATCH_ID = "AF13" - const val SATOSHI_WALLET_BATCH_ID = "AF19" - const val WHITE_WALLET2_BATCH_ID = "AF15" - const val TRILLIANT_WALLET_BATCH_ID = "AF16" - const val AVRORA_WALLET_BATCH_ID = "AF18" - const val BITCOIN_PIZZA_DAY_WALLET_BATCH_ID = "AF33" - const val VECHAIN_WALLET_BATCH_ID = "AF29" - const val NEW_WORLD_ELITE_WALLET_BATCH_ID = "AF26" - const val RED_PANDA_WALLET_BATCH_ID = "AF34" - const val CRYPTO_SETH_WALLET_BATCH_ID = "AF32" - const val KISHU_INU_WALLET_BATCH_ID = "AF52" - const val BABY_DOGE_WALLET_BATCH_ID = "AF51" - const val COQ_WALLET_BATCH_ID = "AF28" - const val COIN_METRICA_WALLET_BATCH_ID = "AF27" - const val VOLT_INU_WALLET_BATCH_ID = "AF35" - // VIVID WALLETS - const val VIVID_LEMON_WALLET_BATCH_ID = "AF40" - const val VIVID_AQUA_WALLET_BATCH_ID = "AF41" - const val VIVID_GRAPEFRUIT_WALLET_BATCH_ID = "AF42" - // PASTEL WALLETS - const val PASTEL_PEACH_WALLET_BATCH_ID = "AF43" - const val PASTEL_AIR_WALLET_BATCH_ID = "AF44" - const val PASTEL_GRASS_WALLET_BATCH_ID = "AF45" } } \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/util/ScanResponseExtensions.kt b/domain/legacy/src/main/java/com/tangem/domain/common/util/ScanResponseExt.kt similarity index 73% rename from domain/legacy/src/main/java/com/tangem/domain/common/util/ScanResponseExtensions.kt rename to domain/legacy/src/main/java/com/tangem/domain/common/util/ScanResponseExt.kt index 1c3519f606..94727f02cb 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/util/ScanResponseExtensions.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/util/ScanResponseExt.kt @@ -12,6 +12,7 @@ import com.tangem.domain.common.TapWorkarounds.isTangemTwins import com.tangem.domain.common.TapWorkarounds.isTestCard import com.tangem.domain.common.configs.CardConfig import com.tangem.domain.common.configs.Wallet2CardConfig +import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.wallets.models.UserWallet @@ -32,8 +33,6 @@ val UserWallet.cardTypesResolver: CardTypesResolver get() = scanResponse.cardTypesResolver fun ScanResponse.twinsIsTwinned(): Boolean = card.isTangemTwins && walletData != null && secondTwinPublicKey != null -fun ScanResponse.supportsHdWallet(): Boolean = card.settings.isHDWalletAllowed -fun ScanResponse.supportsBackup(): Boolean = card.settings.isBackupAllowed fun ScanResponse.hasDerivation(blockchain: Blockchain, rawDerivationPath: String): Boolean { return hasDerivation(blockchain, DerivationPath(rawDerivationPath)) @@ -66,4 +65,37 @@ private fun ScanResponse.hasDerivation(curve: EllipticCurve, derivationPath: Der val extendedPublicKeysMap = derivedKeys[foundWallet.publicKey.toMapKey()] ?: return false val extendedPublicKey = extendedPublicKeysMap[derivationPath] return extendedPublicKey != null +} + +/** + * Get total cards count in wallets set for this [ScanResponse] card + * + * @return null if wallet is not multi-currency or total cards count + */ +fun ScanResponse.getCardsCount(): Int? { + if (!cardTypesResolver.isMultiwalletAllowed()) return null + + return when (val status = card.backupStatus) { + is CardDTO.BackupStatus.Active -> status.cardCount + 1 + is CardDTO.BackupStatus.NoBackup, + is CardDTO.BackupStatus.CardLinked, + null, // Multi-currency wallet without backup function. Example, 4.12 + -> 1 + } +} + +/** + * Get backup cards count for this [ScanResponse] card + * + * @return null if wallet is not multi-currency or total cards count + */ +fun ScanResponse.getBackupCardsCount(): Int? { + return if (cardTypesResolver.isMultiwalletAllowed()) { + when (val status = card.backupStatus) { + is CardDTO.BackupStatus.Active -> status.cardCount + else -> 0 + } + } else { + null + } } \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/util/UserWalletExt.kt b/domain/legacy/src/main/java/com/tangem/domain/common/util/UserWalletExt.kt new file mode 100644 index 0000000000..0b5ef6f2fd --- /dev/null +++ b/domain/legacy/src/main/java/com/tangem/domain/common/util/UserWalletExt.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.common.util + +import com.tangem.domain.wallets.models.UserWallet + +/** + * Get total cards count in wallets set for a card that was saved in [UserWallet] + * + * @return null if wallet is not multi-currency or total cards count + */ +fun UserWallet.getCardsCount(): Int? = scanResponse.getCardsCount() + +/** + * Get backup cards count for a card that was saved in [UserWallet] + * + * @return null if wallet is not multi-currency or total cards count + */ +fun UserWallet.getBackupCardsCount(): Int? = scanResponse.getBackupCardsCount() \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/redux/StateDialog.kt b/domain/legacy/src/main/java/com/tangem/domain/redux/StateDialog.kt index e4beb677bd..120604ae39 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/redux/StateDialog.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/redux/StateDialog.kt @@ -2,6 +2,8 @@ package com.tangem.domain.redux interface StateDialog { + data object NfcFeatureIsUnavailable : StateDialog + data class ScanFailsDialog(val source: ScanFailsSource, val onTryAgain: (() -> Unit)? = null) : StateDialog enum class ScanFailsSource { diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt index 68f4060640..6d6c0b3a4b 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt @@ -31,6 +31,7 @@ import com.tangem.domain.txhistory.models.PaginationWrapper import com.tangem.domain.txhistory.models.TxHistoryItem import com.tangem.domain.txhistory.models.TxHistoryState import com.tangem.domain.walletmanager.model.SmartContractMethod +import com.tangem.domain.walletmanager.model.TokenInfo import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult import com.tangem.domain.walletmanager.utils.* import com.tangem.domain.walletmanager.utils.WalletManagerFactory @@ -103,24 +104,45 @@ class DefaultWalletManagersFacade( tokens .groupBy(CryptoCurrency.Token::network) .forEach { (network, networkTokens) -> - removeTokens(userWalletId, network, networkTokens) + removeTokens( + userWalletId = userWalletId, + network = network, + networkTokens = sdkTokenConverter.convertList(networkTokens), + ) } } - private suspend fun removeTokens( - userWalletId: UserWalletId, - network: Network, - networkTokens: List, - ) { + override suspend fun removeTokensByTokenInfo(userWalletId: UserWalletId, tokenInfos: Set) { + if (tokenInfos.isEmpty()) return + + tokenInfos + .groupBy { it.network } + .forEach { (network, tokenInfoList) -> + removeTokens( + userWalletId = userWalletId, + network = network, + networkTokens = tokenInfoList.map { + Token( + name = it.name, + symbol = it.symbol, + contractAddress = it.contractAddress, + decimals = it.decimals, + id = it.id, + ) + }, + ) + } + } + + private suspend fun removeTokens(userWalletId: UserWalletId, network: Network, networkTokens: List) { withContext(dispatchers.io) { val walletManager = walletManagersStore.getSyncOrNull( userWalletId = userWalletId, blockchain = Blockchain.fromId(network.id.value), derivationPath = network.derivationPath.value, ) ?: return@withContext - val tokensToRemove = sdkTokenConverter.convertList(networkTokens) - tokensToRemove.forEach { token -> + networkTokens.forEach { token -> walletManager.removeToken(token) } @@ -376,15 +398,12 @@ class DefaultWalletManagersFacade( return walletManagersStore.getAllSync(userWalletId) } - @Deprecated( - "Use NetworkAddress from CryptoCurrencyStatus", - ReplaceWith("cryptoCurrencyStatus.value.networkAddress"), - ) - override suspend fun getAddress(userWalletId: UserWalletId, network: Network): List
{ - return getAddresses(userWalletId, network).sortedBy { it.type } + override suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String? { + return getAddresses(userWalletId, network) + .firstOrNull { it.type == AddressType.Default } + ?.value } - @Deprecated("Use NetworkAddress from CryptoCurrencyStatus") override suspend fun getAddresses(userWalletId: UserWalletId, network: Network): Set
{ val manager = getOrCreateWalletManager( userWalletId = userWalletId, diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt index 4ecfbe3805..5104db3554 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -17,6 +17,7 @@ import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.domain.txhistory.models.PaginationWrapper import com.tangem.domain.txhistory.models.TxHistoryItem import com.tangem.domain.txhistory.models.TxHistoryState +import com.tangem.domain.walletmanager.model.TokenInfo import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow @@ -54,6 +55,8 @@ interface WalletManagersFacade { suspend fun removeTokens(userWalletId: UserWalletId, tokens: Set) + suspend fun removeTokensByTokenInfo(userWalletId: UserWalletId, tokenInfos: Set) + /** * Returns [UpdateWalletManagerResult] with last pending transactions * @@ -117,20 +120,18 @@ interface WalletManagersFacade { suspend fun getStoredWalletManagers(userWalletId: UserWalletId): List /** - * Returns ordered list of addresses for selected wallet for given currency + * Returns default network address for selected wallet in given network * * @param userWalletId selected wallet id * @param network network of currency */ - @Deprecated("Use NetworkAddress from CryptoCurrencyStatus") - suspend fun getAddress(userWalletId: UserWalletId, network: Network): List
+ suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String? /** Returns list of all addresses for all currencies in selected wallet * * @param userWalletId selected wallet id * @param network required to create wallet manager */ - @Deprecated("Use NetworkAddress from CryptoCurrencyStatus") suspend fun getAddresses(userWalletId: UserWalletId, network: Network): Set
/** diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/model/TokenInfo.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/model/TokenInfo.kt new file mode 100644 index 0000000000..9d1952b890 --- /dev/null +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/model/TokenInfo.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.walletmanager.model + +import com.tangem.domain.tokens.model.Network + +data class TokenInfo( + val network: Network, + val name: String, + val symbol: String, + val contractAddress: String, + val decimals: Int, + val id: String? = null, +) \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/SdkTransactionHistoryItemConverter.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/SdkTransactionHistoryItemConverter.kt index a429859de4..0df50f5899 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/SdkTransactionHistoryItemConverter.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/SdkTransactionHistoryItemConverter.kt @@ -54,6 +54,10 @@ internal class SdkTransactionHistoryItemConverter( } else { mapToInteractionAddressType(sourceType = sourceType) } + is SdkTransactionHistoryItem.TransactionType.TronStakingTransactionType -> { + TxHistoryItem.InteractionAddressType.Staking + } + is SdkTransactionHistoryItem.TransactionType.ContractMethod, is SdkTransactionHistoryItem.TransactionType.ContractMethodName, -> mapToInteractionAddressType(destinationType) diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/SdkTransactionTypeConverter.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/SdkTransactionTypeConverter.kt index c0ebfa9a66..e62cd5b7eb 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/SdkTransactionTypeConverter.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/SdkTransactionTypeConverter.kt @@ -1,20 +1,37 @@ package com.tangem.domain.walletmanager.utils -import com.tangem.blockchain.transactionhistory.models.TransactionHistoryItem +import com.tangem.blockchain.transactionhistory.models.TransactionHistoryItem.TransactionType import com.tangem.domain.txhistory.models.TxHistoryItem import com.tangem.domain.walletmanager.model.SmartContractMethod import com.tangem.utils.converter.Converter internal class SdkTransactionTypeConverter( private val smartContractMethods: Map, -) : Converter { +) : Converter { - override fun convert(value: TransactionHistoryItem.TransactionType): TxHistoryItem.TransactionType { + override fun convert(value: TransactionType): TxHistoryItem.TransactionType { return when (value) { - is TransactionHistoryItem.TransactionType.ContractMethod -> + is TransactionType.ContractMethod -> { getTransactionType(methodName = smartContractMethods[value.id]?.name) - is TransactionHistoryItem.TransactionType.ContractMethodName -> getTransactionType(methodName = value.name) - is TransactionHistoryItem.TransactionType.Transfer -> TxHistoryItem.TransactionType.Transfer + } + is TransactionType.ContractMethodName -> { + getTransactionType(methodName = value.name) + } + is TransactionType.Transfer -> { + TxHistoryItem.TransactionType.Transfer + } + is TransactionType.TronStakingTransactionType.FreezeBalanceV2Contract -> { + TxHistoryItem.TransactionType.TronStakingTransactionType.Stake + } + is TransactionType.TronStakingTransactionType.UnfreezeBalanceV2Contract -> { + TxHistoryItem.TransactionType.TronStakingTransactionType.Unstake + } + is TransactionType.TronStakingTransactionType.VoteWitnessContract -> { + TxHistoryItem.TransactionType.TronStakingTransactionType.Vote + } + is TransactionType.TronStakingTransactionType.WithdrawBalanceContract -> { + TxHistoryItem.TransactionType.TronStakingTransactionType.Withdraw + } } } diff --git a/domain/manage-tokens/build.gradle.kts b/domain/manage-tokens/build.gradle.kts index b24f993032..5ed6875afa 100644 --- a/domain/manage-tokens/build.gradle.kts +++ b/domain/manage-tokens/build.gradle.kts @@ -1,8 +1,13 @@ plugins { - alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) id("configuration") } +android { + namespace = "com.tangem.domain.managetokens" +} + dependencies { /* Domain */ @@ -10,6 +15,9 @@ dependencies { api(projects.domain.core) implementation(projects.domain.wallets.models) implementation(projects.domain.tokens.models) + implementation(projects.domain.tokens) + implementation(projects.domain.card) + implementation(projects.domain.legacy) /* Core */ api(projects.core.pagination) diff --git a/domain/manage-tokens/models/src/main/kotlin/com/tangem/domain/managetokens/model/ManagedCryptoCurrency.kt b/domain/manage-tokens/models/src/main/kotlin/com/tangem/domain/managetokens/model/ManagedCryptoCurrency.kt index f7f82002cf..b5c78a407e 100644 --- a/domain/manage-tokens/models/src/main/kotlin/com/tangem/domain/managetokens/model/ManagedCryptoCurrency.kt +++ b/domain/manage-tokens/models/src/main/kotlin/com/tangem/domain/managetokens/model/ManagedCryptoCurrency.kt @@ -25,6 +25,7 @@ sealed class ManagedCryptoCurrency { override val iconUrl: String?, override val network: Network, val contractAddress: String, + val decimals: Int, ) : Custom() data class Coin( @@ -42,7 +43,7 @@ sealed class ManagedCryptoCurrency { override val symbol: String, override val iconUrl: String, val availableNetworks: List, - val addedIn: Set, + val addedIn: Set, ) : ManagedCryptoCurrency() { val isAdded: Boolean = addedIn.isNotEmpty() @@ -54,13 +55,18 @@ sealed class ManagedCryptoCurrency { sealed class SourceNetwork { abstract val network: Network + abstract val decimals: Int val id: Network.ID get() = network.id val typeName: String get() = when (this) { - is Main -> MAIN_NETWORK_TYPE_NAME + is Main -> if (isL2Network) { + MAIN_NETWORK_L2_TYPE_NAME + } else { + MAIN_NETWORK_TYPE_NAME + } is Default -> when (network.standardType) { is Network.StandardType.BEP2, is Network.StandardType.BEP20, @@ -73,15 +79,19 @@ sealed class ManagedCryptoCurrency { data class Main( override val network: Network, + override val decimals: Int, + val isL2Network: Boolean, ) : SourceNetwork() data class Default( override val network: Network, + override val decimals: Int, val contractAddress: String, ) : SourceNetwork() private companion object { const val MAIN_NETWORK_TYPE_NAME = "MAIN" + const val MAIN_NETWORK_L2_TYPE_NAME = "MAIN L2" } } } \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckCurrencyUnsupportedUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckCurrencyUnsupportedUseCase.kt new file mode 100644 index 0000000000..eef9093442 --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckCurrencyUnsupportedUseCase.kt @@ -0,0 +1,35 @@ +package com.tangem.domain.managetokens + +import arrow.core.Either +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.managetokens.repository.ManageTokensRepository +import com.tangem.domain.wallets.models.UserWalletId + +class CheckCurrencyUnsupportedUseCase( + private val repository: ManageTokensRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + sourceNetwork: ManagedCryptoCurrency.SourceNetwork, + ): Either { + return Either.catch { + repository.checkCurrencyUnsupportedState(userWalletId, sourceNetwork) + } + } + + suspend operator fun invoke( + userWalletId: UserWalletId, + networkId: String, + isMainNetwork: Boolean, + ): Either { + return Either.catch { + repository.checkCurrencyUnsupportedState( + userWalletId = userWalletId, + rawNetworkId = networkId, + isMainNetwork = isMainNetwork, + ) + } + } +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckHasLinkedTokensUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckHasLinkedTokensUseCase.kt new file mode 100644 index 0000000000..c0acb6bffd --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckHasLinkedTokensUseCase.kt @@ -0,0 +1,28 @@ +package com.tangem.domain.managetokens + +import arrow.core.Either +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.managetokens.repository.ManageTokensRepository +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +class CheckHasLinkedTokensUseCase( + private val repository: ManageTokensRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + network: Network, + tempAddedTokens: Map>, + tempRemovedTokens: Map>, + ): Either { + return Either.catch { + repository.hasLinkedTokens( + userWalletId = userWalletId, + network = network, + tempAddedTokens = tempAddedTokens, + tempRemovedTokens = tempRemovedTokens, + ) + } + } +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckIsCurrencyNotAddedUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckIsCurrencyNotAddedUseCase.kt new file mode 100644 index 0000000000..04aa0761eb --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckIsCurrencyNotAddedUseCase.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.managetokens + +import arrow.core.Either +import com.tangem.domain.managetokens.repository.CustomTokensRepository +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +class CheckIsCurrencyNotAddedUseCase( + private val repository: CustomTokensRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + contractAddress: String?, + ): Either = Either.catch { + repository.isCurrencyNotAdded(userWalletId, networkId, derivationPath, contractAddress) + } +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CreateCurrencyUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CreateCurrencyUseCase.kt new file mode 100644 index 0000000000..dc935f08af --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CreateCurrencyUseCase.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.managetokens + +import arrow.core.Either +import com.tangem.domain.managetokens.model.AddCustomTokenForm +import com.tangem.domain.managetokens.repository.CustomTokensRepository +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network + +class CreateCurrencyUseCase( + private val repository: CustomTokensRepository, +) { + + suspend operator fun invoke( + networkId: Network.ID, + derivationPath: Network.DerivationPath, + formValues: AddCustomTokenForm.Validated.All?, + ): Either = Either.catch { + if (formValues == null) { + repository.createCoin(networkId, derivationPath) + } else { + repository.createCustomToken(networkId, derivationPath, formValues) + } + } +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/FindTokenUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/FindTokenUseCase.kt new file mode 100644 index 0000000000..3542154d4e --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/FindTokenUseCase.kt @@ -0,0 +1,54 @@ +package com.tangem.domain.managetokens + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.either +import arrow.core.raise.ensureNotNull +import com.tangem.domain.managetokens.model.exceptoin.FindTokenException +import com.tangem.domain.managetokens.repository.CustomTokensRepository +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +class FindTokenUseCase( + private val repository: CustomTokensRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + contractAddress: String, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + ): Either = either { + val currency = findCurrency( + userWalletId = userWalletId, + contractAddress = contractAddress, + networkId = networkId, + derivationPath = derivationPath, + ) + + ensureNotNull(currency) { + FindTokenException.NotFound + } + } + + private suspend fun Raise.findCurrency( + userWalletId: UserWalletId, + contractAddress: String, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + ): CryptoCurrency.Token? = catch( + block = { + repository.findToken( + userWalletId = userWalletId, + contractAddress = contractAddress, + networkId = networkId, + derivationPath = derivationPath, + ) + }, + catch = { + raise(FindTokenException.DataError(it)) + }, + ) +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/GetSupportedNetworksUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/GetSupportedNetworksUseCase.kt new file mode 100644 index 0000000000..1074a21b02 --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/GetSupportedNetworksUseCase.kt @@ -0,0 +1,27 @@ +package com.tangem.domain.managetokens + +import arrow.core.Either +import arrow.core.raise.catch +import arrow.core.raise.either +import arrow.core.raise.ensureNotNull +import com.tangem.domain.managetokens.model.exceptoin.SupportedBlockchainException +import com.tangem.domain.managetokens.repository.CustomTokensRepository +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +class GetSupportedNetworksUseCase( + private val repository: CustomTokensRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId): Either> { + return either { + val networks = catch({ repository.getSupportedNetworks(userWalletId) }) { + raise(SupportedBlockchainException.DataError(it)) + } + + ensureNotNull(networks.takeIf { it.isNotEmpty() }) { + SupportedBlockchainException.EmptyList + } + } + } +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/RemoveCustomManagedCryptoCurrencyUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/RemoveCustomManagedCryptoCurrencyUseCase.kt new file mode 100644 index 0000000000..61e33c875c --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/RemoveCustomManagedCryptoCurrencyUseCase.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.managetokens + +import arrow.core.Either +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.managetokens.repository.CustomTokensRepository +import com.tangem.domain.wallets.models.UserWalletId + +class RemoveCustomManagedCryptoCurrencyUseCase(private val repository: CustomTokensRepository) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + customCurrency: ManagedCryptoCurrency.Custom, + ): Either { + return Either.catch { + repository.removeCurrency(userWalletId, customCurrency) + } + } +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt new file mode 100644 index 0000000000..2db0a45d91 --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt @@ -0,0 +1,127 @@ +package com.tangem.domain.managetokens + +import arrow.core.Either +import arrow.core.flatten +import com.tangem.domain.card.repository.DerivationsRepository +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.managetokens.repository.CustomTokensRepository +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.NetworksRepository +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWalletId + +class SaveManagedTokensUseCase( + private val customTokensRepository: CustomTokensRepository, + private val walletManagersFacade: WalletManagersFacade, + private val currenciesRepository: CurrenciesRepository, + private val networksRepository: NetworksRepository, + private val derivationsRepository: DerivationsRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + currenciesToAdd: Map>, + currenciesToRemove: Map>, + ): Either { + return Either.catch { + // TODO: Currently order is matter. [REDACTED_JIRA] + removeCurrencies(userWalletId, currenciesToRemove) + addCurrencies(userWalletId, currenciesToAdd) + } + } + + private suspend fun removeCurrencies( + userWalletId: UserWalletId, + currenciesToRemove: Map>, + ) { + if (currenciesToRemove.isEmpty()) return + + val currencies = currenciesToRemove.mapToCryptoCurrencies() + currenciesRepository.removeCurrencies(userWalletId = userWalletId, currencies = currencies) + + walletManagersFacade.remove( + userWalletId = userWalletId, + networks = currencies + .filterIsInstance() + .mapTo(hashSetOf(), CryptoCurrency::network), + ) + + walletManagersFacade.removeTokens( + userWalletId = userWalletId, + tokens = currencies.filterIsInstance().toSet(), + ) + } + + private suspend fun addCurrencies( + userWalletId: UserWalletId, + currenciesToAdd: Map>, + ) { + if (currenciesToAdd.isEmpty()) return + + val existingCurrencies = currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) + val currencies = currenciesToAdd.mapToCryptoCurrencies() + derivationsRepository.derivePublicKeysByNetworks( + userWalletId = userWalletId, + networks = currenciesToAdd.values.flatten(), + ) + currenciesRepository.addCurrencies(userWalletId, currencies) + refreshUpdatedNetworks( + userWalletId = userWalletId, + existingCurrencies = existingCurrencies, + currenciesToAdd = currencies, + ) + } + + private suspend fun refreshUpdatedNetworks( + userWalletId: UserWalletId, + currenciesToAdd: List, + existingCurrencies: List, + ) { + val networksToUpdate = currenciesToAdd + .asSequence() + .filterIsInstance() + .map(CryptoCurrency.Token::network) + .filterTo(hashSetOf()) { hasCoinForNetwork(existingCurrencies, it) } + + val networkToUpdate = currenciesToAdd.map { it.network } + .subtract(existingCurrencies.map { it.network }.toSet()) + + networksRepository.getNetworkStatusesSync( + userWalletId = userWalletId, + networks = networksToUpdate + networkToUpdate, + refresh = true, + ) + } + + /** + * Determines if the [existingCurrencies] list contains a coin that corresponds + * to the given [network]. + */ + private fun hasCoinForNetwork(existingCurrencies: List, network: Network): Boolean { + return existingCurrencies.any { currency -> + currency is CryptoCurrency.Coin && currency.network == network + } + } + + private fun Map>.mapToCryptoCurrencies(): List { + return flatMap { (token, networks) -> + token.availableNetworks + .filter { sourceNetwork -> networks.contains(sourceNetwork.network) } + .map { sourceNetwork -> + when (sourceNetwork) { + is ManagedCryptoCurrency.SourceNetwork.Default -> customTokensRepository.createToken( + managedCryptoCurrency = token, + sourceNetwork = sourceNetwork, + rawId = token.id.value, + ) + is ManagedCryptoCurrency.SourceNetwork.Main -> customTokensRepository.createCoin( + networkId = sourceNetwork.id, + derivationPath = sourceNetwork.network.derivationPath, + ) + } + } + } + } +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/ValidateDerivationPathUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/ValidateDerivationPathUseCase.kt new file mode 100644 index 0000000000..deaa42edf9 --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/ValidateDerivationPathUseCase.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.managetokens + +import arrow.core.Either +import arrow.core.raise.catch +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.tangem.domain.managetokens.model.exceptoin.DerivationPathValidationException +import com.tangem.domain.managetokens.repository.CustomTokensRepository +import com.tangem.domain.tokens.model.Network + +class ValidateDerivationPathUseCase( + private val repository: CustomTokensRepository, +) { + + operator fun invoke(rawValue: String): Either { + return either { + ensure(rawValue.isNotEmpty()) { DerivationPathValidationException.Empty } + + catch({ repository.createDerivationPath(rawValue) }) { + raise(DerivationPathValidationException.Invalid) + } + } + } +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/ValidateTokenFormUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/ValidateTokenFormUseCase.kt new file mode 100644 index 0000000000..ffbfa795bf --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/ValidateTokenFormUseCase.kt @@ -0,0 +1,80 @@ +package com.tangem.domain.managetokens + +import arrow.core.EitherNel +import arrow.core.nonEmptyListOf +import arrow.core.raise.* +import com.tangem.domain.managetokens.model.AddCustomTokenForm +import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException +import com.tangem.domain.managetokens.repository.CustomTokensRepository +import com.tangem.domain.tokens.model.Network + +class ValidateTokenFormUseCase( + private val repository: CustomTokensRepository, +) { + + suspend operator fun invoke( + networkId: Network.ID, + formValues: AddCustomTokenForm.Raw, + ): EitherNel = either { + if (formValues.name.isBlank() && formValues.symbol.isBlank() && formValues.decimals.isBlank()) { + val contractAddress = withError({ nonEmptyListOf(it) }) { + ensureIsContractAddressValid(formValues.contractAddress, networkId) + } + + AddCustomTokenForm.Validated.ContractAddress(contractAddress) + } else { + zipOrAccumulate( + { ensureIsContractAddressValid(formValues.contractAddress, networkId) }, + { ensureIsDecimalsValid(formValues.decimals) }, + { ensure(formValues.name.isNotBlank()) { CustomTokenFormValidationException.EmptyName } }, + { ensure(formValues.symbol.isNotBlank()) { CustomTokenFormValidationException.EmptySymbol } }, + ) { contractAddress, decimals, _, _ -> + AddCustomTokenForm.Validated.All( + contractAddress = contractAddress, + symbol = formValues.symbol, + name = formValues.name, + decimals = decimals, + ) + } + } + } + + private suspend fun Raise.ensureIsContractAddressValid( + contractAddress: String, + networkId: Network.ID, + ): String { + ensure(contractAddress.isNotBlank()) { + CustomTokenFormValidationException.ContractAddress.Empty + } + + val isValid = catch({ repository.validateContractAddress(contractAddress, networkId) }) { + raise(CustomTokenFormValidationException.DataError(it)) + } + + ensure(isValid) { + CustomTokenFormValidationException.ContractAddress.Invalid + } + + return contractAddress + } + + private fun Raise.ensureIsDecimalsValid(decimals: String): Int { + ensure(condition = decimals.isNotBlank()) { + CustomTokenFormValidationException.Decimals.Empty + } + + val decimalsInt = ensureNotNull(decimals.toIntOrNull()) { + CustomTokenFormValidationException.Decimals.Invalid + } + ensure(condition = decimalsInt in MIN_DECIMALS..MAX_DECIMALS) { + CustomTokenFormValidationException.Decimals.Invalid + } + + return decimalsInt + } + + companion object { + const val MIN_DECIMALS = 1 + const val MAX_DECIMALS = 30 + } +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/AddCustomTokenForm.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/AddCustomTokenForm.kt new file mode 100644 index 0000000000..a5b5dc5bdc --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/AddCustomTokenForm.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.managetokens.model + +sealed class AddCustomTokenForm { + + data class Raw( + val contractAddress: String, + val name: String, + val symbol: String, + val decimals: String, + ) : AddCustomTokenForm() + + sealed class Validated : AddCustomTokenForm() { + + data class ContractAddress( + val contractAddress: String, + ) : Validated() + + data class All( + val contractAddress: String, + val name: String, + val symbol: String, + val decimals: Int, + ) : Validated() + } +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/CurrencyUnsupportedState.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/CurrencyUnsupportedState.kt new file mode 100644 index 0000000000..af7a16af06 --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/CurrencyUnsupportedState.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.managetokens.model + +sealed class CurrencyUnsupportedState { + + abstract val networkName: String + sealed class Token : CurrencyUnsupportedState() { + data class NetworkTokensUnsupported(override val networkName: String) : Token() + data class UnsupportedCurve(override val networkName: String) : Token() + } + + data class UnsupportedNetwork(override val networkName: String) : CurrencyUnsupportedState() +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensUpdateAction.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensUpdateAction.kt index 75b1b2e4ad..0d3af7a007 100644 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensUpdateAction.kt +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensUpdateAction.kt @@ -6,7 +6,7 @@ sealed class ManageTokensUpdateAction { data class AddCurrency( val currencyId: ManagedCryptoCurrency.ID, - val networkId: Network.ID, + val network: Network, val isSelected: Boolean, ) : ManageTokensUpdateAction() } \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/exceptoin/CustomTokenFormValidationException.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/exceptoin/CustomTokenFormValidationException.kt new file mode 100644 index 0000000000..a197980215 --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/exceptoin/CustomTokenFormValidationException.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.managetokens.model.exceptoin + +sealed class CustomTokenFormValidationException { + + sealed class ContractAddress : CustomTokenFormValidationException() { + + data object Empty : ContractAddress() + + data object Invalid : ContractAddress() + } + + sealed class Decimals : CustomTokenFormValidationException() { + + data object Empty : Decimals() + + data object Invalid : Decimals() + } + + data object EmptyName : CustomTokenFormValidationException() + + data object EmptySymbol : CustomTokenFormValidationException() + + data class DataError(val cause: Throwable) : CustomTokenFormValidationException() +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/exceptoin/DerivationPathValidationException.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/exceptoin/DerivationPathValidationException.kt new file mode 100644 index 0000000000..fdda23776c --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/exceptoin/DerivationPathValidationException.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.managetokens.model.exceptoin + +sealed class DerivationPathValidationException { + + data object Empty : DerivationPathValidationException() + + data object Invalid : DerivationPathValidationException() +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/exceptoin/FindTokenException.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/exceptoin/FindTokenException.kt new file mode 100644 index 0000000000..cc8b180305 --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/exceptoin/FindTokenException.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.managetokens.model.exceptoin + +sealed class FindTokenException { + + data object NotFound : FindTokenException() + + data class DataError(val cause: Throwable) : FindTokenException() +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/exceptoin/SupportedBlockchainException.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/exceptoin/SupportedBlockchainException.kt new file mode 100644 index 0000000000..946e3cd9b4 --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/exceptoin/SupportedBlockchainException.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.managetokens.model.exceptoin + +sealed class SupportedBlockchainException { + + data object EmptyList : SupportedBlockchainException() + + data class DataError(val cause: Throwable) : SupportedBlockchainException() +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt new file mode 100644 index 0000000000..eb45ba52ad --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt @@ -0,0 +1,46 @@ +package com.tangem.domain.managetokens.repository + +import com.tangem.domain.managetokens.model.AddCustomTokenForm +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +interface CustomTokensRepository { + + suspend fun validateContractAddress(contractAddress: String, networkId: Network.ID): Boolean + + suspend fun isCurrencyNotAdded( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + contractAddress: String?, + ): Boolean + + suspend fun findToken( + userWalletId: UserWalletId, + contractAddress: String, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + ): CryptoCurrency.Token? + + fun createCoin(networkId: Network.ID, derivationPath: Network.DerivationPath): CryptoCurrency.Coin + + fun createToken( + managedCryptoCurrency: ManagedCryptoCurrency.Token, + sourceNetwork: ManagedCryptoCurrency.SourceNetwork.Default, + rawId: String?, + ): CryptoCurrency.Token + + suspend fun createCustomToken( + networkId: Network.ID, + derivationPath: Network.DerivationPath, + formValues: AddCustomTokenForm.Validated.All, + ): CryptoCurrency.Token + + suspend fun removeCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom) + + suspend fun getSupportedNetworks(userWalletId: UserWalletId): List + + fun createDerivationPath(rawPath: String): Network.DerivationPath +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/ManageTokensRepository.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/ManageTokensRepository.kt index 3104d98d64..0d879955e8 100644 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/ManageTokensRepository.kt +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/ManageTokensRepository.kt @@ -1,9 +1,31 @@ package com.tangem.domain.managetokens.repository +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState import com.tangem.domain.managetokens.model.ManageTokensListBatchFlow import com.tangem.domain.managetokens.model.ManageTokensListBatchingContext +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId interface ManageTokensRepository { fun getTokenListBatchFlow(context: ManageTokensListBatchingContext, batchSize: Int): ManageTokensListBatchFlow + + suspend fun hasLinkedTokens( + userWalletId: UserWalletId, + network: Network, + tempAddedTokens: Map>, + tempRemovedTokens: Map>, + ): Boolean + + suspend fun checkCurrencyUnsupportedState( + userWalletId: UserWalletId, + sourceNetwork: ManagedCryptoCurrency.SourceNetwork, + ): CurrencyUnsupportedState? + + suspend fun checkCurrencyUnsupportedState( + userWalletId: UserWalletId, + rawNetworkId: String, + isMainNetwork: Boolean, + ): CurrencyUnsupportedState? } \ No newline at end of file diff --git a/domain/markets/build.gradle.kts b/domain/markets/build.gradle.kts index 885998c263..430f975fa5 100644 --- a/domain/markets/build.gradle.kts +++ b/domain/markets/build.gradle.kts @@ -11,12 +11,19 @@ android { dependencies { + /* Domain */ api(projects.domain.appCurrency.models) + api(projects.domain.card) api(projects.domain.core) - api(projects.core.pagination) api(projects.domain.markets.models) + api(projects.domain.wallets.models) - implementation(deps.kotlin.serialization) implementation(projects.domain.tokens.models) + implementation(projects.domain.tokens) + + api(projects.core.pagination) + + /* Utils */ + implementation(deps.kotlin.serialization) implementation(projects.core.utils) } \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt index ac425d8e8a..e7d0c1d6e9 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt @@ -8,6 +8,7 @@ data class TokenMarket( val symbol: String, val marketRating: Int?, val marketCap: BigDecimal?, + val isUnderMarketCapLimit: Boolean, val tokenQuotesShort: TokenQuotesShort, val tokenCharts: Charts, private val imageHost: String, 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 7fe496e5f1..6f02eb0639 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 @@ -27,7 +27,13 @@ data class TokenMarketInfo( val liquidityChange: Change?, val buyPressureChange: Change?, val experiencedBuyerChange: Change?, - ) + val sourceNetworks: List, + ) { + data class SourceNetwork( + val id: String, + val name: String, + ) + } data class Change( val day: BigDecimal?, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/TokenMarketSerializable.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketParams.kt similarity index 56% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/TokenMarketSerializable.kt rename to domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketParams.kt index a1aa6b178c..e14b1f417b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/TokenMarketSerializable.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketParams.kt @@ -1,35 +1,32 @@ -package com.tangem.features.markets.details.api +package com.tangem.domain.markets import com.tangem.domain.core.serialization.SerializedBigDecimal -import com.tangem.domain.markets.TokenMarket import kotlinx.serialization.Serializable @Serializable -data class TokenMarketSerializable( +data class TokenMarketParams( val id: String, val name: String, val symbol: String, - val marketCap: SerializedBigDecimal?, val tokenQuotes: Quotes, - val imageUrl: String, + val imageUrl: String?, ) { @Serializable data class Quotes( val currentPrice: SerializedBigDecimal, - val h24Percent: SerializedBigDecimal, - val weekPercent: SerializedBigDecimal, - val monthPercent: SerializedBigDecimal, + val h24Percent: SerializedBigDecimal?, + val weekPercent: SerializedBigDecimal?, + val monthPercent: SerializedBigDecimal?, ) } -fun TokenMarket.toSerializable(): TokenMarketSerializable { - return TokenMarketSerializable( +fun TokenMarket.toSerializableParam(): TokenMarketParams { + return TokenMarketParams( id = id, name = name, symbol = symbol, - marketCap = marketCap, - tokenQuotes = TokenMarketSerializable.Quotes( + tokenQuotes = TokenMarketParams.Quotes( currentPrice = tokenQuotesShort.currentPrice, h24Percent = tokenQuotesShort.h24ChangePercent, weekPercent = tokenQuotesShort.weekChangePercent, diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetCurrencyQuotesUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetCurrencyQuotesUseCase.kt new file mode 100644 index 0000000000..1ec184fbdc --- /dev/null +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetCurrencyQuotesUseCase.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.markets + +import arrow.core.* +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.tokens.repository.QuotesRepository +import kotlinx.coroutines.flow.* + +@Suppress("UnusedPrivateMember") +class GetCurrencyQuotesUseCase( + private val quotesRepository: QuotesRepository, +) { + // TODO apply interval parameter [REDACTED_TASK_KEY] + operator fun invoke( + currencyID: CryptoCurrency.ID, + interval: PriceChangeInterval, + refresh: Boolean, + ): Flow> { + return quotesRepository.getQuotesUpdates( + currenciesIds = setOf(currencyID), + refresh = refresh, + ).map { it.firstOrNull().toOption() }.catch { emit(None) } + } +} \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenQuotesUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenFullQuotesUseCase.kt similarity index 94% rename from domain/markets/src/main/java/com/tangem/domain/markets/GetTokenQuotesUseCase.kt rename to domain/markets/src/main/java/com/tangem/domain/markets/GetTokenFullQuotesUseCase.kt index 6477010d57..a755057a98 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenQuotesUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenFullQuotesUseCase.kt @@ -4,10 +4,9 @@ import arrow.core.Either import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.repositories.MarketsTokenRepository -class GetTokenQuotesUseCase( +class GetTokenFullQuotesUseCase( private val marketsTokenRepository: MarketsTokenRepository, ) { - suspend operator fun invoke(appCurrency: AppCurrency, tokenId: String): Either { return Either.catch { marketsTokenRepository.getTokenQuotes( diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketInfoUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketInfoUseCase.kt index c7935cb4e0..2038181eef 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketInfoUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketInfoUseCase.kt @@ -9,11 +9,16 @@ class GetTokenMarketInfoUseCase( private val marketsTokenRepository: MarketsTokenRepository, ) { - suspend operator fun invoke(appCurrency: AppCurrency, tokenId: String): Either { + suspend operator fun invoke( + appCurrency: AppCurrency, + tokenId: String, + tokenSymbol: String, + ): Either { return Either.catch { marketsTokenRepository.getTokenInfo( fiatCurrencyCode = appCurrency.code, tokenId = tokenId, + tokenSymbol = tokenSymbol, languageCode = SupportedLanguages.getCurrentSupportedLanguageCode(), ) }.mapLeft {} diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt index c686ff3874..c6d677f34b 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt @@ -12,13 +12,25 @@ class GetTokenPriceChartUseCase( appCurrency: AppCurrency, interval: PriceChangeInterval, tokenId: String, + tokenSymbol: String, + preview: Boolean, ): Either { return Either.catch { - marketsTokenRepository.getChart( - fiatCurrencyCode = appCurrency.code, - interval = interval, - tokenId = tokenId, - ) + if (preview) { + marketsTokenRepository.getChartPreview( + fiatCurrencyCode = appCurrency.code, + interval = interval, + tokenId = tokenId, + tokenSymbol = tokenSymbol, + ) + } else { + marketsTokenRepository.getChart( + fiatCurrencyCode = appCurrency.code, + interval = interval, + tokenId = tokenId, + tokenSymbol = tokenSymbol, + ) + } }.mapLeft {} } } \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt new file mode 100644 index 0000000000..d944b5f9de --- /dev/null +++ b/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt @@ -0,0 +1,66 @@ +package com.tangem.domain.markets + +import arrow.core.Either +import com.tangem.domain.card.repository.DerivationsRepository +import com.tangem.domain.markets.repositories.MarketsTokenRepository +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.NetworksRepository +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Use case for saving tokens from Markets + * + * @property derivationsRepository derivations repository + * @property marketsTokenRepository markets token repository + * @property currenciesRepository currencies repository + * +[REDACTED_AUTHOR] + */ +class SaveMarketTokensUseCase( + private val derivationsRepository: DerivationsRepository, + private val marketsTokenRepository: MarketsTokenRepository, + private val currenciesRepository: CurrenciesRepository, + private val networksRepository: NetworksRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + tokenMarketParams: TokenMarketParams, + addedNetworks: Set, + removedNetworks: Set, + ): Either = Either.catch { + currenciesRepository.removeCurrencies( + userWalletId = userWalletId, + currencies = removedNetworks.mapNotNull { + marketsTokenRepository.createCryptoCurrency( + userWalletId = userWalletId, + token = tokenMarketParams, + network = it, + ) + }, + ) + + derivationsRepository.derivePublicKeysByNetworkIds( + userWalletId = userWalletId, + networkIds = addedNetworks.map { Network.ID(it.networkId) }, + ) + + val addedCurrencies = addedNetworks.mapNotNull { + marketsTokenRepository.createCryptoCurrency( + userWalletId = userWalletId, + token = tokenMarketParams, + network = it, + ) + } + + currenciesRepository.addCurrencies(userWalletId = userWalletId, currencies = addedCurrencies) + + networksRepository.getNetworkStatusesSync( + userWalletId = userWalletId, + networks = addedCurrencies.map(CryptoCurrency::network).toSet(), + refresh = true, + ) + } +} \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt index 5c80144aec..c9ff5dae64 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt @@ -1,6 +1,8 @@ package com.tangem.domain.markets.repositories import com.tangem.domain.markets.* +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId interface MarketsTokenRepository { @@ -10,9 +12,32 @@ interface MarketsTokenRepository { nextBatchSize: Int, ): TokenListBatchFlow - suspend fun getChart(fiatCurrencyCode: String, interval: PriceChangeInterval, tokenId: String): TokenChart + suspend fun getChart( + fiatCurrencyCode: String, + interval: PriceChangeInterval, + tokenId: String, + tokenSymbol: String, + ): TokenChart - suspend fun getTokenInfo(fiatCurrencyCode: String, tokenId: String, languageCode: String): TokenMarketInfo + suspend fun getChartPreview( + fiatCurrencyCode: String, + interval: PriceChangeInterval, + tokenId: String, + tokenSymbol: String, + ): TokenChart + + suspend fun getTokenInfo( + fiatCurrencyCode: String, + tokenId: String, + tokenSymbol: String, + languageCode: String, + ): TokenMarketInfo suspend fun getTokenQuotes(fiatCurrencyCode: String, tokenId: String): TokenQuotes + + suspend fun createCryptoCurrency( + userWalletId: UserWalletId, + token: TokenMarketParams, + network: TokenMarketInfo.Network, + ): CryptoCurrency? } \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowMarketsTooltipUseCase.kt b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowMarketsTooltipUseCase.kt new file mode 100644 index 0000000000..14acc20013 --- /dev/null +++ b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowMarketsTooltipUseCase.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.settings + +import com.tangem.domain.settings.repositories.SettingsRepository + +class ShouldShowMarketsTooltipUseCase(private val settingsRepository: SettingsRepository) { + + suspend operator fun invoke(): Boolean = settingsRepository.shouldShowMarketsTooltip() + + suspend operator fun invoke(isShown: Boolean) = settingsRepository.setMarketsTooltipShown(isShown) +} \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt index 50b3e3624d..6067b99a64 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt @@ -29,4 +29,8 @@ interface SettingsRepository { suspend fun setShouldSaveAccessCodes(value: Boolean) suspend fun incrementAppLaunchCounter() + + suspend fun shouldShowMarketsTooltip(): Boolean + + suspend fun setMarketsTooltipShown(value: Boolean) } \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingEntryInfo.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingEntryInfo.kt index 691aece740..c400337e61 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingEntryInfo.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingEntryInfo.kt @@ -1,9 +1,10 @@ package com.tangem.domain.staking.model +import com.tangem.domain.staking.model.stakekit.Yield import java.math.BigDecimal data class StakingEntryInfo( - val interestRate: BigDecimal, - val periodInDays: Int, + val apr: BigDecimal, + val rewardSchedule: Yield.Metadata.RewardSchedule, val tokenSymbol: String, ) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt index f8f6b7732d..4be9bad438 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt @@ -53,16 +53,25 @@ data class Yield( @Serializable data class Validator( val address: String, - val status: String, + val status: ValidatorStatus, val name: String, - val image: String?, - val website: String?, - val apr: SerializedBigDecimal?, - val commission: Double?, - val stakedBalance: String?, - val votingPower: Double?, + val image: String? = null, + val website: String? = null, + val apr: SerializedBigDecimal? = null, + val commission: Double? = null, + val stakedBalance: String? = null, + val votingPower: Double? = null, val preferred: Boolean, - ) + ) { + + enum class ValidatorStatus { + ACTIVE, + DEACTIVATING, + INACTIVE, + JAILED, + UNKNOWN, + } + } @Serializable data class Metadata( diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt index 35072a4cb1..6c93dfccee 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt @@ -1,13 +1,19 @@ package com.tangem.domain.staking.model.stakekit import com.tangem.domain.staking.model.stakekit.action.StakingActionType +import org.joda.time.DateTime import java.math.BigDecimal sealed class YieldBalance { data class Data( val balance: YieldBalanceItem, + val address: String, ) : YieldBalance() { + fun getTotalWithRewardsStakingBalance(): BigDecimal { + return balance.items.sumOf { it.amount } + } + fun getTotalStakingBalance(): BigDecimal { return balance.items .filterNot { it.type == BalanceType.REWARDS } @@ -19,6 +25,13 @@ sealed class YieldBalance { .filter { it.type == BalanceType.REWARDS } .sumOf { it.amount } } + + fun getValidatorsCount(): Int { + return balance.items + .filterNot { it.validatorAddress.isNullOrBlank() } + .distinctBy { it.validatorAddress } + .size + } } data object Empty : YieldBalance() @@ -32,12 +45,14 @@ data class YieldBalanceItem( ) data class BalanceItem( + val id: String, val type: BalanceType, val amount: BigDecimal, val pricePerShare: BigDecimal, val rawCurrencyId: String?, val rawNetworkId: String, val validatorAddress: String?, + val date: DateTime?, val pendingActions: List, ) @@ -73,14 +88,42 @@ data class PendingAction( } } -enum class BalanceType { - AVAILABLE, - STAKED, - UNSTAKING, - UNSTAKED, - PREPARING, - REWARDS, - LOCKED, - UNLOCKING, - UNKNOWN, +/** + * IMPORTANT!!! + * Order is used to sort balances. + */ +@Suppress("MagicNumber") +enum class BalanceType(val order: Int) { + AVAILABLE(1), + STAKED(2), + PREPARING(3), + LOCKED(4), + UNSTAKING(5), + UNLOCKING(6), + UNSTAKED(7), + REWARDS(8), + UNKNOWN(9), + ; + + companion object { + fun BalanceType.isClickable() = when (this) { + STAKED, + UNSTAKED, + LOCKED, + -> true + AVAILABLE, + UNSTAKING, + PREPARING, + REWARDS, + UNLOCKING, + UNKNOWN, + -> false + } + } +} + +enum class RewardBlockType { + NoRewards, + Rewards, + RewardUnavailable, } \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalanceList.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalanceList.kt index 01cc23bdc9..02f20f6e1c 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalanceList.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalanceList.kt @@ -5,13 +5,13 @@ sealed class YieldBalanceList { data class Data( val balances: List, ) : YieldBalanceList() { - fun getBalance(rawCurrencyId: String?, networkName: String): YieldBalance { - return balances.firstOrNull { yield -> - (yield as? YieldBalance.Data)?.balance?.items - ?.any { - rawCurrencyId == it.rawCurrencyId && - networkName.equals(it.rawNetworkId, ignoreCase = true) - } == true + + fun getBalance(address: String?, rawCurrencyId: String?): YieldBalance { + return balances.firstOrNull { yieldBalance -> + val data = yieldBalance as? YieldBalance.Data + data?.balance?.items?.any { + address == data.address && rawCurrencyId == it.rawCurrencyId + } == true } ?: YieldBalance.Error } } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt index 333762d0e1..e09a9309c7 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt @@ -6,7 +6,7 @@ import arrow.core.raise.either import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakingRepository -import com.tangem.domain.tokens.model.CryptoCurrencyAddress +import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId class FetchStakingYieldBalanceUseCase( @@ -16,7 +16,7 @@ class FetchStakingYieldBalanceUseCase( suspend operator fun invoke( userWalletId: UserWalletId, - address: CryptoCurrencyAddress, + cryptoCurrency: CryptoCurrency, refresh: Boolean = false, ): Either { return either { @@ -24,7 +24,7 @@ class FetchStakingYieldBalanceUseCase( block = { stakingRepository.fetchSingleYieldBalance( userWalletId = userWalletId, - address = address, + cryptoCurrency = cryptoCurrency, refresh = refresh, ) }, diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingAvailabilityUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingAvailabilityUseCase.kt index 46a2f010be..7133219a03 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingAvailabilityUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingAvailabilityUseCase.kt @@ -6,6 +6,7 @@ import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId /** * Use case for getting info about staking capability in tangem app. @@ -16,11 +17,16 @@ class GetStakingAvailabilityUseCase( ) { suspend operator fun invoke( - cryptoCurrencyId: CryptoCurrency.ID, - symbol: String, + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, ): Either { return Either - .catch { stakingRepository.getStakingAvailabilityForActions(cryptoCurrencyId, symbol) } + .catch { + stakingRepository.getStakingAvailability( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) + } .mapLeft { stakingErrorResolver.resolve(it) } } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingYieldBalanceUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingYieldBalanceUseCase.kt deleted file mode 100644 index 5c5528b931..0000000000 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingYieldBalanceUseCase.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.tangem.domain.staking - -import arrow.core.Either -import arrow.core.left -import arrow.core.right -import com.tangem.domain.core.utils.EitherFlow -import com.tangem.domain.staking.model.stakekit.StakingError -import com.tangem.domain.staking.model.stakekit.YieldBalance -import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository -import com.tangem.domain.tokens.model.CryptoCurrencyAddress -import com.tangem.domain.wallets.models.UserWalletId -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.map - -class GetStakingYieldBalanceUseCase( - private val stakingRepository: StakingRepository, - private val stakingErrorResolver: StakingErrorResolver, -) { - - operator fun invoke( - userWalletId: UserWalletId, - address: CryptoCurrencyAddress, - ): EitherFlow { - return stakingRepository.getSingleYieldBalanceFlow( - userWalletId = userWalletId, - address = address, - ).map> { it.right() } - .catch { emit(stakingErrorResolver.resolve(it).left()) } - } -} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/IsStakeMoreAvailableUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/IsAnyTokenStakedUseCase.kt similarity index 64% rename from domain/staking/src/main/java/com/tangem/domain/staking/IsStakeMoreAvailableUseCase.kt rename to domain/staking/src/main/java/com/tangem/domain/staking/IsAnyTokenStakedUseCase.kt index 4dd7fc8f38..33f4c0bc1d 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/IsStakeMoreAvailableUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/IsAnyTokenStakedUseCase.kt @@ -4,16 +4,15 @@ import arrow.core.Either import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakingRepository -import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId -class IsStakeMoreAvailableUseCase( +class IsAnyTokenStakedUseCase( private val stakingRepository: StakingRepository, private val stakingErrorResolver: StakingErrorResolver, ) { - - operator fun invoke(networkId: Network.ID): Either { + suspend operator fun invoke(userWalletId: UserWalletId): Either { return Either - .catch { stakingRepository.isStakeMoreAvailable(networkId) } + .catch { stakingRepository.isAnyTokenStaked(userWalletId) } .mapLeft { stakingErrorResolver.resolve(it) } } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/SaveUnsubmittedHashUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/SaveUnsubmittedHashUseCase.kt index 0a83c395cb..abfeab0d56 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/SaveUnsubmittedHashUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/SaveUnsubmittedHashUseCase.kt @@ -4,19 +4,19 @@ import arrow.core.Either import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.repositories.StakingTransactionHashRepository /** * Use case for saving hash that failed to submit during staking confirmation */ class SaveUnsubmittedHashUseCase( - private val stakingRepository: StakingRepository, + private val stakingTransactionHashRepository: StakingTransactionHashRepository, private val stakingErrorResolver: StakingErrorResolver, ) { suspend operator fun invoke(transactionId: String, transactionHash: String): Either { return Either.catch { - stakingRepository.storeUnsubmittedHash( + stakingTransactionHashRepository.storeUnsubmittedHash( unsubmittedTransactionMetadata = UnsubmittedTransactionMetadata( transactionId = transactionId, transactionHash = transactionHash, diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/SendUnsubmittedHashesUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/SendUnsubmittedHashesUseCase.kt index 95f3ad9eea..3564651d9f 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/SendUnsubmittedHashesUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/SendUnsubmittedHashesUseCase.kt @@ -3,19 +3,19 @@ package com.tangem.domain.staking import arrow.core.Either import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.repositories.StakingTransactionHashRepository /** * Use case for commiting hashes that failed to submit during staking confirmation */ class SendUnsubmittedHashesUseCase( - private val stakingRepository: StakingRepository, + private val stakingTransactionHashRepository: StakingTransactionHashRepository, private val stakingErrorResolver: StakingErrorResolver, ) { suspend operator fun invoke(): Either { return Either - .catch { stakingRepository.sendUnsubmittedHashes() } + .catch { stakingTransactionHashRepository.sendUnsubmittedHashes() } .mapLeft { stakingErrorResolver.resolve(it) } } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/SubmitHashUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/SubmitHashUseCase.kt index d810cfac8c..83d1b151b7 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/SubmitHashUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/SubmitHashUseCase.kt @@ -3,20 +3,20 @@ package com.tangem.domain.staking import arrow.core.Either import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.repositories.StakingTransactionHashRepository /** * Use case for submitting transaction hash to stakekit */ class SubmitHashUseCase( - private val stakingRepository: StakingRepository, + private val stakingTransactionHashRepository: StakingTransactionHashRepository, private val stakingErrorResolver: StakingErrorResolver, ) { suspend fun submitHash(transactionId: String, transactionHash: String): Either { return Either .catch { - stakingRepository.submitHash( + stakingTransactionHashRepository.submitHash( transactionId = transactionId, transactionHash = transactionHash, ) diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt index 5b6604423e..ba93d3e3cb 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt @@ -6,7 +6,6 @@ import com.tangem.domain.core.lce.LceFlow import com.tangem.domain.staking.model.StakingApproval import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingEntryInfo -import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList @@ -15,7 +14,6 @@ import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.tokens.model.CryptoCurrencyAddress import com.tangem.domain.tokens.model.Network import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow @@ -23,6 +21,8 @@ import kotlinx.coroutines.flow.Flow @Suppress("TooManyFunctions") interface StakingRepository { + fun getIntegrationKey(cryptoCurrencyId: CryptoCurrency.ID): String + fun isStakingSupported(integrationKey: String): Boolean suspend fun fetchEnabledYields(refresh: Boolean) @@ -31,40 +31,37 @@ interface StakingRepository { suspend fun getYield(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): Yield - suspend fun getStakingAvailabilityForActions( - cryptoCurrencyId: CryptoCurrency.ID, - symbol: String, - ): StakingAvailability + suspend fun getStakingAvailability(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): StakingAvailability suspend fun fetchSingleYieldBalance( userWalletId: UserWalletId, - address: CryptoCurrencyAddress, + cryptoCurrency: CryptoCurrency, refresh: Boolean = false, ) - fun getSingleYieldBalanceFlow(userWalletId: UserWalletId, address: CryptoCurrencyAddress): Flow + fun getSingleYieldBalanceFlow(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Flow - suspend fun getSingleYieldBalanceSync(userWalletId: UserWalletId, address: CryptoCurrencyAddress): YieldBalance + suspend fun getSingleYieldBalanceSync(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): YieldBalance suspend fun fetchMultiYieldBalance( userWalletId: UserWalletId, - addresses: List, + cryptoCurrencies: List, refresh: Boolean = false, ) fun getMultiYieldBalanceFlow( userWalletId: UserWalletId, - addresses: List, + cryptoCurrencies: List, ): Flow fun getMultiYieldBalanceLce( userWalletId: UserWalletId, - addresses: List, + cryptoCurrencies: List, ): LceFlow suspend fun getMultiYieldBalanceSync( userWalletId: UserWalletId, - addresses: List, + cryptoCurrencies: List, ): YieldBalanceList suspend fun createAction(userWalletId: UserWalletId, network: Network, params: ActionParams): StakingAction @@ -77,15 +74,8 @@ interface StakingRepository { transactionId: String, ): Pair - suspend fun submitHash(transactionId: String, transactionHash: String) - - suspend fun storeUnsubmittedHash(unsubmittedTransactionMetadata: UnsubmittedTransactionMetadata) - - suspend fun sendUnsubmittedHashes() - - /** Returns whether additional staking is possible if there is already active staking */ - fun isStakeMoreAvailable(networkId: Network.ID): Boolean - /** Returns staking approval */ fun getStakingApproval(cryptoCurrency: CryptoCurrency): StakingApproval + + suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingTransactionHashRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingTransactionHashRepository.kt new file mode 100644 index 0000000000..be75380a84 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingTransactionHashRepository.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.staking.repositories + +import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata + +interface StakingTransactionHashRepository { + + suspend fun submitHash(transactionId: String, transactionHash: String) + + suspend fun storeUnsubmittedHash(unsubmittedTransactionMetadata: UnsubmittedTransactionMetadata) + + suspend fun sendUnsubmittedHashes() +} \ No newline at end of file diff --git a/domain/tokens/build.gradle.kts b/domain/tokens/build.gradle.kts index bbe01868f9..a1c639300b 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { /** Project - Api */ implementation(projects.features.send.api) implementation(projects.features.staking.api) + implementation(projects.features.markets.api) /** Project - Other */ implementation(projects.core.utils) diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Network.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Network.kt index 59d8d3a517..3543b7094b 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Network.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Network.kt @@ -29,6 +29,7 @@ data class Network( val isTestnet: Boolean, val standardType: StandardType, val hasFiatFeeRate: Boolean, + val canHandleTokens: Boolean, ) { init { diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/ScenarioUnavailabilityReason.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/ScenarioUnavailabilityReason.kt similarity index 100% rename from domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/ScenarioUnavailabilityReason.kt rename to domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/ScenarioUnavailabilityReason.kt diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/TotalFiatBalance.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/TotalFiatBalance.kt similarity index 100% rename from domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/TotalFiatBalance.kt rename to domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/TotalFiatBalance.kt diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt index b89306aded..ae26e772f5 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt @@ -1,34 +1,43 @@ package com.tangem.domain.tokens.model.analytics import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.PLACE +import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER +import com.tangem.core.analytics.models.AnalyticsParam.Key.STATUS +import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_CATEGORY +import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM class TokenExchangeAnalyticsEvent( event: String, params: Map = mapOf(), -) : AnalyticsEvent("Token", event, params, null) { +) : AnalyticsEvent(TOKEN_CATEGORY, event, params, null) { class CexTxStatusOpened(token: String) : TokenScreenAnalyticsEvent( event = "Swap Status Opened", - params = mapOf("Token" to token), + params = mapOf(TOKEN_PARAM to token), ) - class CexTxStatusChanged(token: String, status: String) : TokenScreenAnalyticsEvent( + class CexTxStatusChanged(token: String, status: String, provider: String) : TokenScreenAnalyticsEvent( event = "Swap Status", - params = mapOf("Token" to token, "Status" to status), + params = mapOf( + TOKEN_PARAM to token, + STATUS to status, + PROVIDER to provider, + ), ) class GoToProviderStatus(token: String) : TokenScreenAnalyticsEvent( event = "Button - Go To Provider", - params = mapOf("Token" to token, "Place" to "Status"), + params = mapOf(TOKEN_PARAM to token, PLACE to "Status"), ) class GoToProviderKYC(token: String) : TokenScreenAnalyticsEvent( event = "Button - Go To Provider", - params = mapOf("Token" to token, "Place" to "KYC"), + params = mapOf(TOKEN_PARAM to token, PLACE to "KYC"), ) class GoToProviderFail(token: String) : TokenScreenAnalyticsEvent( event = "Button - Go To Provider", - params = mapOf("Token" to token, "Place" to "Fail"), + params = mapOf(TOKEN_PARAM to token, PLACE to "Fail"), ) } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt index 1896d2c250..bea218c179 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt @@ -77,4 +77,9 @@ sealed class TokenScreenAnalyticsEvent( event = "Button - Token Trustline", params = mapOf("Token" to tokenSymbol, "Blockchain" to blockchain), ) + + data class StakingClicked(val token: String) : TokenScreenAnalyticsEvent( + event = "Staking Clicked", + params = mapOf("Token" to token), + ) } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyCheck.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyCheck.kt new file mode 100644 index 0000000000..04018ed4aa --- /dev/null +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyCheck.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.tokens.model.warnings + +import com.tangem.domain.tokens.model.blockchains.UtxoAmountLimit +import java.math.BigDecimal + +data class CryptoCurrencyCheck( + val dustValue: BigDecimal?, + val reserveAmount: BigDecimal?, + val existentialDeposit: BigDecimal?, + val utxoAmountLimit: UtxoAmountLimit?, +) \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/CheckHasLinkedTokensUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/CheckHasLinkedTokensUseCase.kt deleted file mode 100644 index 503731cabc..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/CheckHasLinkedTokensUseCase.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.domain.tokens - -import arrow.core.Either -import com.tangem.domain.tokens.model.Network -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.wallets.models.UserWalletId - -class CheckHasLinkedTokensUseCase( - private val currenciesRepository: CurrenciesRepository, -) { - - suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either { - return Either.catch { - currenciesRepository.hasTokens(userWalletId, network) - } - } -} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt index ca9266b2d1..e40ecd7e98 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt @@ -44,6 +44,7 @@ class FetchCardTokenListUseCase( val yieldBalances = async { fetchYieldBalances( userWalletId = userWalletId, + currencies = currencies, refresh = refresh, ) } @@ -77,10 +78,13 @@ class FetchCardTokenListUseCase( ) } - private suspend fun fetchYieldBalances(userWalletId: UserWalletId, refresh: Boolean) { - val networkAddresses = networksRepository.getNetworkAddresses(userWalletId) + private suspend fun fetchYieldBalances( + userWalletId: UserWalletId, + currencies: List, + refresh: Boolean, + ) { catch( - block = { stakingRepository.fetchMultiYieldBalance(userWalletId, networkAddresses, refresh) }, + block = { stakingRepository.fetchMultiYieldBalance(userWalletId, currencies, refresh) }, catch = { /* Ignore error */ }, ) } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt index 8fbd29d4f5..7b631669e7 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt @@ -4,6 +4,7 @@ import arrow.core.Either import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either +import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network @@ -29,6 +30,7 @@ class FetchCurrencyStatusUseCase( private val currenciesRepository: CurrenciesRepository, private val networksRepository: NetworksRepository, private val quotesRepository: QuotesRepository, + private val stakingRepository: StakingRepository, ) { /** @@ -80,8 +82,11 @@ class FetchCurrencyStatusUseCase( val fetchQuote = async { fetchQuote(currency.id, refresh) } + val fetchStakingBalance = async { + fetchStakingBalance(userWalletId, currency, refresh) + } - awaitAll(fetchStatus, fetchQuote) + awaitAll(fetchStatus, fetchQuote, fetchStakingBalance) } private suspend fun Raise.getCurrency( @@ -122,4 +127,16 @@ class FetchCurrencyStatusUseCase( raise(CurrencyStatusError.DataError(it)) } } + + private suspend fun Raise.fetchStakingBalance( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + refresh: Boolean, + ) { + catch( + block = { stakingRepository.fetchSingleYieldBalance(userWalletId, cryptoCurrency, refresh) }, + ) { + raise(CurrencyStatusError.DataError(it)) + } + } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt index 0fe7e8b345..394cd7c1dc 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt @@ -6,6 +6,7 @@ import arrow.core.raise.catch import arrow.core.raise.either import arrow.core.raise.ensureNotNull import arrow.core.toNonEmptyListOrNull +import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network @@ -24,12 +25,14 @@ import kotlinx.coroutines.coroutineScope * @param currenciesRepository The repository for retrieving currency-related data. * @param networksRepository The repository for retrieving network-related data. * @param quotesRepository The repository for retrieving cryptocurrency quotes. + * @param stakingRepository The repository for retrieving staking-related data. */ // TODO: Add tests class FetchTokenListUseCase( private val currenciesRepository: CurrenciesRepository, private val networksRepository: NetworksRepository, private val quotesRepository: QuotesRepository, + private val stakingRepository: StakingRepository, ) { /** @@ -59,7 +62,15 @@ class FetchTokenListUseCase( ) } - awaitAll(fetchStatuses, fetchQuotes) + val yieldBalances = async { + fetchYieldBalances( + userWalletId = userWalletId, + currencies = currencies, + refresh = refresh, + ) + } + + awaitAll(fetchStatuses, fetchQuotes, yieldBalances) } } } @@ -98,4 +109,15 @@ class FetchTokenListUseCase( /* Ignore error */ } } + + private suspend fun fetchYieldBalances( + userWalletId: UserWalletId, + currencies: List, + refresh: Boolean, + ) { + catch( + block = { stakingRepository.fetchMultiYieldBalance(userWalletId, currencies, refresh) }, + catch = { /* Ignore error */ }, + ) + } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetAllWalletsCryptoCurrencyStatusesUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetAllWalletsCryptoCurrencyStatusesUseCase.kt new file mode 100644 index 0000000000..816433f7be --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetAllWalletsCryptoCurrencyStatusesUseCase.kt @@ -0,0 +1,66 @@ +package com.tangem.domain.tokens + +import arrow.core.Either +import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.tokens.error.CurrencyStatusError +import com.tangem.domain.tokens.error.mapper.mapToCurrencyError +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.NetworksRepository +import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.domain.wallets.models.UserWallet +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* + +/** + * Get crypto currency statuses by raw ID for all wallets + * + * @property currenciesRepository currencies repository + * @property quotesRepository quotes repository + * @property networksRepository networks repository + * @property stakingRepository staking repository + * +[REDACTED_AUTHOR] + */ +class GetAllWalletsCryptoCurrencyStatusesUseCase( + private val currenciesRepository: CurrenciesRepository, + private val quotesRepository: QuotesRepository, + private val networksRepository: NetworksRepository, + private val stakingRepository: StakingRepository, +) { + + /** + * Get crypto currency statuses by [currencyRawId] for all wallets + * + * @param currencyRawId currency raw ID + */ + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke( + currencyRawId: String, + ): Flow>>> { + return currenciesRepository.getAllWalletsCryptoCurrencies(currencyRawId) + .flatMapLatest { userWalletsWithCurrencies: Map> -> + val walletStatusFlows = userWalletsWithCurrencies.map { (userWallet, cryptoCurrencies) -> + val operations = CurrenciesStatusesOperations( + userWalletId = userWallet.walletId, + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + stakingRepository = stakingRepository, + ) + + val currencyStatusFlows = cryptoCurrencies.map { cryptoCurrency -> + operations.getCurrencyStatusFlow(cryptoCurrency) + .map { it.mapLeft(CurrenciesStatusesOperations.Error::mapToCurrencyError) } + } + + combine(currencyStatusFlows) { statuses -> userWallet to statuses.toList() } + .onEmpty { emit(userWallet to emptyList()) } + } + + combine(walletStatusFlows) { it.toMap() } + } + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt index c83af879ba..c1ac067bff 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt @@ -12,6 +12,7 @@ import com.tangem.domain.tokens.repository.NetworksRepository import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.markets.MarketsFeatureToggles import com.tangem.features.staking.api.featuretoggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.isNullOrZero @@ -33,6 +34,7 @@ class GetCryptoCurrencyActionsUseCase( private val networksRepository: NetworksRepository, private val stakingRepository: StakingRepository, private val stakingFeatureToggles: StakingFeatureToggles, + private val marketsFeatureToggles: MarketsFeatureToggles, private val dispatchers: CoroutineDispatcherProvider, ) { @@ -112,6 +114,12 @@ class GetCryptoCurrencyActionsUseCase( val activeList = mutableListOf() val disabledList = mutableListOf() + // markets + // not a custom token + if (marketsFeatureToggles.isFeatureEnabled && cryptoCurrencyStatus.currency.id.rawCurrencyId != null) { + activeList.add(TokenActionsState.ActionState.Analytics(ScenarioUnavailabilityReason.None)) + } + // copy address if (isAddressAvailable(cryptoCurrencyStatus.value.networkAddress)) { activeList.add(TokenActionsState.ActionState.CopyAddress(ScenarioUnavailabilityReason.None)) @@ -129,12 +137,24 @@ class GetCryptoCurrencyActionsUseCase( // staking if (stakingFeatureToggles.isStakingEnabled) { - if (isStakingAvailable(cryptoCurrency)) { - activeList.add(TokenActionsState.ActionState.Stake(ScenarioUnavailabilityReason.None)) + if (isStakingAvailable(userWallet, cryptoCurrency)) { + val yield = kotlin.runCatching { + stakingRepository.getYield( + cryptoCurrencyId = cryptoCurrency.id, + symbol = cryptoCurrency.symbol, + ) + }.getOrNull() + activeList.add( + TokenActionsState.ActionState.Stake( + unavailabilityReason = ScenarioUnavailabilityReason.None, + yield = yield, + ), + ) } else { disabledList.add( TokenActionsState.ActionState.Stake( unavailabilityReason = ScenarioUnavailabilityReason.StakingUnavailable(cryptoCurrency.name), + yield = null, ), ) } @@ -254,7 +274,7 @@ class GetCryptoCurrencyActionsUseCase( actionsList.add(TokenActionsState.ActionState.Receive(scenario)) } if (stakingFeatureToggles.isStakingEnabled) { - actionsList.add(TokenActionsState.ActionState.Stake(ScenarioUnavailabilityReason.Unreachable)) + actionsList.add(TokenActionsState.ActionState.Stake(ScenarioUnavailabilityReason.Unreachable, null)) } actionsList.add(TokenActionsState.ActionState.HideToken(ScenarioUnavailabilityReason.None)) @@ -288,10 +308,10 @@ class GetCryptoCurrencyActionsUseCase( return networkAddress != null && networkAddress.defaultAddress.value.isNotEmpty() } - private suspend fun isStakingAvailable(cryptoCurrency: CryptoCurrency): Boolean { - return stakingRepository.getStakingAvailabilityForActions( - cryptoCurrencyId = cryptoCurrency.id, - symbol = cryptoCurrency.symbol, + private suspend fun isStakingAvailable(userWallet: UserWallet, cryptoCurrency: CryptoCurrency): Boolean { + return stakingRepository.getStakingAvailability( + userWalletId = userWallet.walletId, + cryptoCurrency = cryptoCurrency, ) is StakingAvailability.Available } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyCheckUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyCheckUseCase.kt new file mode 100644 index 0000000000..4e74a21591 --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyCheckUseCase.kt @@ -0,0 +1,41 @@ +package com.tangem.domain.tokens + +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.tokens.repository.CurrencyChecksRepository +import com.tangem.domain.wallets.models.UserWalletId +import java.math.BigDecimal + +class GetCurrencyCheckUseCase( + private val currencyChecksRepository: CurrencyChecksRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + currencyStatus: CryptoCurrencyStatus, + amount: BigDecimal?, + fee: BigDecimal?, + ): CryptoCurrencyCheck { + val network = currencyStatus.currency.network + val dustValue = currencyChecksRepository.getDustValue(userWalletId, network) + val reserveAmount = currencyChecksRepository.getReserveAmount(userWalletId, network) + val existentialDeposit = currencyChecksRepository.getExistentialDeposit(userWalletId, network) + val utxoAmountLimit = if (amount != null && fee != null) { + currencyChecksRepository.checkUtxoAmountLimit( + userWalletId = userWalletId, + network = network, + amount = amount, + fee = fee, + ) + } else { + null + } + + return CryptoCurrencyCheck( + dustValue = dustValue, + reserveAmount = reserveAmount, + existentialDeposit = existentialDeposit, + utxoAmountLimit = utxoAmountLimit, + ) + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyStatusUpdatesUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyStatusUpdatesUseCase.kt index dceb8551cf..f74a62b2d4 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyStatusUpdatesUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyStatusUpdatesUseCase.kt @@ -44,7 +44,7 @@ class GetCurrencyStatusUpdatesUseCase( ): Flow> { return flow { emitAll( - getCurrency( + getCurrencyStatus( userWalletId = userWalletId, currencyId = currencyId, isSingleWalletWithTokens = isSingleWalletWithTokens, @@ -53,7 +53,7 @@ class GetCurrencyStatusUpdatesUseCase( }.flowOn(dispatchers.io) } - private suspend fun getCurrency( + private suspend fun getCurrencyStatus( userWalletId: UserWalletId, currencyId: CryptoCurrency.ID, isSingleWalletWithTokens: Boolean, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCardTokensListUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetNodlTokenListUseCase.kt similarity index 90% rename from domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCardTokensListUseCase.kt rename to domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetNodlTokenListUseCase.kt index 698c3be4f5..abbe7fa327 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCardTokensListUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetNodlTokenListUseCase.kt @@ -18,7 +18,15 @@ import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.transformLatest -class GetCardTokensListUseCase( +/** + * Use case for getting a list of tokens for NODL card + * + * @property currenciesRepository currencies repository + * @property quotesRepository quotes repository + * @property networksRepository networks repository + * @property stakingRepository staking repository + */ +class GetNodlTokenListUseCase( private val currenciesRepository: CurrenciesRepository, private val quotesRepository: QuotesRepository, private val networksRepository: NetworksRepository, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetWalletTotalBalanceUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetWalletTotalBalanceUseCase.kt index 0f4d3477da..012dd2847a 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetWalletTotalBalanceUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetWalletTotalBalanceUseCase.kt @@ -29,7 +29,7 @@ class GetWalletTotalBalanceUseCase( private val stakingRepository: StakingRepository, ) { - suspend operator fun invoke( + operator fun invoke( userTallestIds: Collection, ): LceFlow> { val flows = userTallestIds.distinct() @@ -54,7 +54,7 @@ class GetWalletTotalBalanceUseCase( } @OptIn(ExperimentalCoroutinesApi::class) - suspend operator fun invoke(userWalletId: UserWalletId): LceFlow { + operator fun invoke(userWalletId: UserWalletId): LceFlow { val currenciesStatuses = getStatuses(userWalletId) return currenciesStatuses.transformLatest { maybeStatuses -> diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RefreshMultiCurrencyWalletQuotesUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RefreshMultiCurrencyWalletQuotesUseCase.kt index e108b21819..aeba9d2c14 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RefreshMultiCurrencyWalletQuotesUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RefreshMultiCurrencyWalletQuotesUseCase.kt @@ -20,7 +20,7 @@ class RefreshMultiCurrencyWalletQuotesUseCase( suspend operator fun invoke(userWalletId: UserWalletId): Either { return either { - val currencies = fetchCurrencies(userWalletId = userWalletId) + val currencies = getCurrencies(userWalletId = userWalletId) .getOrElse { raise(QuotesError.DataError(it)) } coroutineScope { @@ -35,10 +35,10 @@ class RefreshMultiCurrencyWalletQuotesUseCase( } } - private suspend fun fetchCurrencies(userWalletId: UserWalletId): Either> { + private suspend fun getCurrencies(userWalletId: UserWalletId): Either> { return either { catch( - block = { currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId, false) }, + block = { currenciesRepository.getMultiCurrencyWalletCachedCurrenciesSync(userWalletId) }, catch = { raise(it) }, ) } @@ -46,7 +46,9 @@ class RefreshMultiCurrencyWalletQuotesUseCase( private suspend fun fetchQuotes(currenciesIds: Set) { catch( - block = { quotesRepository.fetchQuotes(currenciesIds) }, + block = { + quotesRepository.fetchQuotes(currenciesIds) + }, catch = { /* Ignore error */ }, ) } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/TokenListError.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/TokenListError.kt index 0a321a7874..5332319220 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/TokenListError.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/TokenListError.kt @@ -4,7 +4,7 @@ import com.tangem.domain.tokens.model.TokenList sealed class TokenListError { - object EmptyTokens : TokenListError() + data object EmptyTokens : TokenListError() data class UnableToSortTokenList(val unsortedTokenList: TokenList.Ungrouped) : TokenListError() diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/CurrencyStatusErrorMappers.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/CurrencyStatusErrorMappers.kt index 000588f24b..7fb8c805ca 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/CurrencyStatusErrorMappers.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/CurrencyStatusErrorMappers.kt @@ -10,6 +10,7 @@ internal fun CurrenciesStatusesOperations.Error.mapToCurrencyError(): CurrencySt is CurrenciesStatusesOperations.Error.EmptyNetworksStatuses, is CurrenciesStatusesOperations.Error.EmptyQuotes, is CurrenciesStatusesOperations.Error.EmptyCurrencies, + is CurrenciesStatusesOperations.Error.EmptyAddresses, is CurrenciesStatusesOperations.Error.UnableToCreateCurrencyStatus, -> CurrencyStatusError.UnableToCreateCurrency } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/TokenListErrorMappers.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/TokenListErrorMappers.kt index 4f918d6a79..5e4071d5b1 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/TokenListErrorMappers.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/TokenListErrorMappers.kt @@ -10,6 +10,7 @@ internal fun CurrenciesStatusesOperations.Error.mapToTokenListError(): TokenList is CurrenciesStatusesOperations.Error.EmptyNetworksStatuses, is CurrenciesStatusesOperations.Error.EmptyQuotes, is CurrenciesStatusesOperations.Error.EmptyCurrencies, + is CurrenciesStatusesOperations.Error.EmptyAddresses, is CurrenciesStatusesOperations.Error.UnableToCreateCurrencyStatus, is CurrenciesStatusesOperations.Error.EmptyYieldBalances, -> TokenListError.EmptyTokens diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/legacy/TradeCryptoAction.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/legacy/TradeCryptoAction.kt index 0b04bf321b..b32c7a091f 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/legacy/TradeCryptoAction.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/legacy/TradeCryptoAction.kt @@ -1,10 +1,8 @@ package com.tangem.domain.tokens.legacy -import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWallet -import com.tangem.domain.wallets.models.UserWalletId import org.rekotlin.Action import java.math.BigDecimal @@ -24,6 +22,7 @@ sealed class TradeCryptoAction : Action { val appCurrencyCode: String, ) : TradeCryptoAction() + @Deprecated("Use AppRoute instead") data class SendToken( val userWallet: UserWallet, val tokenCurrency: CryptoCurrency.Token, @@ -33,6 +32,7 @@ sealed class TradeCryptoAction : Action { val transactionInfo: TransactionInfo? = null, ) : TradeCryptoAction() + @Deprecated("Use AppRoute instead") data class SendCoin( val userWallet: UserWallet, val coinStatus: CryptoCurrencyStatus, @@ -40,14 +40,6 @@ sealed class TradeCryptoAction : Action { val transactionInfo: TransactionInfo? = null, ) : TradeCryptoAction() - data class Swap(val cryptoCurrency: CryptoCurrency) : TradeCryptoAction() - - data class Stake( - val userWalletId: UserWalletId, - val cryptoCurrencyId: CryptoCurrency.ID, - val yield: Yield, - ) : TradeCryptoAction() - data class TransactionInfo( val transactionId: String, val destinationAddress: String, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/TokenActionsState.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/TokenActionsState.kt index a3dbea0959..2531bc3ee2 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/TokenActionsState.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/TokenActionsState.kt @@ -1,5 +1,6 @@ package com.tangem.domain.tokens.model +import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.wallets.models.UserWalletId data class TokenActionsState( @@ -20,12 +21,17 @@ data class TokenActionsState( data class Receive(override val unavailabilityReason: ScenarioUnavailabilityReason) : ActionState() - data class Stake(override val unavailabilityReason: ScenarioUnavailabilityReason) : ActionState() + data class Stake( + override val unavailabilityReason: ScenarioUnavailabilityReason, + val yield: Yield?, + ) : ActionState() data class Swap(override val unavailabilityReason: ScenarioUnavailabilityReason) : ActionState() data class Send(override val unavailabilityReason: ScenarioUnavailabilityReason) : ActionState() + data class Analytics(override val unavailabilityReason: ScenarioUnavailabilityReason) : ActionState() + data class HideToken(override val unavailabilityReason: ScenarioUnavailabilityReason) : ActionState() } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesLceOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesLceOperations.kt index 18f07bea3e..ff2271edbb 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesLceOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesLceOperations.kt @@ -69,11 +69,11 @@ internal class CurrenciesStatusesLceOperations( val (networks, currenciesIds) = getIds(nonEmptyCurrencies) - val addresses = networksRepository.getNetworkAddresses(userWalletId) combine( getQuotes(currenciesIds), getNetworksStatuses(userWalletId, networks), - getYieldBalances(userWalletId, addresses), + getYieldBalances(userWalletId, nonEmptyCurrencies), + ) { maybeQuotes, maybeNetworksStatuses, maybeYieldBalances -> val statuses = createCurrenciesStatuses( currencies = nonEmptyCurrencies, @@ -145,10 +145,18 @@ internal class CurrenciesStatusesLceOperations( currencies.map { currency -> val quote = quotes?.firstOrNull { it.rawCurrencyId == currency.id.rawCurrencyId } val networkStatus = networksStatuses?.firstOrNull { it.network == currency.network } - val yieldBalance = (yieldBalances as? YieldBalanceList.Data)?.getBalance( - rawCurrencyId = currency.id.rawCurrencyId, - networkName = currency.network.name, + val address = extractAddress(networkStatus) + val isStakingSupported = stakingRepository.isStakingSupported( + stakingRepository.getIntegrationKey(currency.id), ) + val yieldBalance = if (isStakingSupported) { + (yieldBalances as? YieldBalanceList.Data)?.getBalance( + address = address, + rawCurrencyId = currency.id.rawCurrencyId, + ) + } else { + null + } createCurrencyStatus( currency = currency, @@ -196,11 +204,11 @@ internal class CurrenciesStatusesLceOperations( private fun getYieldBalances( userWalletId: UserWalletId, - addresses: List, + cryptoCurrencies: List, ): LceFlow { return stakingRepository.getMultiYieldBalanceLce( userWalletId = userWalletId, - addresses = addresses, + cryptoCurrencies = cryptoCurrencies, ).map { maybeBalances -> maybeBalances.mapError { TokenListError.DataError(it) } } @@ -218,4 +226,13 @@ internal class CurrenciesStatusesLceOperations( return networks to currenciesIds } + + private fun extractAddress(networkStatus: NetworkStatus?): String? { + return when (val value = networkStatus?.value) { + is NetworkStatus.NoAccount -> value.address.defaultAddress.value + is NetworkStatus.Unreachable -> value.address?.defaultAddress?.value + is NetworkStatus.Verified -> value.address.defaultAddress.value + else -> null + } + } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt index fd9870c235..bea881092e 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt @@ -11,7 +11,6 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.NetworksRepository import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.wallets.models.UserWalletId -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* // FIXME: Refactor - [REDACTED_JIRA] @@ -35,9 +34,14 @@ internal class CurrenciesStatusesOperations( val quotes = quotesRepository.getQuotesSync(currenciesIds, false).right() val networkStatuses = networksRepository.getNetworkStatusesSync(userWalletId, networks, false).right() - val yieldBalances = getYieldBalancesSync() + val yieldBalances = getYieldBalancesSync(nonEmptyCurrencies) - return createCurrenciesStatuses(nonEmptyCurrencies, quotes, networkStatuses, yieldBalances) + return createCurrenciesStatuses( + nonEmptyCurrencies, + quotes, + networkStatuses, + yieldBalances, + ) }, catch = { raise(Error.DataError(it)) }, ) @@ -147,9 +151,14 @@ internal class CurrenciesStatusesOperations( val currenciesFlow = combine( getQuotes(currenciesIds), getNetworksStatuses(networks), - getYieldBalances(), + getYieldBalances(nonEmptyCurrencies), ) { maybeQuotes, maybeNetworksStatuses, maybeYieldBalances -> - createCurrenciesStatuses(nonEmptyCurrencies, maybeQuotes, maybeNetworksStatuses, maybeYieldBalances) + createCurrenciesStatuses( + currencies = nonEmptyCurrencies, + maybeQuotes = maybeQuotes, + maybeNetworkStatuses = maybeNetworksStatuses, + maybeYieldBalances = maybeYieldBalances, + ) } emitAll(currenciesFlow) @@ -208,7 +217,7 @@ internal class CurrenciesStatusesOperations( return getCurrencyStatusFlow(currency) } - private fun getCurrencyStatusFlow(currency: CryptoCurrency): Flow> { + fun getCurrencyStatusFlow(currency: CryptoCurrency): Flow> { val (networks, currenciesIds) = getIds(nonEmptyListOf(currency)) val quoteFlow = getQuotes(currenciesIds) @@ -261,10 +270,19 @@ internal class CurrenciesStatusesOperations( currencies.map { currency -> val quote = quotes?.firstOrNull { it.rawCurrencyId == currency.id.rawCurrencyId } val networkStatus = networksStatuses?.firstOrNull { it.network == currency.network } - val yieldBalance = (yieldBalances as? YieldBalanceList.Data)?.getBalance( - rawCurrencyId = currency.id.rawCurrencyId, - networkName = currency.network.name, + val address = extractAddress(networkStatus) + + val isStakingSupported = stakingRepository.isStakingSupported( + stakingRepository.getIntegrationKey(currency.id), ) + val yieldBalance = if (isStakingSupported) { + (yieldBalances as? YieldBalanceList.Data)?.getBalance( + address = address, + rawCurrencyId = currency.id.rawCurrencyId, + ) + } else { + null + } createCurrencyStatus( currency = currency, quote = quote, @@ -385,25 +403,23 @@ internal class CurrenciesStatusesOperations( .onEmpty { emit(Error.EmptyNetworksStatuses.left()) } } - @OptIn(ExperimentalCoroutinesApi::class) - private fun getYieldBalances(): EitherFlow { - return networksRepository.getNetworkAddressesFlow(userWalletId).flatMapLatest { addresses -> - stakingRepository.getMultiYieldBalanceFlow( - userWalletId = userWalletId, - addresses = addresses, - ).map> { it.right() } - .catch { emit(Error.DataError(it).left()) } - .onEmpty { emit(Error.EmptyYieldBalances.left()) } - } + private fun getYieldBalances(cryptoCurrencies: List): Flow> { + return stakingRepository.getMultiYieldBalanceFlow( + userWalletId = userWalletId, + cryptoCurrencies = cryptoCurrencies, + ).map> { it.right() } + .catch { emit(Error.DataError(it).left()) } + .onEmpty { emit(Error.EmptyYieldBalances.left()) } } - private suspend fun getYieldBalancesSync(): Either { + private suspend fun getYieldBalancesSync( + cryptoCurrencies: List, + ): Either { return catch( block = { - val networkAddresses = networksRepository.getNetworkAddresses(userWalletId) stakingRepository.getMultiYieldBalanceSync( userWalletId, - networkAddresses, + cryptoCurrencies, ).right() }, catch = { @@ -417,10 +433,9 @@ internal class CurrenciesStatusesOperations( ): Either { return catch( block = { - val address = networksRepository.getNetworkAddress(userWalletId, cryptoCurrency) stakingRepository.getSingleYieldBalanceSync( userWalletId, - address, + cryptoCurrency, ).right() }, catch = { @@ -429,19 +444,13 @@ internal class CurrenciesStatusesOperations( ) } - @OptIn(ExperimentalCoroutinesApi::class) private fun getYieldBalance(cryptoCurrency: CryptoCurrency): EitherFlow { - return networksRepository.getNetworkAddressFlow( - userWalletId, - cryptoCurrency, - ).flatMapLatest { address -> - stakingRepository.getSingleYieldBalanceFlow( - userWalletId = userWalletId, - address = address, - ).map> { it.right() } - .catch { emit(Error.DataError(it).left()) } - .onEmpty { emit(Error.EmptyYieldBalances.left()) } - } + return stakingRepository.getSingleYieldBalanceFlow( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ).map> { it.right() } + .catch { emit(Error.DataError(it).left()) } + .onEmpty { emit(Error.EmptyYieldBalances.left()) } } private fun getIds( @@ -459,6 +468,15 @@ internal class CurrenciesStatusesOperations( return networks to currenciesIds } + private fun extractAddress(networkStatus: NetworkStatus?): String? { + return when (val value = networkStatus?.value) { + is NetworkStatus.NoAccount -> value.address.defaultAddress.value + is NetworkStatus.Unreachable -> value.address?.defaultAddress?.value + is NetworkStatus.Verified -> value.address.defaultAddress.value + else -> null + } + } + sealed class Error { data object EmptyCurrencies : Error() @@ -467,6 +485,8 @@ internal class CurrenciesStatusesOperations( data object EmptyNetworksStatuses : Error() + data object EmptyAddresses : Error() + data object UnableToCreateCurrencyStatus : Error() data class DataError(val cause: Throwable) : Error() diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrencyStatusOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrencyStatusOperations.kt index 380f77dc4f..abdf3f94fd 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrencyStatusOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrencyStatusOperations.kt @@ -57,14 +57,16 @@ internal class CurrencyStatusOperations( val hasCurrentNetworkTransactions = status.pendingTransactions.isNotEmpty() val currentTransactions = status.pendingTransactions.getOrElse(currency.id, ::emptySet) - + val isCurrentAddressStaking = + (yieldBalance as? YieldBalance.Data)?.address == status.address.defaultAddress.value + val currentYieldBalance = yieldBalance.takeIf { isCurrentAddressStaking } return when { ignoreQuote -> CryptoCurrencyStatus.NoQuote( amount = amount, hasCurrentNetworkTransactions = hasCurrentNetworkTransactions, pendingTransactions = currentTransactions, networkAddress = status.address, - yieldBalance = yieldBalance, + yieldBalance = currentYieldBalance, ) currency is CryptoCurrency.Token && currency.isCustom -> CryptoCurrencyStatus.Custom( amount = amount, @@ -74,7 +76,7 @@ internal class CurrencyStatusOperations( hasCurrentNetworkTransactions = hasCurrentNetworkTransactions, pendingTransactions = currentTransactions, networkAddress = status.address, - yieldBalance = yieldBalance, + yieldBalance = currentYieldBalance, ) quote == null -> CryptoCurrencyStatus.Loading else -> CryptoCurrencyStatus.Loaded( @@ -85,7 +87,7 @@ internal class CurrencyStatusOperations( hasCurrentNetworkTransactions = hasCurrentNetworkTransactions, pendingTransactions = currentTransactions, networkAddress = status.address, - yieldBalance = yieldBalance, + yieldBalance = currentYieldBalance, ) } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListFiatBalanceOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListFiatBalanceOperations.kt index e446ca55aa..de711b188f 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListFiatBalanceOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListFiatBalanceOperations.kt @@ -58,7 +58,8 @@ internal class TokenListFiatBalanceOperations( currentBalance: TotalFiatBalance, ): TotalFiatBalance { return with(currentBalance) { - val stakingBalance = (status.yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance().orZero() + val yieldBalance = status.yieldBalance as? YieldBalance.Data + val stakingBalance = yieldBalance?.getTotalWithRewardsStakingBalance().orZero() val fiatStakingBalance = status.fiatRate.times(stakingBalance) (this as? TotalFiatBalance.Loaded)?.copy( @@ -76,7 +77,7 @@ internal class TokenListFiatBalanceOperations( ): TotalFiatBalance { return with(currentBalance) { val isTokenAmountCanBeSummarized = status.fiatAmount != null - val yieldBalance = (status.yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance().orZero() + val yieldBalance = (status.yieldBalance as? YieldBalance.Data)?.getTotalWithRewardsStakingBalance().orZero() val fiatYieldBalance = status.fiatRate?.times(yieldBalance).orZero() (this as? TotalFiatBalance.Loaded)?.copy( amount = this.amount + status.fiatAmount.orZero() + fiatYieldBalance, 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 37efae4b4b..f2578bd4c4 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 @@ -6,6 +6,7 @@ import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow @@ -148,6 +149,17 @@ interface CurrenciesRepository { refresh: Boolean = false, ): List + /** + * Retrieves the list of cryptocurrencies within a multi-currency wallet. + * Returns previously loaded currencies or empty list + * + * @param userWalletId The unique identifier of the user wallet. + * @return A list of [CryptoCurrency]. + * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet + * ID provided. + */ + suspend fun getMultiCurrencyWalletCachedCurrenciesSync(userWalletId: UserWalletId): List + /** * Retrieves the cryptocurrency for a specific multi-currency user wallet. * @@ -215,14 +227,13 @@ interface CurrenciesRepository { */ fun createTokenCurrency(cryptoCurrency: CryptoCurrency.Token, network: Network): CryptoCurrency.Token - /** - * Creates token [cryptoCurrency] based on [contractAddress] and [networkId] it`s will be added - */ + /** Creates token [CryptoCurrency.Token] based on [contractAddress] and [networkId] for specified [userWalletId] */ suspend fun createTokenCurrency( userWalletId: UserWalletId, contractAddress: String, networkId: String, ): CryptoCurrency.Token - suspend fun hasTokens(userWalletId: UserWalletId, network: Network): Boolean + /** Get crypto currencies by [currencyRawId] from all user wallets */ + fun getAllWalletsCryptoCurrencies(currencyRawId: String): Flow>> } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/QuotesRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/QuotesRepository.kt index 4ebd284421..57a8429697 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/QuotesRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/QuotesRepository.kt @@ -12,12 +12,12 @@ interface QuotesRepository { /** * Retrieves updates of quotes for a set of specified cryptocurrencies, identified by their unique IDs. * - * Loads remote quotes if they have expired. + * Loads remote quotes if they have expired or if [refresh] is `true`. * * @param currenciesIds The unique identifiers of the cryptocurrencies for which quotes are to be retrieved. * @return A [Flow] emitting a set of quotes corresponding to the specified cryptocurrencies. */ - fun getQuotesUpdates(currenciesIds: Set): Flow> + fun getQuotesUpdates(currenciesIds: Set, refresh: Boolean = false): Flow> /** * Retrieves quotes for a set of specified cryptocurrencies, identified by their unique IDs. diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/GetPrimaryCurrencyStatusUpdatesUseCaseTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/GetPrimaryCurrencyStatusUpdatesUseCaseTest.kt index 824a8e89c2..b96776f92b 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/GetPrimaryCurrencyStatusUpdatesUseCaseTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/GetPrimaryCurrencyStatusUpdatesUseCaseTest.kt @@ -4,7 +4,6 @@ import arrow.core.Either import arrow.core.left import arrow.core.right import com.tangem.domain.core.error.DataError -import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.mock.MockNetworks import com.tangem.domain.tokens.mock.MockQuotes @@ -123,7 +122,7 @@ internal class GetPrimaryCurrencyStatusUpdatesUseCaseTest { networkAddress = NetworkAddress.Single( defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), ), - yieldBalance = YieldBalance.Error, + yieldBalance = null, ), ) } diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt index 091154dda9..cd410b3487 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt @@ -22,6 +22,7 @@ internal object MockNetworks { currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, hasFiatFeeRate = true, + canHandleTokens = true, ) val network2 = Network( @@ -33,6 +34,7 @@ internal object MockNetworks { currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, hasFiatFeeRate = true, + canHandleTokens = true, ) val network3 = Network( @@ -44,6 +46,7 @@ internal object MockNetworks { currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, hasFiatFeeRate = true, + canHandleTokens = true, ) val networkStatus1 = NetworkStatus( diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockTokensStates.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockTokensStates.kt index 78858d98c1..45c2f537d4 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockTokensStates.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockTokensStates.kt @@ -1,7 +1,6 @@ package com.tangem.domain.tokens.mock import arrow.core.nonEmptyListOf -import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.tokens.model.CryptoCurrencyAmountStatus import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.NetworkAddress @@ -152,7 +151,7 @@ internal object MockTokensStates { pendingTransactions = emptySet(), hasCurrentNetworkTransactions = false, networkAddress = requireNotNull(networkStatus.value as? NetworkStatus.Verified).address, - yieldBalance = YieldBalance.Error, + yieldBalance = null, ), ) } @@ -168,7 +167,7 @@ internal object MockTokensStates { .first { it.network == status.currency.network } .value as? NetworkStatus.Verified, ).address, - yieldBalance = YieldBalance.Error, + yieldBalance = null, ), ) } 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 8ef1dfea78..68d9c77d7f 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 @@ -9,6 +9,7 @@ import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow @@ -65,6 +66,10 @@ internal class MockCurrenciesRepository( return tokens.first().getOrElse { e -> throw e } } + override suspend fun getMultiCurrencyWalletCachedCurrenciesSync(userWalletId: UserWalletId): List { + return tokens.first().getOrElse { e -> throw e } + } + override suspend fun getSingleCurrencyWalletPrimaryCurrency(userWalletId: UserWalletId): CryptoCurrency { return token.getOrElse { e -> throw e } } @@ -145,7 +150,7 @@ internal class MockCurrenciesRepository( error("not implemented") } - override suspend fun hasTokens(userWalletId: UserWalletId, network: Network): Boolean { - return false + override fun getAllWalletsCryptoCurrencies(currencyRawId: String): Flow>> { + return emptyFlow() } } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockQuotesRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockQuotesRepository.kt index 3d45f98879..393432cf16 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockQuotesRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockQuotesRepository.kt @@ -13,7 +13,7 @@ internal class MockQuotesRepository( private val quotes: Flow>>, ) : QuotesRepository { - override fun getQuotesUpdates(currenciesIds: Set): Flow> { + override fun getQuotesUpdates(currenciesIds: Set, refresh: Boolean): Flow> { return quotes.map { it.getOrElse { e -> throw e } } } diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt index 33c6654e89..cc3fc8d8b2 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt @@ -8,7 +8,6 @@ import com.tangem.domain.core.lce.lceFlow import com.tangem.domain.staking.model.StakingApproval import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingEntryInfo -import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata import com.tangem.domain.staking.model.stakekit.* import com.tangem.domain.staking.model.stakekit.action.StakingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus @@ -16,7 +15,6 @@ import com.tangem.domain.staking.model.stakekit.action.StakingActionType import com.tangem.domain.staking.model.stakekit.transaction.* import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.tokens.model.CryptoCurrencyAddress import com.tangem.domain.tokens.model.Network import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow @@ -25,6 +23,9 @@ import org.joda.time.DateTime import java.math.BigDecimal class MockStakingRepository : StakingRepository { + + override fun getIntegrationKey(cryptoCurrencyId: CryptoCurrency.ID): String = "" + override fun isStakingSupported(currencyId: String): Boolean = true override suspend fun fetchEnabledYields(refresh: Boolean) { @@ -33,9 +34,9 @@ class MockStakingRepository : StakingRepository { override suspend fun getEntryInfo(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): StakingEntryInfo = StakingEntryInfo( - interestRate = 1.toBigDecimal(), - periodInDays = 2, + apr = 1.toBigDecimal(), tokenSymbol = "SOL", + rewardSchedule = Yield.Metadata.RewardSchedule.DAY, ) override suspend fun getYield(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): Yield = Yield( @@ -111,14 +112,14 @@ class MockStakingRepository : StakingRepository { isAvailable = false, ) - override suspend fun getStakingAvailabilityForActions( - cryptoCurrencyId: CryptoCurrency.ID, - symbol: String, + override suspend fun getStakingAvailability( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, ): StakingAvailability = StakingAvailability.Unavailable override suspend fun fetchSingleYieldBalance( userWalletId: UserWalletId, - address: CryptoCurrencyAddress, + cryptoCurrency: CryptoCurrency, refresh: Boolean, ) { /* no-op */ @@ -126,19 +127,19 @@ class MockStakingRepository : StakingRepository { override fun getSingleYieldBalanceFlow( userWalletId: UserWalletId, - address: CryptoCurrencyAddress, + cryptoCurrency: CryptoCurrency, ): Flow = channelFlow { send(YieldBalance.Error) } override suspend fun getSingleYieldBalanceSync( userWalletId: UserWalletId, - address: CryptoCurrencyAddress, + cryptoCurrency: CryptoCurrency, ): YieldBalance = YieldBalance.Error override suspend fun fetchMultiYieldBalance( userWalletId: UserWalletId, - addresses: List, + cryptoCurrencies: List, refresh: Boolean, ) { /* no-op */ @@ -146,7 +147,7 @@ class MockStakingRepository : StakingRepository { override fun getMultiYieldBalanceFlow( userWalletId: UserWalletId, - addresses: List, + cryptoCurrencies: List, ): Flow = channelFlow { send( YieldBalanceList.Data( @@ -157,7 +158,7 @@ class MockStakingRepository : StakingRepository { override fun getMultiYieldBalanceLce( userWalletId: UserWalletId, - addresses: List, + cryptoCurrencies: List, ): LceFlow = lceFlow { send( YieldBalanceList.Data( @@ -168,7 +169,7 @@ class MockStakingRepository : StakingRepository { override suspend fun getMultiYieldBalanceSync( userWalletId: UserWalletId, - addresses: List, + cryptoCurrencies: List, ): YieldBalanceList = YieldBalanceList.Data( balances = listOf(YieldBalance.Error), ) @@ -237,19 +238,7 @@ class MockStakingRepository : StakingRepository { status = TransactionStatus.Unconfirmed, ) - override suspend fun submitHash(transactionId: String, transactionHash: String) { - /* no-op */ - } - - override suspend fun storeUnsubmittedHash(unsubmittedTransactionMetadata: UnsubmittedTransactionMetadata) { - /* no-op */ - } - - override suspend fun sendUnsubmittedHashes() { - /* no-op */ - } - - override fun isStakeMoreAvailable(networkId: Network.ID): Boolean = true - override fun getStakingApproval(cryptoCurrency: CryptoCurrency): StakingApproval = StakingApproval.Empty + + override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean = false } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/GetFeeError.kt b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt similarity index 100% rename from domain/transaction/src/main/java/com/tangem/domain/transaction/error/GetFeeError.kt rename to domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/SendTransactionError.kt similarity index 81% rename from domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt rename to domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/SendTransactionError.kt index 3f5f7485d4..afa825d759 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt +++ b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/SendTransactionError.kt @@ -1,7 +1,5 @@ package com.tangem.domain.transaction.error -import com.tangem.core.ui.extensions.TextReference - sealed class SendTransactionError { data object DemoCardError : SendTransactionError() @@ -16,7 +14,7 @@ sealed class SendTransactionError { data class CreateAccountUnderfunded(val amount: String) : SendTransactionError() - data class TangemSdkError(val code: Int, val messageReference: TextReference) : SendTransactionError() + data class TangemSdkError(val code: Int, val messageRes: Int, val args: List) : SendTransactionError() data class UnknownError(val ex: Exception? = null) : SendTransactionError() diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt index 6784c1726f..247f3c7605 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt @@ -6,6 +6,7 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionExtras import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionSendResult +import com.tangem.blockchain.common.transaction.TransactionsSendResult import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.models.TransactionType @@ -58,6 +59,13 @@ interface TransactionRepository { network: Network, ): com.tangem.blockchain.extensions.Result + suspend fun sendMultipleTransactions( + txsData: List, + signer: CommonSigner, + userWalletId: UserWalletId, + network: Network, + ): com.tangem.blockchain.extensions.Result + fun createTransactionDataExtras( data: String, network: Network, diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendMultipleTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendMultipleTransactionUseCase.kt new file mode 100644 index 0000000000..c7fce8b278 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendMultipleTransactionUseCase.kt @@ -0,0 +1,147 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionSigner +import com.tangem.blockchain.common.transaction.TransactionsSendResult +import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.network.ResultChecker +import com.tangem.common.core.TangemSdkError +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.TapWorkarounds.isStart2Coin +import com.tangem.domain.common.TapWorkarounds.isTangemTwins +import com.tangem.domain.demo.DemoConfig +import com.tangem.domain.demo.DemoTransactionSender +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.transaction.R +import com.tangem.domain.transaction.TransactionRepository +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.error.SendTransactionError.Companion.USER_CANCELLED_ERROR_CODE +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.sdk.extensions.localizedDescriptionRes +import com.tangem.utils.toFormattedString + +// TODO [REDACTED_TASK_KEY] merge with SendTransactionUseCase +class SendMultipleTransactionUseCase( + private val demoConfig: DemoConfig, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val transactionRepository: TransactionRepository, + private val walletManagersFacade: WalletManagersFacade, +) { + suspend operator fun invoke( + txsData: List, + userWallet: UserWallet, + network: Network, + ): Either> { + val card = userWallet.scanResponse.card + val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins + + val signer = cardSdkConfigRepository.getCommonSigner(cardId = card.cardId.takeIf { isCardNotBackedUp }) + + val linkedTerminal = cardSdkConfigRepository.isLinkedTerminal() + if (userWallet.scanResponse.card.isStart2Coin) { + cardSdkConfigRepository.setLinkedTerminal(false) + } + val sendResult = try { + if (demoConfig.isDemoCardId(cardId = userWallet.cardId)) { + sendDemo( + userWallet = userWallet, + network = network, + transactionsData = txsData, + signer = signer, + ) + } else { + val sendResult = transactionRepository.sendMultipleTransactions( + txsData = txsData, + signer = signer, + userWalletId = userWallet.walletId, + network = network, + ) + when (sendResult) { + is Result.Failure -> handleError(sendResult).left() + is Result.Success -> sendResult.data.right() + } + } + } catch (ex: Exception) { + cardSdkConfigRepository.setLinkedTerminal(linkedTerminal) + SendTransactionError.DataError(ex.message).left() + } + + cardSdkConfigRepository.setLinkedTerminal(linkedTerminal) + return sendResult.fold( + ifRight = { result -> result.hashes.right() }, + ifLeft = { it.left() }, + ) + } + + private suspend fun sendDemo( + userWallet: UserWallet, + network: Network, + transactionsData: List, + signer: TransactionSigner, + ): Either { + val demoTransactionSender = DemoTransactionSender( + walletManagersFacade + .getOrCreateWalletManager(userWallet.walletId, network) + ?: error("WalletManager is null"), + ) + + val result = demoTransactionSender.sendMultiple(transactionDataList = transactionsData, signer = signer) + + return if (result is Result.Failure && result.error.customMessage.contains(DemoTransactionSender.ID)) { + SendTransactionError.DemoCardError.left() + } else { + TransactionsSendResult(listOf("hash")).right() + } + } + + private fun handleError(result: Result.Failure): SendTransactionError { + if (ResultChecker.isNetworkError(result)) { + return SendTransactionError.NetworkError( + code = result.error.message, + message = result.error.customMessage, + ) + } + val error = result.error as? BlockchainSdkError ?: return SendTransactionError.UnknownError() + return when (error) { + is BlockchainSdkError.WrappedTangemError -> parseWrappedError(error) + is BlockchainSdkError.CreateAccountUnderfunded -> { + val minAmount = error.minReserve + val minValue = minAmount.value?.toFormattedString(minAmount.decimals).orEmpty() + SendTransactionError.CreateAccountUnderfunded(minValue) + } + else -> { + SendTransactionError.BlockchainSdkError( + code = error.code, + message = error.customMessage, + ) + } + } + } + + private fun parseWrappedError(error: BlockchainSdkError.WrappedTangemError): SendTransactionError { + return if (error.code == USER_CANCELLED_ERROR_CODE) { + SendTransactionError.UserCancelledError + } else { + when (val tangemError = error.tangemError) { + is TangemSdkError -> { + val resource = tangemError.localizedDescriptionRes() + val resId = resource.resId ?: R.string.common_unknown_error + val resArgs = resource.args.map { it.value } + SendTransactionError.TangemSdkError(tangemError.code, resId, wrappedList(resArgs)) + } + is BlockchainSdkError.WrappedTangemError -> { + parseWrappedError(tangemError) // todo remove when sdk errors are revised + } + else -> { + SendTransactionError.BlockchainSdkError(error.code, tangemError.customMessage) + } + } + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt index 96b5db01fa..c6b5e9258c 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt @@ -10,7 +10,6 @@ import com.tangem.blockchain.common.transaction.TransactionSendResult import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.network.ResultChecker import com.tangem.common.core.TangemSdkError -import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.TapWorkarounds.isStart2Coin @@ -133,8 +132,7 @@ class SendTransactionUseCase( val resource = tangemError.localizedDescriptionRes() val resId = resource.resId ?: R.string.common_unknown_error val resArgs = resource.args.map { it.value } - val textReference = resourceReference(resId, wrappedList(resArgs)) - SendTransactionError.TangemSdkError(tangemError.code, textReference) + SendTransactionError.TangemSdkError(tangemError.code, resId, wrappedList(resArgs)) } is BlockchainSdkError.WrappedTangemError -> { parseWrappedError(tangemError) // todo remove when sdk errors are revised diff --git a/domain/txhistory/models/src/main/kotlin/com/tangem/domain/txhistory/models/TxHistoryItem.kt b/domain/txhistory/models/src/main/kotlin/com/tangem/domain/txhistory/models/TxHistoryItem.kt index beaa4a031b..68677bdd63 100644 --- a/domain/txhistory/models/src/main/kotlin/com/tangem/domain/txhistory/models/TxHistoryItem.kt +++ b/domain/txhistory/models/src/main/kotlin/com/tangem/domain/txhistory/models/TxHistoryItem.kt @@ -33,20 +33,28 @@ data class TxHistoryItem( } sealed interface TransactionType { - object Transfer : TransactionType - object Approve : TransactionType - object Swap : TransactionType - object UnknownOperation : TransactionType + data object Transfer : TransactionType + data object Approve : TransactionType + data object Swap : TransactionType + data object UnknownOperation : TransactionType data class Operation(val name: String) : TransactionType + + sealed interface TronStakingTransactionType : TransactionType { + data object Vote : TronStakingTransactionType + data object Withdraw : TronStakingTransactionType + data object Stake : TronStakingTransactionType + data object Unstake : TronStakingTransactionType + } } sealed class TransactionStatus { - object Failed : TransactionStatus() - object Unconfirmed : TransactionStatus() - object Confirmed : TransactionStatus() + data object Failed : TransactionStatus() + data object Unconfirmed : TransactionStatus() + data object Confirmed : TransactionStatus() } sealed class InteractionAddressType { + data object Staking : InteractionAddressType() data class User(val address: String) : InteractionAddressType() data class Contract(val address: String) : InteractionAddressType() data class Multiple(val addresses: List) : InteractionAddressType() diff --git a/fastlane/Fastfile b/fastlane/Fastfile index d1cfaf3f7e..a411e3c160 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -96,7 +96,8 @@ platform :android do firebase_app_distribution( app: ENV['app_id_internal'], apk_path: ENV['apk_path_internal'], - groups: ENV['groups'] + groups: ENV['groups'], + release_notes: ENV['releaseNotes'] ) end end diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index 865ab65f7b..9a16bc4892 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -23,8 +23,10 @@ dependencies { implementation(projects.core.ui) implementation(projects.core.featuretoggles) implementation(projects.core.navigation) + implementation(projects.core.analytics) implementation(projects.core.analytics.models) implementation(projects.common.routing) + implementation(projects.common.ui) /* Project - Domain */ implementation(projects.domain.models) diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt index 209cad1288..9a4ec03248 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt @@ -17,8 +17,7 @@ internal class PreviewDetailsComponent : DetailsComponent { private val previewBlocks = runBlocking { ItemsBuilder( router = DummyRouter(), - urlOpener = DummyUrlOpener(), - ).buildAll(isWalletConnectAvailable = true, onSupportClick = {}) + ).buildAll(isWalletConnectAvailable = true, onSupportClick = {}, onBuyClick = {}) } private val previewFooter = DetailsFooterUM( diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt index 92b6f46de3..e2a4774961 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt @@ -2,6 +2,7 @@ package com.tangem.features.details.component.preview import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -17,7 +18,7 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { private val previewState = UserWalletListUM( userWallets = persistentListOf( - UserWalletListUM.UserWalletUM( + UserWalletItemUM( id = UserWalletId("user_wallet_1".encodeToByteArray()), name = stringReference("My Wallet"), information = getInformation(3, "4 496,75 $"), @@ -25,7 +26,7 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { isEnabled = true, onClick = {}, ), - UserWalletListUM.UserWalletUM( + UserWalletItemUM( id = UserWalletId("user_wallet_2".encodeToByteArray()), name = stringReference("Old wallet"), information = getInformation(3, "4 496,75 $"), @@ -33,7 +34,7 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { isEnabled = true, onClick = {}, ), - UserWalletListUM.UserWalletUM( + UserWalletItemUM( id = UserWalletId("user_wallet_3".encodeToByteArray()), name = stringReference("Multi Card"), information = getInformation(3, "4 496,75 $"), 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 1569f48efb..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 @@ -1,25 +1,14 @@ package com.tangem.features.details.entity import androidx.compose.runtime.Immutable +import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.wallets.models.UserWalletId import kotlinx.collections.immutable.ImmutableList @Immutable internal data class UserWalletListUM( - val userWallets: ImmutableList, + val userWallets: ImmutableList, val isWalletSavingInProgress: Boolean, val addNewWalletText: TextReference, val onAddNewWalletClick: () -> Unit, -) { - - @Immutable - data class UserWalletUM( - val id: UserWalletId, - val name: TextReference, - val information: TextReference, - val imageUrl: String, - val isEnabled: Boolean, - val onClick: () -> Unit, - ) -} \ No newline at end of file +) \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index fd592c79b8..884ee4f78a 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -1,10 +1,12 @@ package com.tangem.features.details.model import arrow.core.getOrElse +import com.tangem.core.analytics.AppInstanceIdProvider import com.tangem.core.decompose.di.ComponentScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router +import com.tangem.core.navigation.url.UrlOpener import com.tangem.domain.redux.LegacyAction import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase @@ -35,6 +37,8 @@ internal class DetailsModel @Inject constructor( private val appVersionProvider: AppVersionProvider, private val checkIsWalletConnectAvailableUseCase: CheckIsWalletConnectAvailableUseCase, private val router: Router, + private val urlOpener: UrlOpener, + private val appInstanceIdProvider: AppInstanceIdProvider, paramsContainer: ParamsContainer, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val appStateHolder: ReduxStateHolder, @@ -74,6 +78,7 @@ internal class DetailsModel @Inject constructor( items.value = itemsBuilder.buildAll( isWalletConnectAvailable = isWalletConnectAvailable, onSupportClick = ::sendFeedback, + onBuyClick = ::onBuyClick, ) } @@ -86,6 +91,12 @@ internal class DetailsModel @Inject constructor( } } + private fun onBuyClick() { + modelScope.launch { + urlOpener.openUrl(buildBuyLink()) + } + } + private fun updateState(items: ImmutableList) { state.update { prevState -> prevState.copy(items = items) @@ -93,4 +104,14 @@ internal class DetailsModel @Inject constructor( } private fun getAppVersion(): String = "${appVersionProvider.versionName} (${appVersionProvider.versionCode})" + + private suspend fun buildBuyLink(): String { + return appInstanceIdProvider.getAppInstanceId()?.let { + "$BUY_TANGEM_URL&app_instance_id=$it" + } ?: BUY_TANGEM_URL + } + + private companion object { + const val BUY_TANGEM_URL = "https://buy.tangem.com/?utm_source=tangem&utm_medium=app" + } } \ 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 12f18cc1f4..a36e126509 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt @@ -1,12 +1,12 @@ package com.tangem.features.details.model +import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.decompose.di.ComponentScoped import com.tangem.core.decompose.model.Model import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase import com.tangem.features.details.entity.UserWalletListUM -import com.tangem.features.details.entity.UserWalletListUM.UserWalletUM import com.tangem.features.details.impl.R import com.tangem.features.details.utils.UserWalletSaver import com.tangem.features.details.utils.UserWalletsFetcher @@ -48,7 +48,7 @@ internal class UserWalletListModel @Inject constructor( } private fun updateState( - userWallets: ImmutableList, + userWallets: ImmutableList, shouldSaveUserWallets: Boolean, isWalletSavingInProgress: Boolean, ) = state.update { value -> diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt index 9132fff08b..98302fc6f7 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -27,6 +28,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.res.LocalSnackbarHostState import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TestTags import com.tangem.features.details.component.preview.PreviewDetailsComponent import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsItemUM @@ -74,7 +76,7 @@ private fun Content( modifier: Modifier = Modifier, ) { LazyColumn( - modifier = modifier, + modifier = modifier.testTag(TestTags.DETAILS_SCREEN), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), contentPadding = PaddingValues( top = TangemTheme.dimens.spacing12, @@ -125,7 +127,7 @@ private fun Block( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.Top, ) { - val itemModifier = Modifier.fillMaxWidth() + val itemModifier = Modifier.fillMaxWidth().testTag(TestTags.DETAILS_SCREEN_ITEM) when (model) { is DetailsItemUM.Basic -> { 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 794ef632c3..b09124ca04 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 @@ -1,7 +1,6 @@ package com.tangem.features.details.ui import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -10,32 +9,25 @@ 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.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import coil.compose.SubcomposeAsyncImage -import coil.request.ImageRequest -import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.common.ui.userwallet.UserWalletItem import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.features.details.entity.UserWalletListUM import com.tangem.features.details.impl.R -import com.tangem.features.details.ui.coil.RotationTransformation @Composable internal fun UserWalletListBlock(state: UserWalletListUM, modifier: Modifier = Modifier) { BlockCard( modifier = modifier, ) { - state.userWallets.forEach { model -> - key(model.id) { + state.userWallets.forEach { state -> + key(state.id) { UserWalletItem( modifier = Modifier.fillMaxWidth(), - model = model, + state = state, ) } } @@ -47,96 +39,6 @@ internal fun UserWalletListBlock(state: UserWalletListUM, modifier: Modifier = M } } -@Composable -private fun UserWalletItem(model: UserWalletListUM.UserWalletUM, modifier: Modifier = Modifier) { - BlockCard( - modifier = modifier, - onClick = model.onClick, - enabled = model.isEnabled, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = TangemTheme.dimens.size68) - .padding(all = TangemTheme.dimens.spacing12), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), - ) { - Image(imageUrl = model.imageUrl) - NameAndInfo( - name = model.name, - information = model.information, - ) - } - } -} - -@Composable -private fun NameAndInfo(name: TextReference, information: TextReference, modifier: Modifier = Modifier) { - Column( - modifier = modifier.heightIn(min = TangemTheme.dimens.size40), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.SpaceEvenly, - ) { - Text( - text = name.resolveReference(), - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - AnimatedContent( - targetState = information.resolveReference(), - label = "User wallet information", - ) { information -> - Text( - text = information, - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -private fun Image(imageUrl: String, modifier: Modifier = Modifier) { - val imageModifier = modifier - .width(TangemTheme.dimens.size24) - .height(TangemTheme.dimens.size36) - .clip(TangemTheme.shapes.roundedCornersSmall) - - SubcomposeAsyncImage( - modifier = imageModifier, - model = ImageRequest.Builder(LocalContext.current) - .transformations(RotationTransformation(angle = 90f)) - .size( - width = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }, - height = with(LocalDensity.current) { TangemTheme.dimens.size24.roundToPx() }, - ) - .data(imageUrl) - .crossfade(enable = true) - .allowHardware(enable = false) - .build(), - loading = { - RectangleShimmer( - modifier = imageModifier, - radius = TangemTheme.dimens.size2, - ) - }, - error = { - Image( - modifier = imageModifier, - painter = painterResource(id = R.drawable.img_card_wallet_2_gray_22_36), - contentDescription = null, - ) - }, - contentDescription = null, - ) -} - @Composable private fun AddWalletButton( text: TextReference, diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt index f14c204327..70d3936e99 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt @@ -3,7 +3,6 @@ package com.tangem.features.details.utils import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.di.ComponentScoped import com.tangem.core.decompose.navigation.Router -import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.components.block.model.BlockUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -16,19 +15,19 @@ import kotlinx.collections.immutable.toImmutableList import javax.inject.Inject @ComponentScoped -internal class ItemsBuilder @Inject constructor( - private val router: Router, - private val urlOpener: UrlOpener, -) { +internal class ItemsBuilder @Inject constructor(private val router: Router) { - fun buildAll(isWalletConnectAvailable: Boolean, onSupportClick: () -> Unit): ImmutableList = - buildList { - buildWalletConnectBlock(isWalletConnectAvailable)?.let(::add) - buildUserWalletListBlock().let(::add) - buildShopBlock().let(::add) - buildSettingsBlock().let(::add) - buildSupportBlock(onSupportClick).let(::add) - }.toImmutableList() + fun buildAll( + isWalletConnectAvailable: Boolean, + onSupportClick: () -> Unit, + onBuyClick: () -> Unit, + ): ImmutableList = buildList { + buildWalletConnectBlock(isWalletConnectAvailable)?.let(::add) + buildUserWalletListBlock().let(::add) + buildShopBlock(onBuyClick).let(::add) + buildSettingsBlock().let(::add) + buildSupportBlock(onSupportClick).let(::add) + }.toImmutableList() private fun buildWalletConnectBlock(isWalletConnectAvailable: Boolean): DetailsItemUM? { return if (isWalletConnectAvailable) { @@ -42,7 +41,7 @@ internal class ItemsBuilder @Inject constructor( private fun buildUserWalletListBlock(): DetailsItemUM = DetailsItemUM.UserWalletList - private fun buildShopBlock(): DetailsItemUM = DetailsItemUM.Basic( + private fun buildShopBlock(onBuyClick: () -> Unit): DetailsItemUM = DetailsItemUM.Basic( id = "shop", items = persistentListOf( DetailsItemUM.Basic.Item( @@ -50,7 +49,7 @@ internal class ItemsBuilder @Inject constructor( block = BlockUM( text = resourceReference(R.string.details_buy_wallet), iconRes = R.drawable.ic_tangem_24, - onClick = { urlOpener.openUrl(BUY_TANGEM_URL) }, + onClick = onBuyClick, ), ), ), @@ -102,8 +101,4 @@ internal class ItemsBuilder @Inject constructor( ), ), ) - - private companion object { - const val BUY_TANGEM_URL = "https://buy.tangem.com/?utm_source=tangem&utm_medium=app" - } } \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletMappers.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletMappers.kt deleted file mode 100644 index a9782fc1d9..0000000000 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletMappers.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.tangem.features.details.utils - -import com.tangem.core.ui.extensions.* -import com.tangem.core.ui.utils.BigDecimalFormatter -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.scan.CardDTO -import com.tangem.domain.tokens.model.TotalFiatBalance -import com.tangem.domain.wallets.models.UserWallet -import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.features.details.entity.UserWalletListUM.UserWalletUM -import com.tangem.features.details.impl.R -import com.tangem.utils.StringsSigns.STARS -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList - -internal fun List.toUiModels( - onClick: (UserWalletId) -> Unit, - appCurrency: AppCurrency? = null, - balances: Map = emptyMap(), - isLoading: Boolean = true, - isBalancesHidden: Boolean = false, -): ImmutableList = this.map { model -> - val balance = balances[model.walletId] - - model.toUiModel( - balance = balance, - appCurrency = appCurrency, - isLoading = isLoading, - isBalanceHidden = isBalancesHidden, - onClick = { onClick(model.walletId) }, - ) -}.toImmutableList() - -private fun UserWallet.toUiModel( - balance: TotalFiatBalance?, - appCurrency: AppCurrency?, - isLoading: Boolean, - isBalanceHidden: Boolean, - onClick: () -> Unit, -): UserWalletUM = UserWalletUM( - id = walletId, - name = stringReference(name), - information = getInfo( - appCurrency = appCurrency, - balance = balance, - isBalanceHidden = isBalanceHidden, - isLoading = isLoading, - ), - imageUrl = artworkUrl, - isEnabled = !isLocked, - onClick = onClick, -) - -private fun UserWallet.getInfo( - appCurrency: AppCurrency?, - balance: TotalFiatBalance?, - isBalanceHidden: Boolean, - isLoading: Boolean, -): TextReference { - val dividerRef = stringReference(value = " • ") - - val cardCount = getCardCount() - val cardCountRef = TextReference.PluralRes( - id = R.plurals.card_label_card_count, - count = cardCount, - formatArgs = wrappedList(cardCount), - ) - - return when { - isBalanceHidden -> combinedReference(cardCountRef, dividerRef, stringReference(STARS)) - isLocked -> combinedReference(cardCountRef, dividerRef, resourceReference(R.string.common_locked)) - isLoading -> cardCountRef - else -> getBalanceInfo(balance, appCurrency, cardCountRef, dividerRef) - } -} - -private fun getBalanceInfo( - balance: TotalFiatBalance?, - appCurrency: AppCurrency?, - cardCountRef: TextReference, - dividerRef: TextReference, -): TextReference { - val amount = when (balance) { - is TotalFiatBalance.Loaded -> balance.amount - is TotalFiatBalance.Failed, - is TotalFiatBalance.Loading, - null, - -> null - } - - return if (amount != null && appCurrency != null) { - val formattedAmount = BigDecimalFormatter.formatFiatAmount( - fiatAmount = amount, - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ) - val amountRef = stringReference(formattedAmount) - combinedReference(cardCountRef, dividerRef, amountRef) - } else { - combinedReference(cardCountRef, dividerRef, stringReference(BigDecimalFormatter.EMPTY_BALANCE_SIGN)) - } -} - -private fun UserWallet.getCardCount() = when (val status = scanResponse.card.backupStatus) { - is CardDTO.BackupStatus.Active -> status.cardCount.inc() - is CardDTO.BackupStatus.CardLinked -> status.cardCount.inc() - is CardDTO.BackupStatus.NoBackup, - null, - -> 1 -} \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt index e141c4034e..c9115930ec 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt @@ -2,6 +2,8 @@ package com.tangem.features.details.utils import arrow.core.Either import com.tangem.common.routing.AppRoute +import com.tangem.common.ui.userwallet.converter.UserWalletItemUMConverter +import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.decompose.di.ComponentScoped import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender @@ -22,9 +24,9 @@ import com.tangem.domain.tokens.model.TotalFiatBalance import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.features.details.entity.UserWalletListUM.UserWalletUM import com.tangem.features.details.impl.R import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import javax.inject.Inject @@ -40,8 +42,11 @@ internal class UserWalletsFetcher @Inject constructor( ) { @OptIn(ExperimentalCoroutinesApi::class) - val userWallets: Flow> = getWalletsUseCase().transformLatest { wallets -> - emit(wallets.toUiModels(onClick = ::navigateToWalletSettings)) + val userWallets: Flow> = getWalletsUseCase().transformLatest { wallets -> + val uiModels = UserWalletItemUMConverter(onClick = ::navigateToWalletSettings).convertList(wallets) + .toImmutableList() + + emit(uiModels) combine( getSelectedAppCurrencyUseCase().distinctUntilChanged(), @@ -72,23 +77,33 @@ internal class UserWalletsFetcher @Inject constructor( maybeAppCurrency: Either, maybeBalances: Lce>, balanceHidingSettings: BalanceHidingSettings, - ): Lce> = lce { + ): Lce> = lce { val balances = withError( transform = { Error.UnableToGetBalances }, - block = { maybeBalances.bindOrNull().orEmpty() }, + block = { + maybeBalances.bindOrNull().orEmpty() + .filterKeys { userWalletId -> wallets.any { it.walletId == userWalletId } } + .mapKeys { entry -> wallets.first { it.walletId == entry.key } } + }, ) + val appCurrency = withError( transform = { Error.UnableToGetAppCurrency }, block = { maybeAppCurrency.toLce().bind() }, ) - wallets.toUiModels( - appCurrency = appCurrency, - balances = balances, - onClick = ::navigateToWalletSettings, - isBalancesHidden = balanceHidingSettings.isBalanceHidden, - isLoading = maybeBalances.isLoading(), - ) + balances + .map { (userWallet, balance) -> + UserWalletItemUMConverter( + onClick = ::navigateToWalletSettings, + appCurrency = appCurrency, + balance = balance, + isBalanceHidden = balanceHidingSettings.isBalanceHidden, + isLoading = maybeBalances.isLoading(), + ) + .convert(userWallet) + } + .toImmutableList() } private fun navigateToWalletSettings(userWalletId: UserWalletId) { diff --git a/features/disclaimer/impl/src/main/java/com/tangem/features/disclaimer/impl/ui/DisclaimerScreen.kt b/features/disclaimer/impl/src/main/java/com/tangem/features/disclaimer/impl/ui/DisclaimerScreen.kt index 208feec00e..f3fe9913c7 100644 --- a/features/disclaimer/impl/src/main/java/com/tangem/features/disclaimer/impl/ui/DisclaimerScreen.kt +++ b/features/disclaimer/impl/src/main/java/com/tangem/features/disclaimer/impl/ui/DisclaimerScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -34,6 +35,8 @@ import com.tangem.core.ui.extensions.resourceReference 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.TestTags.DISCLAIMER_SCREEN_ACCEPT_BUTTON +import com.tangem.core.ui.test.TestTags.DISCLAIMER_SCREEN_CONTAINER import com.tangem.features.disclaimer.impl.R import com.tangem.features.disclaimer.impl.entity.DisclaimerUM import com.tangem.features.disclaimer.impl.entity.DummyDisclaimer @@ -57,7 +60,8 @@ internal fun DisclaimerScreen(state: DisclaimerUM) { Box( modifier = Modifier .background(backgroundColor) - .statusBarsPadding(), + .statusBarsPadding() + .testTag(DISCLAIMER_SCREEN_CONTAINER), ) { Column( modifier = Modifier @@ -158,6 +162,7 @@ private fun BoxScope.DisclaimerButton(onAccept: (Boolean) -> Unit) { disabledContentColor = TangemColorPalette.Dark6, ), modifier = Modifier + .testTag(DISCLAIMER_SCREEN_ACCEPT_BUTTON) .align(Alignment.BottomCenter) .navigationBarsPadding() .padding( diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt index 761758d465..4539527ce5 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt @@ -1,18 +1,16 @@ package com.tangem.features.managetokens.component -import androidx.compose.runtime.Composable +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.wallets.models.UserWalletId -interface AddCustomTokenComponent { - - @Composable - fun BottomSheet(isVisible: Boolean, onDismiss: () -> Unit) +interface AddCustomTokenComponent : ComposableBottomSheetComponent { data class Params( val userWalletId: UserWalletId, + val onDismiss: () -> Unit, + val onCurrencyAdded: () -> Unit, ) - interface Factory { - fun create(params: Params): AddCustomTokenComponent - } + interface Factory : ComponentFactory } \ No newline at end of file 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 a38a96267c..c327e0ac05 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,11 +2,11 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.wallets.models.UserWalletId interface ManageTokensComponent : ComposableContentComponent { - data class Params(val mode: Mode) + data class Params(val userWalletId: UserWalletId?) - enum class Mode { READ_ONLY, MANAGE, } interface Factory : ComponentFactory } \ 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 1305e90261..a506043ad2 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -22,8 +22,11 @@ dependencies { implementation(projects.core.featuretoggles) /* Project - Domain */ - implementation(projects.domain.wallets.models) + implementation(projects.domain.manageTokens) + implementation(projects.domain.card) + implementation(projects.domain.tokens) implementation(projects.domain.tokens.models) + implementation(projects.domain.wallets.models) /* AndroidX */ implementation(deps.androidx.activity.compose) @@ -43,5 +46,6 @@ dependencies { /* Other */ implementation(deps.kotlin.immutable.collections) + implementation(deps.decompose.ext.compose) implementation(deps.timber) } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt new file mode 100644 index 0000000000..383d94f073 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt @@ -0,0 +1,17 @@ +package com.tangem.features.managetokens.component + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableDialogComponent +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath + +internal interface CustomTokenDerivationInputComponent : ComposableDialogComponent { + + data class Params( + val userWalletId: UserWalletId, + val onConfirm: (SelectedDerivationPath) -> Unit, + val onDismiss: () -> Unit, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt index acb8dc42a4..b7c219e560 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt @@ -1,19 +1,23 @@ package com.tangem.features.managetokens.component -import androidx.compose.foundation.lazy.LazyListScope -import com.tangem.domain.tokens.model.Network +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormValues +import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath +import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork -internal interface CustomTokenFormComponent { - - fun content(scope: LazyListScope) +internal interface CustomTokenFormComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, - val networkId: Network.ID, + val network: SelectedNetwork, + val derivationPath: SelectedDerivationPath?, + val formValues: CustomTokenFormValues, + val onSelectNetworkClick: (CustomTokenFormValues) -> Unit, + val onSelectDerivationPathClick: (CustomTokenFormValues) -> Unit, + val onCurrencyAdded: () -> Unit, ) - interface Factory { - fun create(params: Params): CustomTokenFormComponent - } + interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenNetworkSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenNetworkSelectorComponent.kt deleted file mode 100644 index a2551d430c..0000000000 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenNetworkSelectorComponent.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.features.managetokens.component - -import androidx.compose.foundation.lazy.LazyListScope -import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.features.managetokens.entity.SelectedNetworkUM - -internal interface CustomTokenNetworkSelectorComponent { - - fun content(scope: LazyListScope) - - data class Params( - val userWalletId: UserWalletId, - val selectedNetwork: SelectedNetworkUM?, - val onNetworkSelected: (SelectedNetworkUM) -> Unit, - ) - - interface Factory { - fun create(params: Params): CustomTokenNetworkSelectorComponent - } -} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt new file mode 100644 index 0000000000..d1f5e8aaf6 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt @@ -0,0 +1,28 @@ +package com.tangem.features.managetokens.component + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath +import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork + +internal interface CustomTokenSelectorComponent : ComposableContentComponent { + + sealed class Params { + + data class NetworkSelector( + val userWalletId: UserWalletId, + val selectedNetwork: SelectedNetwork?, + val onNetworkSelected: (SelectedNetwork) -> Unit, + ) : Params() + + data class DerivationPathSelector( + val userWalletId: UserWalletId, + val selectedNetwork: SelectedNetwork, + val selectedDerivationPath: SelectedDerivationPath?, + val onDerivationPathSelected: (SelectedDerivationPath) -> Unit, + ) : Params() + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt new file mode 100644 index 0000000000..bde22e12c6 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt @@ -0,0 +1,185 @@ +package com.tangem.features.managetokens.component.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.jetpack.stack.Children +import com.arkivanov.decompose.extensions.compose.jetpack.stack.animation.stackAnimation +import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState +import com.arkivanov.decompose.router.stack.* +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.managetokens.component.AddCustomTokenComponent +import com.tangem.features.managetokens.component.CustomTokenFormComponent +import com.tangem.features.managetokens.component.CustomTokenSelectorComponent +import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenConfig +import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormValues +import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath +import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork +import com.tangem.features.managetokens.ui.AddCustomTokenBottomSheet +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAddCustomTokenComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: AddCustomTokenComponent.Params, + private val selectorComponentFactory: CustomTokenSelectorComponent.Factory, + private val formComponentFactory: CustomTokenFormComponent.Factory, +) : AddCustomTokenComponent, AppComponentContext by context { + + private val navigation = StackNavigation() + private val contentStack = childStack( + key = "add_custom_token_content_stack", + source = navigation, + initialConfiguration = AddCustomTokenConfig( + userWalletId = params.userWalletId, + step = AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR, + popBack = ::dismiss, + ), + handleBackButton = true, + serializer = AddCustomTokenConfig.serializer(), + childFactory = ::contentChild, + ) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val config = remember { + TangemBottomSheetConfig( + isShow = true, + onDismissRequest = ::dismiss, + content = contentStack.active.configuration, + ) + } + val childStack by contentStack.subscribeAsState() + + AddCustomTokenBottomSheet( + config = config.copy( + content = childStack.active.configuration, + ), + content = { modifier -> + Children( + stack = childStack, + animation = stackAnimation(), + ) { child -> + child.instance.Content(modifier = modifier) + } + }, + ) + } + + private fun contentChild( + config: AddCustomTokenConfig, + componentContext: ComponentContext, + ): ComposableContentComponent = when (config.step) { + AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR -> { + selectorComponentFactory.create( + context = childByContext(componentContext), + params = CustomTokenSelectorComponent.Params.NetworkSelector( + userWalletId = config.userWalletId, + selectedNetwork = null, + onNetworkSelected = { network -> + showForm(network = network) + }, + ), + ) + } + AddCustomTokenConfig.Step.NETWORK_SELECTOR -> { + selectorComponentFactory.create( + context = childByContext(componentContext), + params = CustomTokenSelectorComponent.Params.NetworkSelector( + userWalletId = config.userWalletId, + selectedNetwork = config.selectedNetwork, + onNetworkSelected = { network -> + showForm(network = network) + }, + ), + ) + } + AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR -> { + selectorComponentFactory.create( + context = childByContext(componentContext), + params = CustomTokenSelectorComponent.Params.DerivationPathSelector( + userWalletId = config.userWalletId, + selectedNetwork = requireNotNull(config.selectedNetwork) { + "Network is not selected" + }, + selectedDerivationPath = config.selectedDerivationPath, + onDerivationPathSelected = { derivationPath -> + showForm(derivationPath = derivationPath) + }, + ), + ) + } + AddCustomTokenConfig.Step.FORM -> { + formComponentFactory.create( + context = childByContext(componentContext), + params = CustomTokenFormComponent.Params( + userWalletId = config.userWalletId, + network = requireNotNull(config.selectedNetwork) { + "Network is not selected" + }, + derivationPath = config.selectedDerivationPath, + formValues = config.formValues, + onSelectNetworkClick = ::showNetworkSelector, + onSelectDerivationPathClick = ::showDerivationPathSelector, + onCurrencyAdded = ::dismissAndNotify, + ), + ) + } + } + + private fun showDerivationPathSelector(formValues: CustomTokenFormValues) { + val currentConfig = contentStack.value.active.configuration + + val config = currentConfig.copy( + step = AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR, + formValues = formValues, + popBack = navigation::pop, + ) + navigation.push(config) + } + + private fun showNetworkSelector(formValues: CustomTokenFormValues) { + val currentConfig = contentStack.value.active.configuration + + val config = currentConfig.copy( + step = AddCustomTokenConfig.Step.NETWORK_SELECTOR, + formValues = formValues, + popBack = navigation::pop, + ) + navigation.push(config) + } + + private fun showForm(network: SelectedNetwork? = null, derivationPath: SelectedDerivationPath? = null) { + val currentConfig = contentStack.value.active.configuration + + val config = currentConfig.copy( + step = AddCustomTokenConfig.Step.FORM, + selectedNetwork = network ?: currentConfig.selectedNetwork, + selectedDerivationPath = derivationPath ?: currentConfig.selectedDerivationPath, + popBack = ::dismiss, + ) + navigation.replaceAll(config) + } + + private fun dismissAndNotify() { + dismiss() + params.onCurrencyAdded() + } + + @AssistedFactory + interface Factory : AddCustomTokenComponent.Factory { + override fun create( + context: AppComponentContext, + params: AddCustomTokenComponent.Params, + ): DefaultAddCustomTokenComponent + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt new file mode 100644 index 0000000000..0695290569 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt @@ -0,0 +1,127 @@ +package com.tangem.features.managetokens.component.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import arrow.core.getOrElse +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.managetokens.ValidateDerivationPathUseCase +import com.tangem.domain.managetokens.model.exceptoin.DerivationPathValidationException +import com.tangem.domain.tokens.model.Network +import com.tangem.features.managetokens.component.CustomTokenDerivationInputComponent +import com.tangem.features.managetokens.entity.customtoken.CustomDerivationInputUM +import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath +import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.ui.dialog.CustomDerivationInputDialog +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.* + +internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: CustomTokenDerivationInputComponent.Params, + private val validateDerivationPathUseCase: ValidateDerivationPathUseCase, +) : CustomTokenDerivationInputComponent, AppComponentContext by context { + + private val state: MutableStateFlow = MutableStateFlow( + value = getInitialState(), + ) + + init { + observeValueUpdates() + } + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun Dialog() { + val state by state.collectAsStateWithLifecycle() + + CustomDerivationInputDialog( + model = state, + onDismiss = ::dismiss, + ) + } + + @OptIn(FlowPreview::class) + private fun observeValueUpdates() { + state + .map { it.value.text } + .distinctUntilChanged() + .sample(periodMillis = 1_000) + .onEach(::validateValue) + .launchIn(componentScope) + } + + private fun getInitialState(): CustomDerivationInputUM = CustomDerivationInputUM( + value = TextFieldValue(), + error = null, + updateValue = ::updateValue, + isConfirmEnabled = false, + onConfirm = ::confirm, + ) + + private fun validateValue(value: String) { + validateDerivationPathUseCase(value).getOrElse { e -> + updateWithValidationError(e) + return + } + + state.update { state -> + state.copy( + error = null, + isConfirmEnabled = true, + ) + } + } + + private fun updateWithValidationError(e: DerivationPathValidationException) { + state.update { state -> + state.copy( + error = when (e) { + DerivationPathValidationException.Empty -> null + DerivationPathValidationException.Invalid -> { + resourceReference(R.string.custom_token_invalid_derivation_path) + } + }, + isConfirmEnabled = false, + ) + } + } + + private fun updateValue(value: TextFieldValue) { + state.update { state -> + state.copy(value = value) + } + } + + private fun confirm() { + if (state.value.error != null && !state.value.isConfirmEnabled) { + return + } + + val value = state.value.value.text + val model = SelectedDerivationPath( + id = null, + value = Network.DerivationPath.Custom(value), + networkName = stringReference(value = value), + ) + + params.onConfirm(model) + } + + @AssistedFactory + interface Factory : CustomTokenDerivationInputComponent.Factory { + override fun create( + context: AppComponentContext, + params: CustomTokenDerivationInputComponent.Params, + ): DefaultCustomTokenDerivationInputComponent + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenFormComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenFormComponent.kt new file mode 100644 index 0000000000..f2413c6b88 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenFormComponent.kt @@ -0,0 +1,40 @@ +package com.tangem.features.managetokens.component.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.model.getOrCreateModel +import com.tangem.features.managetokens.component.CustomTokenFormComponent +import com.tangem.features.managetokens.model.CustomTokenFormModel +import com.tangem.features.managetokens.ui.CustomTokenFormContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultCustomTokenFormComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: CustomTokenFormComponent.Params, +) : CustomTokenFormComponent, AppComponentContext by context { + + private val model: CustomTokenFormModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + + CustomTokenFormContent( + modifier = modifier, + model = state, + ) + } + + @AssistedFactory + interface Factory : CustomTokenFormComponent.Factory { + override fun create( + context: AppComponentContext, + params: CustomTokenFormComponent.Params, + ): DefaultCustomTokenFormComponent + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt new file mode 100644 index 0000000000..2d690a862c --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt @@ -0,0 +1,71 @@ +package com.tangem.features.managetokens.component.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.jetpack.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.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableDialogComponent +import com.tangem.features.managetokens.component.CustomTokenDerivationInputComponent +import com.tangem.features.managetokens.component.CustomTokenSelectorComponent +import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorDialogConfig +import com.tangem.features.managetokens.model.CustomTokenSelectorModel +import com.tangem.features.managetokens.ui.CustomTokenSelectorContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultCustomTokenSelectorComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: CustomTokenSelectorComponent.Params, + private val customTokenDerivationInputComponentFactory: CustomTokenDerivationInputComponent.Factory, +) : CustomTokenSelectorComponent, AppComponentContext by context { + + private val model: CustomTokenSelectorModel = getOrCreateModel(params) + private val dialogSlot = childSlot( + source = model.dialogNavigation, + serializer = CustomTokenSelectorDialogConfig.serializer(), + childFactory = ::createDialog, + ) + + private fun createDialog( + config: CustomTokenSelectorDialogConfig, + context: ComponentContext, + ): ComposableDialogComponent = when (config) { + is CustomTokenSelectorDialogConfig.CustomDerivationInput -> customTokenDerivationInputComponentFactory.create( + context = childByContext(context), + params = CustomTokenDerivationInputComponent.Params( + userWalletId = config.userWalletId, + onConfirm = model::selectCustomDerivationPath, + onDismiss = model.dialogNavigation::dismiss, + ), + ) + } + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + val dialog by dialogSlot.subscribeAsState() + + CustomTokenSelectorContent( + modifier = modifier, + model = state, + ) + + dialog.child?.instance?.Dialog() + } + + @AssistedFactory + interface Factory : CustomTokenSelectorComponent.Factory { + override fun create( + context: AppComponentContext, + params: CustomTokenSelectorComponent.Params, + ): DefaultCustomTokenSelectorComponent + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt index e89139cf5f..2b513d23cb 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt @@ -1,12 +1,21 @@ package com.tangem.features.managetokens.component.impl +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.jetpack.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.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.managetokens.component.AddCustomTokenComponent import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig import com.tangem.features.managetokens.model.ManageTokensModel import com.tangem.features.managetokens.ui.ManageTokensScreen import dagger.assisted.Assisted @@ -16,18 +25,47 @@ import dagger.assisted.AssistedInject internal class DefaultManageTokensComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted params: ManageTokensComponent.Params, + private val addCustomTokenComponentFactory: AddCustomTokenComponent.Factory, ) : ManageTokensComponent, AppComponentContext by context { private val model: ManageTokensModel = getOrCreateModel(params) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = ManageTokensBottomSheetConfig.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + BackHandler(onBack = state.popBack) ManageTokensScreen( modifier = modifier, state = state, ) + + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: ManageTokensBottomSheetConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = when (config) { + is ManageTokensBottomSheetConfig.AddCustomToken -> { + addCustomTokenComponentFactory.create( + context = childByContext(componentContext), + params = AddCustomTokenComponent.Params( + userWalletId = config.userWalletId, + onDismiss = model.bottomSheetNavigation::dismiss, + onCurrencyAdded = model::reloadList, + ), + ) + } } @AssistedFactory diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt index b428c0ed2d..79d2f3bdcd 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt @@ -4,81 +4,73 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.managetokens.component.AddCustomTokenComponent -import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent -import com.tangem.features.managetokens.entity.AddCustomTokenButtonUM -import com.tangem.features.managetokens.entity.AddCustomTokenUM -import com.tangem.features.managetokens.entity.ClickableFieldUM -import com.tangem.features.managetokens.entity.SelectedNetworkUM -import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.component.CustomTokenSelectorComponent +import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenConfig import com.tangem.features.managetokens.ui.AddCustomTokenBottomSheet import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update internal class PreviewAddCustomTokenComponent( - initialState: AddCustomTokenUM = AddCustomTokenUM.NetworkSelector(popBack = {}), + initialState: AddCustomTokenConfig = AddCustomTokenConfig( + userWalletId = UserWalletId(stringValue = "321"), + step = AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR, + popBack = {}, + ), ) : AddCustomTokenComponent { - private val userWalletId = UserWalletId(stringValue = "321") + private val previewState: MutableStateFlow = MutableStateFlow(initialState) - private val previewState: MutableStateFlow = MutableStateFlow(initialState) + override fun dismiss() { + /* no-op */ + } @Composable - override fun BottomSheet(isVisible: Boolean, onDismiss: () -> Unit) { + override fun BottomSheet() { val state by previewState.collectAsStateWithLifecycle() val config = TangemBottomSheetConfig( - isShow = isVisible, - onDismissRequest = onDismiss, + isShow = true, + onDismissRequest = ::dismiss, content = state, ) AddCustomTokenBottomSheet( config = config, - content = { - when (val s = state) { - is AddCustomTokenUM.Form -> { - PreviewCustomTokenFormComponent( - networkName = ClickableFieldUM( - label = resourceReference(R.string.custom_token_network_input_title), - value = stringReference(s.selectedNetwork.name), - onClick = { showNetworkSelector(s.selectedNetwork) }, + content = { modifier -> + when (state.step) { + AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR -> { + PreviewCustomTokenSelectorComponent( + params = CustomTokenSelectorComponent.Params.NetworkSelector( + userWalletId = state.userWalletId, + selectedNetwork = null, + onNetworkSelected = {}, ), - ).content(this) + ).Content(modifier) } - is AddCustomTokenUM.NetworkSelector -> { - PreviewCustomTokenNetworkSelectorComponent( - params = CustomTokenNetworkSelectorComponent.Params( - userWalletId = userWalletId, - selectedNetwork = s.selectedNetwork, - onNetworkSelected = ::showForm, + AddCustomTokenConfig.Step.NETWORK_SELECTOR -> { + PreviewCustomTokenSelectorComponent( + params = CustomTokenSelectorComponent.Params.NetworkSelector( + userWalletId = state.userWalletId, + selectedNetwork = state.selectedNetwork, + onNetworkSelected = {}, ), - networksSize = 20, - ).content(this) + ).Content(modifier) + } + AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR -> { + PreviewCustomTokenSelectorComponent( + params = CustomTokenSelectorComponent.Params.DerivationPathSelector( + userWalletId = state.userWalletId, + selectedNetwork = state.selectedNetwork!!, + selectedDerivationPath = state.selectedDerivationPath!!, + onDerivationPathSelected = {}, + ), + ).Content(modifier) + } + AddCustomTokenConfig.Step.FORM -> { + PreviewCustomTokenFormComponent().Content(modifier) } } }, ) } - - private fun showNetworkSelector(selectedNetwork: SelectedNetworkUM) { - previewState.update { - AddCustomTokenUM.NetworkSelector(selectedNetwork, popBack = { showForm(selectedNetwork) }) - } - } - - private fun showForm(network: SelectedNetworkUM) { - previewState.update { - AddCustomTokenUM.Form( - popBack = {}, - selectedNetwork = network, - addTokenButton = AddCustomTokenButtonUM.Visible( - isEnabled = false, - onClick = {}, - ), - ) - } - } } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenDerivationInputComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenDerivationInputComponent.kt new file mode 100644 index 0000000000..a91caf00e0 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenDerivationInputComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.managetokens.component.preview + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.managetokens.component.CustomTokenDerivationInputComponent +import com.tangem.features.managetokens.entity.customtoken.CustomDerivationInputUM +import com.tangem.features.managetokens.ui.dialog.CustomDerivationInputDialog + +internal class PreviewCustomTokenDerivationInputComponent( + private val value: String = "", + private val error: String? = null, +) : CustomTokenDerivationInputComponent { + + override fun dismiss() { + /* no-op */ + } + + @Composable + override fun Dialog() { + val model = CustomDerivationInputUM( + value = TextFieldValue(value), + error = error?.let(::stringReference), + updateValue = {}, + isConfirmEnabled = false, + onConfirm = {}, + ) + + CustomDerivationInputDialog( + model = model, + onDismiss = ::dismiss, + ) + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt index 625090f28d..fa909e5257 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt @@ -1,81 +1,87 @@ package com.tangem.features.managetokens.component.preview -import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.features.managetokens.component.CustomTokenFormComponent -import com.tangem.features.managetokens.entity.ClickableFieldUM -import com.tangem.features.managetokens.entity.CustomTokenFormUM -import com.tangem.features.managetokens.entity.TextInputFieldUM +import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM +import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM +import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM import com.tangem.features.managetokens.impl.R -import com.tangem.features.managetokens.ui.customTokenFormContent -import kotlinx.collections.immutable.ImmutableList +import com.tangem.features.managetokens.ui.CustomTokenFormContent +import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf internal class PreviewCustomTokenFormComponent( - networkName: ClickableFieldUM = ClickableFieldUM( - label = resourceReference(R.string.custom_token_network_input_title), - value = stringReference(value = "Ethereum"), - onClick = {}, - ), - derivationPath: ClickableFieldUM = ClickableFieldUM( - label = resourceReference(R.string.custom_token_derivation_path), - value = stringReference(value = "Default"), - onClick = {}, - ), + networkName: ClickableFieldUM = PreviewCustomTokenFormComponent.networkName, + derivationPath: ClickableFieldUM = PreviewCustomTokenFormComponent.derivationPath, canAddToken: Boolean = false, - contractAddress: TextInputFieldUM = TextInputFieldUM( - label = resourceReference(R.string.custom_token_contract_address_input_title), - placeholder = stringReference(value = "0x000000000000000000000000000"), - value = "", - onValueChange = {}, - ), - tokenName: TextInputFieldUM = TextInputFieldUM( - label = resourceReference(R.string.custom_token_name_input_title), - placeholder = stringReference(value = "E.g. USD Coin"), - value = "", - onValueChange = {}, - ), - tokenSymbol: TextInputFieldUM = TextInputFieldUM( - label = resourceReference(R.string.custom_token_token_symbol_input_title), - placeholder = stringReference(value = "E.g. USDC"), - value = "", - onValueChange = {}, - ), - tokenDecimals: TextInputFieldUM = TextInputFieldUM( - label = resourceReference(R.string.custom_token_decimals_input_title), - placeholder = stringReference(value = "8"), - value = "", - onValueChange = {}, - ), - notifications: ImmutableList = persistentListOf( - CustomTokenFormUM.NotificationUM( - id = "1", - config = NotificationConfig( - title = stringReference(value = "Note that tokens can be created by anyone"), - subtitle = stringReference(value = "Be aware of adding scam tokens, they can cost nothing"), - iconResId = R.drawable.img_attention_20, - ), - ), - ), + tokenForm: CustomTokenFormUM.TokenFormUM? = PreviewCustomTokenFormComponent.tokenForm, + notifications: PersistentList = PreviewCustomTokenFormComponent.notifications, ) : CustomTokenFormComponent { private val previewState = CustomTokenFormUM( networkName = networkName, - contractAddress = contractAddress, - tokenName = tokenName, - tokenSymbol = tokenSymbol, - tokenDecimals = tokenDecimals, + tokenForm = tokenForm, derivationPath = derivationPath, notifications = notifications, canAddToken = canAddToken, - onDerivationPathClick = {}, - onNetworkClick = {}, - onAddClick = {}, + saveToken = {}, ) - override fun content(scope: LazyListScope) { - scope.customTokenFormContent(model = previewState) + @Composable + override fun Content(modifier: Modifier) { + CustomTokenFormContent(modifier = modifier, model = previewState) + } + + companion object { + val networkName: ClickableFieldUM = ClickableFieldUM( + label = resourceReference(R.string.custom_token_network_input_title), + value = stringReference(value = "Ethereum"), + onClick = {}, + ) + val derivationPath: ClickableFieldUM = ClickableFieldUM( + label = resourceReference(R.string.custom_token_derivation_path), + value = stringReference(value = "Default"), + onClick = {}, + ) + val tokenForm: CustomTokenFormUM.TokenFormUM = CustomTokenFormUM.TokenFormUM( + contractAddress = TextInputFieldUM( + label = resourceReference(R.string.custom_token_contract_address_input_title), + placeholder = stringReference(value = "0x000000000000000000000000000"), + value = "", + onValueChange = {}, + ), + name = TextInputFieldUM( + label = resourceReference(R.string.custom_token_name_input_title), + placeholder = stringReference(value = "E.g. USD Coin"), + value = "", + onValueChange = {}, + ), + symbol = TextInputFieldUM( + label = resourceReference(R.string.custom_token_token_symbol_input_title), + placeholder = stringReference(value = "E.g. USDC"), + value = "", + onValueChange = {}, + ), + decimals = TextInputFieldUM( + label = resourceReference(R.string.custom_token_decimals_input_title), + placeholder = stringReference(value = "8"), + value = "", + onValueChange = {}, + ), + ) + val notifications: PersistentList = persistentListOf( + CustomTokenFormUM.NotificationUM( + id = "1", + config = NotificationConfig( + title = stringReference(value = "Note that tokens can be created by anyone"), + subtitle = stringReference(value = "Be aware of adding scam tokens, they can cost nothing"), + iconResId = R.drawable.img_attention_20, + ), + ), + ) } } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenNetworkSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenNetworkSelectorComponent.kt deleted file mode 100644 index 620dbe1222..0000000000 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenNetworkSelectorComponent.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.tangem.features.managetokens.component.preview - -import androidx.compose.foundation.lazy.LazyListScope -import com.tangem.domain.tokens.model.Network -import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent -import com.tangem.features.managetokens.entity.CurrencyNetworkUM -import com.tangem.features.managetokens.entity.CustomTokenNetworkSelectorUM -import com.tangem.features.managetokens.entity.SelectedNetworkUM -import com.tangem.features.managetokens.impl.R -import com.tangem.features.managetokens.ui.customTokenNetworkSelectorContent -import kotlinx.collections.immutable.toImmutableList - -internal class PreviewCustomTokenNetworkSelectorComponent( - private val params: CustomTokenNetworkSelectorComponent.Params = CustomTokenNetworkSelectorComponent.Params( - userWalletId = UserWalletId(stringValue = "321"), - selectedNetwork = null, - onNetworkSelected = {}, - ), - networksSize: Int = 5, -) : CustomTokenNetworkSelectorComponent { - - private val previewNetworks = List(size = networksSize) { networkIndex -> - val n = SelectedNetworkUM( - id = Network.ID(networkIndex.toString()), - name = "Network $networkIndex", - ) - - CurrencyNetworkUM( - id = n.id, - name = n.name, - type = "N$networkIndex", - iconResId = R.drawable.ic_eth_16, - isMainNetwork = false, - isSelected = n.id == params.selectedNetwork?.id, - onSelectedStateChange = { params.onNetworkSelected(n) }, - ) - }.toImmutableList() - - private val previewState = CustomTokenNetworkSelectorUM( - showTitle = params.selectedNetwork == null, - networks = previewNetworks, - ) - - override fun content(scope: LazyListScope) { - scope.customTokenNetworkSelectorContent( - model = previewState, - ) - } -} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt new file mode 100644 index 0000000000..24b389d850 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt @@ -0,0 +1,96 @@ +package com.tangem.features.managetokens.component.preview + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.component.CustomTokenSelectorComponent +import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params +import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM +import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath +import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork +import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM +import com.tangem.features.managetokens.entity.item.DerivationPathUM +import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.ui.CustomTokenSelectorContent +import kotlinx.collections.immutable.toImmutableList + +internal class PreviewCustomTokenSelectorComponent( + private val params: Params = Params.NetworkSelector( + userWalletId = UserWalletId(stringValue = "321"), + selectedNetwork = null, + onNetworkSelected = {}, + ), + itemsSize: Int = 5, +) : CustomTokenSelectorComponent { + + private val previewItems = List(size = itemsSize) { index -> + when (params) { + is Params.DerivationPathSelector -> { + val d = SelectedDerivationPath( + id = Network.ID(index.toString()), + value = Network.DerivationPath.Card("m/44'/0'/0'/0/$index"), + networkName = stringReference(value = "Network $index"), + ) + + DerivationPathUM( + id = d.id?.value ?: "", + value = d.value.value.orEmpty(), + networkName = d.networkName, + isSelected = d.value == params.selectedDerivationPath?.value, + onSelectedStateChange = { params.onDerivationPathSelected(d) }, + ) + } + is Params.NetworkSelector -> { + val n = SelectedNetwork( + id = Network.ID(index.toString()), + name = stringReference(value = "Network $index"), + derivationPath = Network.DerivationPath.Card("m/44'/0'/0'/0/$index"), + canHandleTokens = false, + ) + + CurrencyNetworkUM( + network = Network( + id = n.id, + backendId = n.id.value, + name = "", + currencySymbol = "", + derivationPath = Network.DerivationPath.Card(""), + isTestnet = false, + standardType = Network.StandardType.ERC20, + hasFiatFeeRate = false, + canHandleTokens = false, + ), + name = "Network $index", + type = "N$index", + iconResId = R.drawable.ic_eth_16, + isMainNetwork = false, + isSelected = n.id == params.selectedNetwork?.id, + onSelectedStateChange = { params.onNetworkSelected(n) }, + onLongClick = {}, + ) + } + } + }.toImmutableList() + + val previewState = CustomTokenSelectorUM( + header = when (params) { + is Params.DerivationPathSelector -> CustomTokenSelectorUM.HeaderUM.CustomDerivationButton( + value = null, + onClick = {}, + ) + is Params.NetworkSelector -> if (params.selectedNetwork == null) { + CustomTokenSelectorUM.HeaderUM.Description + } else { + CustomTokenSelectorUM.HeaderUM.None + } + }, + items = previewItems, + ) + + @Composable + override fun Content(modifier: Modifier) { + CustomTokenSelectorContent(modifier = modifier, model = previewState) + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt index 980afcb895..fae9aafb14 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt @@ -9,11 +9,14 @@ import androidx.compose.ui.util.fastForEachIndexed import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.fields.entity.SearchBarUM -import com.tangem.core.ui.components.rows.model.ChainRowUM import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency import com.tangem.domain.tokens.model.Network import com.tangem.features.managetokens.component.ManageTokensComponent -import com.tangem.features.managetokens.entity.* +import com.tangem.features.managetokens.entity.item.CurrencyItemUM +import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM +import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM +import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.ui.ManageTokensScreen import kotlinx.collections.immutable.mutate @@ -22,7 +25,9 @@ import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -internal class PreviewManageTokensComponent : ManageTokensComponent { +internal class PreviewManageTokensComponent( + private val isLoading: Boolean = false, +) : ManageTokensComponent { private val changedItemsIds: MutableSet = mutableSetOf() @@ -47,8 +52,11 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { onActiveChange = ::toggleSearchBar, ), hasChanges = false, - isLoading = false, - onSaveClick = {}, + isInitialBatchLoading = false, + isNextBatchLoading = true, + loadMore = { false }, + saveChanges = {}, + isSavingInProgress = false, ), ) @@ -58,7 +66,7 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { initItems() } else { items.filter { currency -> - currency.model.name.contains(query, ignoreCase = true) + currency.name.contains(query, ignoreCase = true) }.toPersistentList() } @@ -88,42 +96,40 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { } private fun initItems() = List(size = 30) { index -> - if (index < 2) { - getCustomItem(index) + if (isLoading) { + CurrencyItemUM.Loading(index) } else { - getBasicItem(index) + if (index < 2) { + getCustomItem(index) + } else { + getBasicItem(index) + } } }.toPersistentList() private fun getCustomItem(index: Int) = CurrencyItemUM.Custom( - id = index.toString(), - model = ChainRowUM( - name = "Custom token $index", - type = "CT$index", - icon = CurrencyIconState.CustomTokenIcon( - tint = Color.White, - background = Color.Black, - topBadgeIconResId = R.drawable.img_eth_22, - isGrayscale = false, - showCustomBadge = true, - ), - showCustom = true, + id = ManagedCryptoCurrency.ID(index.toString()), + name = "Custom token $index", + symbol = "CT$index", + icon = CurrencyIconState.CustomTokenIcon( + tint = Color.White, + background = Color.Black, + topBadgeIconResId = R.drawable.img_eth_22, + isGrayscale = false, + showCustomBadge = true, ), onRemoveClick = {}, ) private fun getBasicItem(index: Int) = CurrencyItemUM.Basic( - id = index.toString(), - model = ChainRowUM( - name = "Currency $index", - type = "C$index", - icon = CurrencyIconState.CoinIcon( - url = null, - fallbackResId = R.drawable.img_btc_22, - isGrayscale = false, - showCustomBadge = false, - ), - showCustom = false, + id = ManagedCryptoCurrency.ID(index.toString()), + name = "Currency $index", + symbol = "C$index", + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_btc_22, + isGrayscale = false, + showCustomBadge = false, ), networks = if (index == 2) { CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks(index)) @@ -135,13 +141,24 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { private fun getCurrencyNetworks(currencyIndex: Int) = List(size = 3) { networkIndex -> CurrencyNetworkUM( - id = Network.ID(networkIndex.toString()), + network = Network( + id = Network.ID(networkIndex.toString()), + backendId = networkIndex.toString(), + name = "Network $networkIndex", + currencySymbol = "N$networkIndex", + derivationPath = Network.DerivationPath.Card(""), + isTestnet = false, + standardType = Network.StandardType.ERC20, + hasFiatFeeRate = false, + canHandleTokens = false, + ), name = "NETWORK$networkIndex", type = "N$networkIndex", iconResId = R.drawable.ic_eth_16, isMainNetwork = networkIndex == 0, isSelected = false, onSelectedStateChange = { toggleNetwork(currencyIndex, networkIndex, isSelected = it) }, + onLongClick = {}, ) }.toImmutableList() @@ -154,7 +171,9 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { CurrencyItemUM.Basic.NetworksUM.Collapsed }, ) - is CurrencyItemUM.Custom -> return + is CurrencyItemUM.Custom, + is CurrencyItemUM.Loading, + -> return } previewState.update { state -> @@ -189,7 +208,9 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { item.copy(networks = updatedNetworks) } - is CurrencyItemUM.Custom -> return + is CurrencyItemUM.Custom, + is CurrencyItemUM.Loading, + -> return } val id = "${currencyIndex}_$networkIndex" diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ComponentModule.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ComponentModule.kt index f84b3c4c8e..ea21e92af1 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ComponentModule.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ComponentModule.kt @@ -1,7 +1,7 @@ package com.tangem.features.managetokens.di -import com.tangem.features.managetokens.component.ManageTokensComponent -import com.tangem.features.managetokens.component.impl.DefaultManageTokensComponent +import com.tangem.features.managetokens.component.* +import com.tangem.features.managetokens.component.impl.* import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -15,4 +15,28 @@ internal interface ComponentModule { @Binds @Singleton fun bindManageTokensComponentFactory(factory: DefaultManageTokensComponent.Factory): ManageTokensComponent.Factory + + @Binds + @Singleton + fun bindAddCustomTokenComponentFactory( + factory: DefaultAddCustomTokenComponent.Factory, + ): AddCustomTokenComponent.Factory + + @Binds + @Singleton + fun bindCustomTokenSelectorComponentFactory( + factory: DefaultCustomTokenSelectorComponent.Factory, + ): CustomTokenSelectorComponent.Factory + + @Binds + @Singleton + fun bindCustomTokenFormComponentFactory( + factory: DefaultCustomTokenFormComponent.Factory, + ): CustomTokenFormComponent.Factory + + @Binds + @Singleton + fun bindCustomTokenDerivationInputComponentFactory( + factory: DefaultCustomTokenDerivationInputComponent.Factory, + ): CustomTokenDerivationInputComponent.Factory } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ModelModule.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ModelModule.kt index 572c2624cb..0f33d5994f 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ModelModule.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ModelModule.kt @@ -2,6 +2,8 @@ package com.tangem.features.managetokens.di import com.tangem.core.decompose.di.DecomposeComponent import com.tangem.core.decompose.model.Model +import com.tangem.features.managetokens.model.CustomTokenFormModel +import com.tangem.features.managetokens.model.CustomTokenSelectorModel import com.tangem.features.managetokens.model.ManageTokensModel import dagger.Binds import dagger.Module @@ -17,4 +19,14 @@ internal interface ModelModule { @IntoMap @ClassKey(ManageTokensModel::class) fun provideManageTokensModel(model: ManageTokensModel): Model + + @Binds + @IntoMap + @ClassKey(CustomTokenFormModel::class) + fun provideCustomTokenFormModel(model: CustomTokenFormModel): Model + + @Binds + @IntoMap + @ClassKey(CustomTokenSelectorModel::class) + fun provideCustomTokenSelectorModel(model: CustomTokenSelectorModel): Model } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/AddCustomTokenUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/AddCustomTokenUM.kt deleted file mode 100644 index a34939d61f..0000000000 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/AddCustomTokenUM.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.tangem.features.managetokens.entity - -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.domain.tokens.model.Network - -@Immutable -internal sealed class AddCustomTokenUM : TangemBottomSheetConfigContent { - - abstract val selectedNetwork: SelectedNetworkUM? - abstract val addTokenButton: AddCustomTokenButtonUM - - abstract val popBack: () -> Unit - - data class NetworkSelector( - override val selectedNetwork: SelectedNetworkUM? = null, - override val popBack: () -> Unit, - ) : AddCustomTokenUM() { - - override val addTokenButton: AddCustomTokenButtonUM = AddCustomTokenButtonUM.Hidden - } - - data class Form( - override val selectedNetwork: SelectedNetworkUM, - override val addTokenButton: AddCustomTokenButtonUM.Visible, - override val popBack: () -> Unit, - ) : AddCustomTokenUM() -} - -@Immutable -internal data class SelectedNetworkUM( - val id: Network.ID, - val name: String, -) - -@Immutable -internal sealed class AddCustomTokenButtonUM { - - open val onClick: () -> Unit = {} - - open val isEnabled: Boolean = false - - val isVisible: Boolean - get() = this is Visible - - data object Hidden : AddCustomTokenButtonUM() { - override val onClick: () -> Unit = {} - } - - data class Visible( - override val isEnabled: Boolean, - override val onClick: () -> Unit, - ) : AddCustomTokenButtonUM() -} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyItemUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyItemUM.kt deleted file mode 100644 index 0cfc505545..0000000000 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyItemUM.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.tangem.features.managetokens.entity - -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.components.rows.model.ChainRowUM -import kotlinx.collections.immutable.ImmutableList - -@Immutable -internal sealed class CurrencyItemUM { - - abstract val id: String - abstract val model: ChainRowUM - - data class Basic( - override val id: String, - override val model: ChainRowUM, - val networks: NetworksUM, - val onExpandClick: () -> Unit, - ) : CurrencyItemUM() { - - @Immutable - sealed class NetworksUM { - - data object Collapsed : NetworksUM() - - data class Expanded( - val networks: ImmutableList, - ) : NetworksUM() - } - } - - data class Custom( - override val id: String, - override val model: ChainRowUM, - val onRemoveClick: () -> Unit, - ) : CurrencyItemUM() -} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyNetworkUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyNetworkUM.kt deleted file mode 100644 index 477585db3f..0000000000 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyNetworkUM.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.features.managetokens.entity - -import androidx.compose.runtime.Immutable -import com.tangem.domain.tokens.model.Network - -@Immutable -internal data class CurrencyNetworkUM( - val id: Network.ID, - val name: String, - val type: String, - val iconResId: Int, - val isMainNetwork: Boolean, - val isSelected: Boolean, - val onSelectedStateChange: (Boolean) -> Unit, -) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenFormUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenFormUM.kt deleted file mode 100644 index ea9bfbceb0..0000000000 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenFormUM.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.tangem.features.managetokens.entity - -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.components.notifications.NotificationConfig -import com.tangem.core.ui.extensions.TextReference -import kotlinx.collections.immutable.ImmutableList - -@Immutable -internal data class CustomTokenFormUM( - val networkName: ClickableFieldUM, - val contractAddress: TextInputFieldUM, - val tokenName: TextInputFieldUM, - val tokenSymbol: TextInputFieldUM, - val tokenDecimals: TextInputFieldUM, - val derivationPath: ClickableFieldUM, - val notifications: ImmutableList, - val canAddToken: Boolean, - val onNetworkClick: () -> Unit, - val onDerivationPathClick: () -> Unit, - val onAddClick: () -> Unit, -) { - - @Immutable - data class NotificationUM( - val id: String, - val config: NotificationConfig, - ) -} - -@Immutable -internal data class TextInputFieldUM( - val label: TextReference, - val placeholder: TextReference, - val value: String, - val onValueChange: (String) -> Unit, - val error: TextReference? = null, -) - -@Immutable -internal data class ClickableFieldUM( - val label: TextReference, - val value: TextReference, - val onClick: () -> Unit, -) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenNetworkSelectorUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenNetworkSelectorUM.kt deleted file mode 100644 index 1435d25f7d..0000000000 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenNetworkSelectorUM.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.features.managetokens.entity - -import androidx.compose.runtime.Immutable -import kotlinx.collections.immutable.ImmutableList - -@Immutable -internal data class CustomTokenNetworkSelectorUM( - val showTitle: Boolean, - val networks: ImmutableList, -) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensUM.kt deleted file mode 100644 index d53e7e5e4d..0000000000 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensUM.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.tangem.features.managetokens.entity - -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.components.fields.entity.SearchBarUM -import kotlinx.collections.immutable.ImmutableList - -@Immutable -internal sealed class ManageTokensUM { - - abstract val popBack: () -> Unit - abstract val isLoading: Boolean - abstract val items: ImmutableList - abstract val topBar: ManageTokensTopBarUM - abstract val search: SearchBarUM - - data class ReadContent( - override val popBack: () -> Unit, - override val isLoading: Boolean, - override val items: ImmutableList, - override val topBar: ManageTokensTopBarUM, - override val search: SearchBarUM, - ) : ManageTokensUM() - - data class ManageContent( - override val popBack: () -> Unit, - override val isLoading: Boolean, - override val items: ImmutableList, - override val topBar: ManageTokensTopBarUM, - override val search: SearchBarUM, - val onSaveClick: () -> Unit, - val hasChanges: Boolean, - ) : ManageTokensUM() - - fun copySealed( - search: SearchBarUM = this.search, - items: ImmutableList = this.items, - hasChanges: Boolean = this is ManageContent && this.hasChanges, - ): ManageTokensUM { - return when (this) { - is ManageContent -> copy(search = search, items = items, hasChanges = hasChanges) - is ReadContent -> copy(search = search, items = items) - } - } -} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/AddCustomTokenConfig.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/AddCustomTokenConfig.kt new file mode 100644 index 0000000000..1c5de5c702 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/AddCustomTokenConfig.kt @@ -0,0 +1,40 @@ +package com.tangem.features.managetokens.entity.customtoken + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.serialization.Serializable + +@Serializable +internal data class AddCustomTokenConfig( + val step: Step, + val popBack: () -> Unit, + val userWalletId: UserWalletId, + val selectedNetwork: SelectedNetwork? = null, + val selectedDerivationPath: SelectedDerivationPath? = null, + val formValues: CustomTokenFormValues = CustomTokenFormValues(), +) : TangemBottomSheetConfigContent { + + enum class Step { + INITIAL_NETWORK_SELECTOR, + NETWORK_SELECTOR, + DERIVATION_PATH_SELECTOR, + FORM, + } +} + +@Serializable +internal data class SelectedNetwork( + val id: Network.ID, + val name: TextReference, + val derivationPath: Network.DerivationPath, + val canHandleTokens: Boolean, +) + +@Serializable +internal data class SelectedDerivationPath( + val id: Network.ID?, + val value: Network.DerivationPath, + val networkName: TextReference, +) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomDerivationInputUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomDerivationInputUM.kt new file mode 100644 index 0000000000..0a0e28431a --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomDerivationInputUM.kt @@ -0,0 +1,12 @@ +package com.tangem.features.managetokens.entity.customtoken + +import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.core.ui.extensions.TextReference + +internal data class CustomDerivationInputUM( + val value: TextFieldValue, + val error: TextReference? = null, + val updateValue: (value: TextFieldValue) -> Unit, + val isConfirmEnabled: Boolean, + val onConfirm: () -> Unit, +) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt new file mode 100644 index 0000000000..fe72248f0d --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt @@ -0,0 +1,45 @@ +package com.tangem.features.managetokens.entity.customtoken + +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf + +internal data class CustomTokenFormUM( + val networkName: ClickableFieldUM, + val derivationPath: ClickableFieldUM, + val tokenForm: TokenFormUM?, + val notifications: PersistentList = persistentListOf(), + val canAddToken: Boolean = false, + val isValidating: Boolean = false, + val saveToken: () -> Unit, +) { + + data class TokenFormUM( + val contractAddress: TextInputFieldUM, + val name: TextInputFieldUM, + val symbol: TextInputFieldUM, + val decimals: TextInputFieldUM, + val wasFilled: Boolean = false, + ) + + data class NotificationUM( + val id: String, + val config: NotificationConfig, + ) +} + +internal data class TextInputFieldUM( + val label: TextReference, + val placeholder: TextReference, + val value: String = "", + val error: TextReference? = null, + val isEnabled: Boolean = true, + val onValueChange: (String) -> Unit, +) + +internal data class ClickableFieldUM( + val label: TextReference, + val value: TextReference, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormValues.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormValues.kt new file mode 100644 index 0000000000..983eb8e61a --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormValues.kt @@ -0,0 +1,45 @@ +package com.tangem.features.managetokens.entity.customtoken + +import com.tangem.domain.managetokens.model.AddCustomTokenForm +import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM.TokenFormUM +import kotlinx.serialization.Serializable + +@JvmInline +@Serializable +internal value class CustomTokenFormValues private constructor(private val values: List) { + + constructor() : this(values = emptyList()) + + constructor(form: TokenFormUM?) : this( + values = if (form == null) { + emptyList() + } else { + listOf( + form.contractAddress.value, + form.name.value, + form.symbol.value, + form.decimals.value, + ) + }, + ) + + fun fillValues(to: TokenFormUM): TokenFormUM = to.copy( + contractAddress = to.contractAddress.copy(value = values.getOrElse(index = 0) { "" }), + name = to.name.copy(value = values.getOrElse(index = 1) { "" }), + symbol = to.symbol.copy(value = values.getOrElse(index = 2) { "" }), + decimals = to.decimals.copy(value = values.getOrElse(index = 3) { "" }), + ) + + fun toDomainModel(): AddCustomTokenForm.Raw? { + return if (values.isEmpty()) { + null + } else { + AddCustomTokenForm.Raw( + contractAddress = values.getOrElse(index = 0) { "" }, + name = values.getOrElse(index = 1) { "" }, + symbol = values.getOrElse(index = 2) { "" }, + decimals = values.getOrElse(index = 3) { "" }, + ) + } + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt new file mode 100644 index 0000000000..19b090720b --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt @@ -0,0 +1,13 @@ +package com.tangem.features.managetokens.entity.customtoken + +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class CustomTokenSelectorDialogConfig { + + @Serializable + data class CustomDerivationInput( + val userWalletId: UserWalletId, + ) : CustomTokenSelectorDialogConfig() +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorUM.kt new file mode 100644 index 0000000000..d607a8b959 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorUM.kt @@ -0,0 +1,24 @@ +package com.tangem.features.managetokens.entity.customtoken + +import androidx.compose.runtime.Immutable +import com.tangem.features.managetokens.entity.item.SelectableItemUM +import kotlinx.collections.immutable.ImmutableList + +internal data class CustomTokenSelectorUM( + val header: HeaderUM, + val items: ImmutableList, +) { + + @Immutable + sealed class HeaderUM { + + data object None : HeaderUM() + + data object Description : HeaderUM() + + data class CustomDerivationButton( + val value: String?, + val onClick: () -> Unit, + ) : HeaderUM() + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyItemUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyItemUM.kt new file mode 100644 index 0000000000..d81b14e587 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyItemUM.kt @@ -0,0 +1,51 @@ +package com.tangem.features.managetokens.entity.item + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class CurrencyItemUM { + + abstract val id: ManagedCryptoCurrency.ID + abstract val name: String + abstract val symbol: String + abstract val icon: CurrencyIconState + + data class Basic( + override val id: ManagedCryptoCurrency.ID, + override val name: String, + override val symbol: String, + override val icon: CurrencyIconState, + val networks: NetworksUM, + val onExpandClick: () -> Unit, + ) : CurrencyItemUM() { + + @Immutable + sealed class NetworksUM { + + data object Collapsed : NetworksUM() + + data class Expanded( + val networks: ImmutableList, + ) : NetworksUM() + } + } + + data class Custom( + override val id: ManagedCryptoCurrency.ID, + override val name: String, + override val symbol: String, + override val icon: CurrencyIconState, + val onRemoveClick: () -> Unit, + ) : CurrencyItemUM() + + class Loading(val index: Int) : CurrencyItemUM() { + + override val id: ManagedCryptoCurrency.ID = ManagedCryptoCurrency.ID(value = "loading_$index") + override val name: String = "loading" + override val symbol: String = "loading" + override val icon: CurrencyIconState = CurrencyIconState.Loading + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyNetworkUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyNetworkUM.kt new file mode 100644 index 0000000000..bdec914faa --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyNetworkUM.kt @@ -0,0 +1,19 @@ +package com.tangem.features.managetokens.entity.item + +import com.tangem.domain.tokens.model.Network + +internal data class CurrencyNetworkUM( + val network: Network, + val name: String, + val type: String, + val iconResId: Int, + val isMainNetwork: Boolean, + val onLongClick: () -> Unit, + override val isSelected: Boolean, + override val onSelectedStateChange: (Boolean) -> Unit, +) : SelectableItemUM { + + override val id: String = network.id.value + + data class LongTapConfig(val contractAddress: String, val onLongTap: () -> Unit) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/DerivationPathUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/DerivationPathUM.kt new file mode 100644 index 0000000000..bf6387ff0d --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/DerivationPathUM.kt @@ -0,0 +1,11 @@ +package com.tangem.features.managetokens.entity.item + +import com.tangem.core.ui.extensions.TextReference + +internal data class DerivationPathUM( + override val id: String, + val value: String, + val networkName: TextReference, + override val isSelected: Boolean, + override val onSelectedStateChange: (Boolean) -> Unit, +) : SelectableItemUM \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/SelectableItemUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/SelectableItemUM.kt new file mode 100644 index 0000000000..73c5910af8 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/SelectableItemUM.kt @@ -0,0 +1,11 @@ +package com.tangem.features.managetokens.entity.item + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface SelectableItemUM { + + val id: String + val isSelected: Boolean + val onSelectedStateChange: (Boolean) -> Unit +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensBottomSheetConfig.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensBottomSheetConfig.kt new file mode 100644 index 0000000000..96f7553288 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensBottomSheetConfig.kt @@ -0,0 +1,13 @@ +package com.tangem.features.managetokens.entity.managetokens + +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class ManageTokensBottomSheetConfig { + + @Serializable + data class AddCustomToken( + val userWalletId: UserWalletId, + ) : ManageTokensBottomSheetConfig() +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensTopBarUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensTopBarUM.kt similarity index 91% rename from features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensTopBarUM.kt rename to features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensTopBarUM.kt index b2f08a0ef0..875ed483ed 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensTopBarUM.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensTopBarUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.managetokens.entity +package com.tangem.features.managetokens.entity.managetokens import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM 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 new file mode 100644 index 0000000000..8a0bf34154 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensUM.kt @@ -0,0 +1,75 @@ +package com.tangem.features.managetokens.entity.managetokens + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.event.consumedEvent +import com.tangem.features.managetokens.entity.item.CurrencyItemUM +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class ManageTokensUM { + + abstract val popBack: () -> Unit + abstract val isInitialBatchLoading: Boolean + abstract val isNextBatchLoading: Boolean + abstract val items: ImmutableList + abstract val topBar: ManageTokensTopBarUM + abstract val search: SearchBarUM + abstract val loadMore: () -> Boolean + abstract val scrollToTop: StateEvent + + data class ReadContent( + override val popBack: () -> Unit, + override val isInitialBatchLoading: Boolean, + override val isNextBatchLoading: Boolean, + override val items: ImmutableList, + override val topBar: ManageTokensTopBarUM, + override val search: SearchBarUM, + override val loadMore: () -> Boolean, + override val scrollToTop: StateEvent = consumedEvent(), + ) : ManageTokensUM() + + data class ManageContent( + override val popBack: () -> Unit, + override val isInitialBatchLoading: Boolean, + override val isNextBatchLoading: Boolean, + override val items: ImmutableList, + override val topBar: ManageTokensTopBarUM, + override val search: SearchBarUM, + override val loadMore: () -> Boolean, + override val scrollToTop: StateEvent = consumedEvent(), + val saveChanges: () -> Unit, + val hasChanges: Boolean, + val isSavingInProgress: Boolean, + ) : ManageTokensUM() + + fun copySealed( + search: SearchBarUM = this.search, + items: ImmutableList = this.items, + hasChanges: Boolean = this is ManageContent && this.hasChanges, + isInitialBatchLoading: Boolean = this.isInitialBatchLoading, + isNextBatchLoading: Boolean = this.isNextBatchLoading, + isSavingInProgress: Boolean = this is ManageContent && this.isSavingInProgress, + scrollToTop: StateEvent = this.scrollToTop, + ): ManageTokensUM { + return when (this) { + is ManageContent -> copy( + search = search, + items = items, + hasChanges = hasChanges, + isInitialBatchLoading = isInitialBatchLoading, + isNextBatchLoading = isNextBatchLoading, + isSavingInProgress = isSavingInProgress, + scrollToTop = scrollToTop, + ) + is ReadContent -> copy( + search = search, + items = items, + isInitialBatchLoading = isInitialBatchLoading, + isNextBatchLoading = isNextBatchLoading, + scrollToTop = scrollToTop, + ) + } + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt new file mode 100644 index 0000000000..4e678f1bb4 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt @@ -0,0 +1,367 @@ +package com.tangem.features.managetokens.model + +import androidx.compose.ui.res.stringResource +import arrow.core.getOrElse +import com.tangem.core.decompose.di.ComponentScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.SimpleOkDialog +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.ContentMessage +import com.tangem.domain.card.DerivePublicKeysUseCase +import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException +import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.features.managetokens.component.CustomTokenFormComponent +import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM +import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM +import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormValues +import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM +import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.utils.CustomCurrencyValidator +import com.tangem.features.managetokens.utils.mapper.mapToDomainModel +import com.tangem.features.managetokens.utils.ui.* +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@ComponentScoped +internal class CustomTokenFormModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val customCurrencyValidator: CustomCurrencyValidator, + private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, + private val derivePublicKeysUseCase: DerivePublicKeysUseCase, + private val messageSender: UiMessageSender, + paramsContainer: ParamsContainer, +) : Model() { + + private val params: CustomTokenFormComponent.Params = paramsContainer.require() + private var createdCurrency: CryptoCurrency? = null + + val state: MutableStateFlow = MutableStateFlow( + value = getInitialState(), + ) + + init { + observeValidatorUpdates() + + if (params.network.canHandleTokens) { + observeTokenFormUpdates() + } else { + modelScope.launch { + customCurrencyValidator.createCoin( + userWalletId = params.userWalletId, + networkId = params.network.id, + derivationPath = getDerivationPath(), + ) + } + } + } + + private fun getInitialState(): CustomTokenFormUM { + return CustomTokenFormUM( + networkName = ClickableFieldUM( + label = resourceReference(R.string.custom_token_network_input_title), + value = params.network.name, + onClick = ::selectNetwork, + ), + tokenForm = if (params.network.canHandleTokens) { + getInitialTokenForm() + } else { + null + }, + derivationPath = ClickableFieldUM( + label = resourceReference(R.string.custom_token_derivation_path), + value = if (params.derivationPath == null || params.derivationPath.id == params.network.id) { + resourceReference(R.string.custom_token_derivation_path_default) + } else { + params.derivationPath.networkName + }, + onClick = ::selectDerivationPath, + ), + saveToken = ::addCurrency, + ) + } + + @OptIn(FlowPreview::class) + private fun observeTokenFormUpdates() { + state + .transform { state -> + val form = state.tokenForm + + if (form != null && !form.wasFilled) { + emit(form.mapToDomainModel()) + } + } + .distinctUntilChanged() + .sample(periodMillis = 1_000) + .onEach { formValues -> + customCurrencyValidator.validateForm( + userWalletId = params.userWalletId, + networkId = params.network.id, + derivationPath = getDerivationPath(), + formValues = formValues, + ) + } + .launchIn(modelScope) + } + + private fun observeValidatorUpdates() = modelScope.launch { + customCurrencyValidator.consumeUpdates { validatorState -> + createdCurrency = null + + when (validatorState) { + is CustomCurrencyValidator.Status.NotStarted, + is CustomCurrencyValidator.Status.Validating, + -> Unit + is CustomCurrencyValidator.Status.SearchingToken -> updateStateWithProgress() + is CustomCurrencyValidator.Status.UnexpectedException -> showErrorDialog() + is CustomCurrencyValidator.Status.FormValidationException -> updateStateWithExceptions( + exceptions = validatorState.exceptions, + ) + is CustomCurrencyValidator.Status.TokenNotFound -> updateStateWithNotFoundNotification() + is CustomCurrencyValidator.Status.Validated -> { + createdCurrency = validatorState.currency + + updateStateWithCurrency( + currency = validatorState.currency, + fillForm = validatorState.fillForm, + isAlreadyAdded = validatorState.isAlreadyAdded, + isCustom = validatorState.isCustom, + ) + } + } + } + } + + private fun updateStateWithCurrency( + currency: CryptoCurrency, + fillForm: Boolean, + isAlreadyAdded: Boolean, + isCustom: Boolean, + ) { + state.update { state -> + var updatedState = state + .updateWithProgress( + showProgress = false, + canAddToken = !isAlreadyAdded, + isWasFilled = fillForm, + clearNotifications = true, + clearFieldErrors = true, + disableSecondaryFields = !isCustom, + ) + + if (fillForm) { + updatedState = updatedState.updateWithCurrency(currency) + } + + if (isAlreadyAdded) { + updatedState = updatedState.updateWithCurrencyAlreadyAddedNotification() + } + + if (isCustom) { + updatedState = updatedState.updateWithCurrencyNotFoundNotification() + } + + updatedState + } + } + + private fun updateStateWithNotFoundNotification() { + state.update { state -> + state + .updateWithProgress( + showProgress = false, + canAddToken = false, + clearNotifications = true, + clearFieldErrors = true, + ) + .updateWithCurrencyNotFoundNotification() + } + } + + private fun updateStateWithProgress() { + state.update { state -> + state.updateWithProgress( + showProgress = true, + canAddToken = false, + clearNotifications = false, + clearFieldErrors = false, + ) + } + } + + private fun showErrorDialog() { + val dialog = ContentMessage { onDismiss -> + SimpleOkDialog( + message = stringResource(R.string.common_unknown_error), + onDismissDialog = onDismiss, + ) + } + + messageSender.send(dialog) + } + + private fun updateStateWithExceptions(exceptions: List) { + state.update { state -> + val validatedState = state + .updateWithProgress( + showProgress = false, + canAddToken = false, + clearNotifications = true, + clearFieldErrors = true, + ) + + exceptions.fold(validatedState) { stateAcc, exception -> + when (exception) { + is CustomTokenFormValidationException.ContractAddress -> { + stateAcc.updateWithContractAddressException(exception) + } + is CustomTokenFormValidationException.Decimals -> { + stateAcc.updateWithDecimalsException(exception) + } + is CustomTokenFormValidationException.EmptyName -> { + stateAcc // No need to display error + } + is CustomTokenFormValidationException.EmptySymbol -> { + stateAcc // No need to display error + } + is CustomTokenFormValidationException.DataError -> { + Timber.e(exception.cause, "Unable to validate custom currency") + stateAcc + } + } + } + } + } + + private fun getInitialTokenForm(): CustomTokenFormUM.TokenFormUM { + val formValues = params.formValues + + val form = CustomTokenFormUM.TokenFormUM( + contractAddress = TextInputFieldUM( + label = resourceReference(R.string.custom_token_contract_address_input_title), + placeholder = stringReference(CONTRACT_ADDRESS_PLACEHOLDER), + onValueChange = ::updateContractAddress, + ), + name = TextInputFieldUM( + label = resourceReference(R.string.custom_token_name_input_title), + placeholder = resourceReference(R.string.custom_token_name_input_placeholder), + onValueChange = ::updateTokenName, + ), + symbol = TextInputFieldUM( + label = resourceReference(R.string.custom_token_token_symbol_input_title), + placeholder = resourceReference(R.string.custom_token_token_symbol_input_placeholder), + onValueChange = ::updateTokenSymbol, + ), + decimals = TextInputFieldUM( + label = resourceReference(R.string.custom_token_decimals_input_title), + placeholder = stringReference(DECIMALS_PLACEHOLDER), + onValueChange = ::updateDecimals, + ), + ) + + return formValues.fillValues(form) + } + + private fun getDerivationPath(): Network.DerivationPath { + return params.derivationPath?.value ?: params.network.derivationPath + } + + private fun updateContractAddress(value: String) { + state.update { state -> + state.updateTokenForm { + copy( + contractAddress = contractAddress.updateValue(value), + wasFilled = false, + ) + } + } + } + + private fun updateTokenName(value: String) { + state.update { state -> + state.updateTokenForm { + copy( + name = name.updateValue(value), + wasFilled = false, + ) + } + } + } + + private fun updateTokenSymbol(value: String) { + state.update { state -> + state.updateTokenForm { + copy( + symbol = symbol.updateValue(value), + wasFilled = false, + ) + } + } + } + + private fun updateDecimals(value: String) { + state.update { state -> + state.updateTokenForm { + copy( + decimals = decimals.updateValue(value), + wasFilled = false, + ) + } + } + } + + private fun addCurrency() = resource( + acquire = { + state.update { state -> + state.updateWithProgress(showProgress = true) + } + }, + release = { + state.update { state -> + state.updateWithProgress(showProgress = false) + } + }, + ) { + val currency = createdCurrency + if (currency == null) { + Timber.e("Trying to add currency without validation") + showErrorDialog() + return@resource + } + + derivePublicKeysUseCase(params.userWalletId, listOf(currency)).getOrElse { + Timber.e(it, "Failed to derive public keys") + showErrorDialog() + return@resource + } + + addCryptoCurrenciesUseCase(params.userWalletId, currency).getOrElse { + Timber.e(it, "Failed to add currency") + showErrorDialog() + return@resource + } + + params.onCurrencyAdded() + } + + private fun selectNetwork() { + params.onSelectNetworkClick(CustomTokenFormValues(state.value.tokenForm)) + } + + private fun selectDerivationPath() { + params.onSelectDerivationPathClick(CustomTokenFormValues(state.value.tokenForm)) + } + + private companion object { + const val CONTRACT_ADDRESS_PLACEHOLDER = "0x000000000000000000000000000..." + const val DECIMALS_PLACEHOLDER = "0" + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..611c6a1fe9 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt @@ -0,0 +1,172 @@ +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.core.decompose.di.ComponentScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.managetokens.GetSupportedNetworksUseCase +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.component.CustomTokenSelectorComponent +import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params.DerivationPathSelector +import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params.NetworkSelector +import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorDialogConfig +import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM +import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM.HeaderUM +import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath +import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork +import com.tangem.features.managetokens.entity.item.DerivationPathUM +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.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ComponentScoped +internal class CustomTokenSelectorModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val getSupportedNetworksUseCase: GetSupportedNetworksUseCase, + private val messageSender: UiMessageSender, + paramsContainer: ParamsContainer, +) : Model() { + + private val params: CustomTokenSelectorComponent.Params = paramsContainer.require() + + val dialogNavigation: SlotNavigation = SlotNavigation() + + val state: MutableStateFlow = MutableStateFlow( + value = getInitialState(), + ) + + init { + loadItems() + } + + private fun getInitialState(): CustomTokenSelectorUM = when (params) { + is NetworkSelector -> CustomTokenSelectorUM( + header = if (params.selectedNetwork == null) { + HeaderUM.Description + } else { + HeaderUM.None + }, + items = persistentListOf(), + ) + is DerivationPathSelector -> CustomTokenSelectorUM( + header = HeaderUM.CustomDerivationButton( + value = (params.selectedDerivationPath?.value as? Network.DerivationPath.Custom)?.value, + onClick = ::showCustomDerivationInput, + ), + items = persistentListOf(), + ) + } + + private fun loadItems() = modelScope.launch { + val items = when (params) { + is NetworkSelector -> loadNetworks(params).toImmutableList() + is DerivationPathSelector -> loadDerivationPaths(params).toImmutableList() + } + + state.update { state -> + state.copy(items = items) + } + } + + private suspend fun loadNetworks(selector: NetworkSelector): List { + return getSupportedNetworks(selector.userWalletId).map { network -> + network.toCurrencyNetworkModel( + isSelected = network.id == selector.selectedNetwork?.id, + onSelectedStateChange = { + val model = SelectedNetwork( + id = network.id, + name = stringReference(network.name), + derivationPath = network.derivationPath, + canHandleTokens = network.canHandleTokens, + ) + + selector.onNetworkSelected(model) + }, + ) + } + } + + private suspend fun loadDerivationPaths(selector: DerivationPathSelector): List { + val derivationPaths = mutableListOf() + val defaultPath = selector.selectedNetwork.let { network -> + network.toDerivationPathModel( + isSelected = network.id == selector.selectedDerivationPath?.id, + onSelectedStateChange = { + val model = SelectedDerivationPath( + id = network.id, + networkName = resourceReference(R.string.custom_token_derivation_path_default), + value = network.derivationPath, + ) + + selector.onDerivationPathSelected(model) + }, + ) + } + + if (defaultPath != null) { + derivationPaths.add(defaultPath) + } + + getSupportedNetworks(selector.userWalletId) + .mapNotNullTo(derivationPaths) { network -> + if (network.id == selector.selectedNetwork.id) { + return@mapNotNullTo null // Skip default path + } + + network.toDerivationPathModel( + isSelected = network.id == selector.selectedDerivationPath?.id, + onSelectedStateChange = { + val model = SelectedDerivationPath( + id = network.id, + networkName = stringReference(network.name), + value = network.derivationPath, + ) + + selector.onDerivationPathSelected(model) + }, + ) + } + + return derivationPaths + } + + private suspend fun getSupportedNetworks(userWalletId: UserWalletId): List { + return getSupportedNetworksUseCase(userWalletId).getOrElse { e -> + val message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)) + messageSender.send(message) + + emptyList() + } + } + + private fun showCustomDerivationInput() { + val config = when (params) { + is NetworkSelector -> return + is DerivationPathSelector -> CustomTokenSelectorDialogConfig.CustomDerivationInput(params.userWalletId) + } + + dialogNavigation.activate(config) + } + + fun selectCustomDerivationPath(value: SelectedDerivationPath) { + when (params) { + is NetworkSelector -> return + is DerivationPathSelector -> params.onDerivationPathSelected(value) + } + } +} \ 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 6141538abd..5c4d924942 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 @@ -1,53 +1,97 @@ package com.tangem.features.managetokens.model -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.util.fastForEachIndexed +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate import com.tangem.core.decompose.di.ComponentScoped 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.components.appbar.models.TopAppBarButtonUM -import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.fields.entity.SearchBarUM -import com.tangem.core.ui.components.rows.model.ChainRowUM +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.tokens.model.Network +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.managetokens.SaveManagedTokensUseCase +import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.managetokens.component.ManageTokensComponent -import com.tangem.features.managetokens.entity.* +import com.tangem.features.managetokens.entity.item.CurrencyItemUM +import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig +import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM +import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.utils.list.ChangedCurrencies +import com.tangem.features.managetokens.utils.list.ManageTokensListManager +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.PaginationStatus import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.mutate -import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @ComponentScoped internal class ManageTokensModel @Inject constructor( - paramsContainer: ParamsContainer, - private val router: Router, override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val manageTokensListManager: ManageTokensListManager, + private val messageSender: UiMessageSender, + private val saveManagedTokensUseCase: SaveManagedTokensUseCase, + paramsContainer: ParamsContainer, ) : Model() { private val params: ManageTokensComponent.Params = paramsContainer.require() - private val changedItemsIds: MutableSet = mutableSetOf() - private var items = initItems() - val state: MutableStateFlow = MutableStateFlow(value = getInitialState(mode = params.mode)) + val state: MutableStateFlow = MutableStateFlow(getInitialState(params.userWalletId)) + val bottomSheetNavigation: SlotNavigation = SlotNavigation() - private fun getInitialState(mode: ManageTokensComponent.Mode): ManageTokensUM { - return when (mode) { - ManageTokensComponent.Mode.READ_ONLY -> createReadContentModel() - ManageTokensComponent.Mode.MANAGE -> createManageContentModel() + init { + manageTokensListManager.uiItems + .onEach { items -> updateItems(items) } + .launchIn(modelScope) + + manageTokensListManager.paginationStatus + .onEach { status -> updatePaginationStatus(status) } + .launchIn(modelScope) + + combine( + manageTokensListManager.currenciesToAdd, + manageTokensListManager.currenciesToRemove, + ::updateChangedItems, + ).launchIn(modelScope) + + observeSearchQueryChanges() + + modelScope.launch { + manageTokensListManager.launchPagination(params.userWalletId) + } + } + + fun reloadList() { + modelScope.launch { + manageTokensListManager.reload(params.userWalletId) + } + } + + private fun getInitialState(userWalletId: UserWalletId?): ManageTokensUM { + return if (userWalletId == null) { + createReadContentModel() + } else { + createManageContentModel() } } private fun createReadContentModel(): ManageTokensUM.ReadContent { return ManageTokensUM.ReadContent( popBack = router::pop, - isLoading = false, - items = initItems(), + isInitialBatchLoading = true, + isNextBatchLoading = false, + items = getLoadingItems(), topBar = ManageTokensTopBarUM.ReadContent( title = resourceReference(R.string.common_search_tokens), onBackButtonClick = router::pop, @@ -59,20 +103,22 @@ internal class ManageTokensModel @Inject constructor( isActive = false, onActiveChange = ::toggleSearchBar, ), + loadMore = ::loadMoreItems, ) } private fun createManageContentModel(): ManageTokensUM.ManageContent { return ManageTokensUM.ManageContent( popBack = router::pop, - isLoading = false, - items = initItems(), + isInitialBatchLoading = true, + isNextBatchLoading = false, + items = getLoadingItems(), topBar = ManageTokensTopBarUM.ManageContent( title = resourceReference(id = R.string.main_manage_tokens), onBackButtonClick = router::pop, endButton = TopAppBarButtonUM( iconRes = R.drawable.ic_plus_24, - onIconClicked = ::onAddCustomToken, + onIconClicked = ::navigateToAddCustomToken, ), ), search = SearchBarUM( @@ -82,161 +128,179 @@ internal class ManageTokensModel @Inject constructor( isActive = false, onActiveChange = ::toggleSearchBar, ), - onSaveClick = ::onSaveClick, hasChanges = false, + saveChanges = ::saveChanges, + loadMore = ::loadMoreItems, + isSavingInProgress = false, ) } - private fun onAddCustomToken() { - // TODO: [REDACTED_JIRA] + @OptIn(FlowPreview::class) + private fun observeSearchQueryChanges() { + state + .distinctUntilChanged { old, new -> + // It's also used to skip search activation to avoid searching an empty query + old.search.query == new.search.query && + (old.search.isActive == new.search.isActive || new.search.isActive) + } + .transform { state -> + val query = state.search.query + + if (state.search.isActive) { + emit(query) + } + } + .sample(periodMillis = 1_000) + .onEach { query -> + manageTokensListManager.search( + userWalletId = params.userWalletId, + query = query, + ) + } + .launchIn(modelScope) } - private fun onSaveClick() { - // TODO: [REDACTED_JIRA] - } - - @Suppress("UnusedPrivateMember") - private fun searchCurrencies(query: String) { - // TODO: [REDACTED_JIRA] - val newItems = if (query.isBlank()) { - initItems() - } else { - state.value.items.filter { currency -> - currency.model.name.contains(query, ignoreCase = true) - }.toPersistentList() - } + private fun updateItems(items: ImmutableList) { state.update { state -> - state.copySealed(search = state.search.copy(query = query), items = newItems) + state.copySealed( + items = items, + ) + } + } + + private fun updatePaginationStatus(status: PaginationStatus<*>) { + state.update { state -> + when (status) { + is PaginationStatus.None, + is PaginationStatus.InitialLoading, + -> { + if (state.search.isActive) { + state.copySealed( + items = getLoadingItems(), + ) + } else { + state.copySealed( + items = getLoadingItems(), + isInitialBatchLoading = true, + ) + } + } + is PaginationStatus.NextBatchLoading -> state.copySealed( + isNextBatchLoading = true, + ) + is PaginationStatus.InitialLoadingError -> { + val message = SnackbarMessage( + message = status.throwable.localizedMessage + ?.let(::stringReference) + ?: resourceReference(R.string.common_error), + ) + messageSender.send(message) + + state.copySealed( + isInitialBatchLoading = false, + isNextBatchLoading = false, + ) + } + is PaginationStatus.Paginating -> { + (status.lastResult as? BatchFetchResult.Error)?.let { fetchError -> + Timber.e(fetchError.throwable) + } + + state.copySealed( + isInitialBatchLoading = false, + isNextBatchLoading = false, + scrollToTop = if (state.isInitialBatchLoading && state.items.isNotEmpty()) { + triggeredEvent( + data = Unit, + onConsume = ::consumeScrollToTopEvent, + ) + } else { + state.scrollToTop + }, + ) + } + is PaginationStatus.EndOfPagination -> state.copySealed( + isInitialBatchLoading = false, + isNextBatchLoading = false, + ) + } + } + } + + private fun getLoadingItems(): ImmutableList { + return List(size = 10) { index -> + CurrencyItemUM.Loading(index) + }.toPersistentList() + } + + private fun consumeScrollToTopEvent() { + this.state.update { state -> + state.copySealed( + scrollToTop = consumedEvent(), + ) + } + } + + private fun updateChangedItems(currenciesToAdd: ChangedCurrencies, currenciesToRemove: ChangedCurrencies) { + state.update { state -> + state.copySealed( + hasChanges = currenciesToAdd.isNotEmpty() || currenciesToRemove.isNotEmpty(), + ) + } + } + + private fun loadMoreItems(): Boolean { + val state = state.value + if (state.isInitialBatchLoading || state.isNextBatchLoading) return false + + modelScope.launch { + manageTokensListManager.loadMore( + userWalletId = params.userWalletId, + query = state.search.query, + ) + } + + return true + } + + private fun navigateToAddCustomToken() { + params.userWalletId?.let { + bottomSheetNavigation.activate(ManageTokensBottomSheetConfig.AddCustomToken(it)) + } + } + + private fun saveChanges() { + modelScope.launch { + state.update { state -> state.copySealed(isSavingInProgress = true) } + saveManagedTokensUseCase.invoke( + userWalletId = requireNotNull(params.userWalletId), + currenciesToAdd = manageTokensListManager.currenciesToAdd.value, + currenciesToRemove = manageTokensListManager.currenciesToRemove.value, + ).fold( + ifLeft = { Timber.e(it, "Failed to save changes") }, + ifRight = { router.pop() }, + ) + state.update { state -> state.copySealed(isSavingInProgress = false) } + } + } + + private fun searchCurrencies(query: String) { + state.update { state -> + state.copySealed( + search = state.search.copy( + query = query, + isActive = true, + ), + ) } } private fun toggleSearchBar(isActive: Boolean) { state.update { state -> state.copySealed( - search = state.search.copy(isActive = isActive), - ) - } - } - - private fun initItems() = List(size = 30) { index -> - if (index < 2) { - getCustomItem(index) - } else { - getBasicItem(index) - } - }.toPersistentList() - - private fun getCustomItem(index: Int) = CurrencyItemUM.Custom( - id = index.toString(), - model = ChainRowUM( - name = "Custom token $index", - type = "CT$index", - icon = CurrencyIconState.CustomTokenIcon( - tint = Color.White, - background = Color.Black, - topBadgeIconResId = R.drawable.img_eth_22, - isGrayscale = false, - showCustomBadge = true, - ), - showCustom = true, - ), - onRemoveClick = {}, - ) - - private fun getBasicItem(index: Int) = CurrencyItemUM.Basic( - id = index.toString(), - model = ChainRowUM( - name = "Currency $index", - type = "C$index", - icon = CurrencyIconState.CoinIcon( - url = null, - fallbackResId = R.drawable.img_btc_22, - isGrayscale = false, - showCustomBadge = false, - ), - showCustom = false, - ), - networks = if (index == 2) { - CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks(index)) - } else { - CurrencyItemUM.Basic.NetworksUM.Collapsed - }, - onExpandClick = { toggleCurrency(index) }, - ) - - private fun getCurrencyNetworks(currencyIndex: Int) = List(size = 3) { networkIndex -> - CurrencyNetworkUM( - id = Network.ID(networkIndex.toString()), - name = "NETWORK$networkIndex", - type = "N$networkIndex", - iconResId = R.drawable.ic_eth_16, - isMainNetwork = networkIndex == 0, - isSelected = false, - onSelectedStateChange = { toggleNetwork(currencyIndex, networkIndex, isSelected = it) }, - ) - }.toImmutableList() - - private fun toggleCurrency(index: Int) { - val updatedItem = when (val item = items[index]) { - is CurrencyItemUM.Basic -> item.copy( - networks = if (item.networks is CurrencyItemUM.Basic.NetworksUM.Collapsed) { - CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks(index)) - } else { - CurrencyItemUM.Basic.NetworksUM.Collapsed - }, - ) - is CurrencyItemUM.Custom -> return - } - - state.update { state -> - items = items.mutate { - it[index] = updatedItem - } - state.copySealed(items = items) - } - } - - private fun toggleNetwork(currencyIndex: Int, networkIndex: Int, isSelected: Boolean) { - val updatedItem = when (val item = items[currencyIndex]) { - is CurrencyItemUM.Basic -> { - val updatedNetworks = (item.networks as? CurrencyItemUM.Basic.NetworksUM.Expanded) - ?.copy( - networks = item.networks.networks.toPersistentList().mutate { - it.fastForEachIndexed { index, network -> - if (index == networkIndex) { - it[index] = network.copy( - iconResId = if (isSelected) { - R.drawable.img_eth_22 - } else { - R.drawable.ic_eth_16 - }, - isSelected = isSelected, - ) - } - } - }, - ) - ?: return - - item.copy(networks = updatedNetworks) - } - is CurrencyItemUM.Custom -> return - } - - val id = "${currencyIndex}_$networkIndex" - if (changedItemsIds.contains(id)) { - changedItemsIds.remove(id) - } else { - changedItemsIds.add(id) - } - - state.update { state -> - items = items.mutate { - it[currencyIndex] = updatedItem - } - state.copySealed( - items = items, - hasChanges = changedItemsIds.isNotEmpty(), + search = state.search.copy( + isActive = isActive, + ), ) } } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt index a002b54b4c..69ea2418d6 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt @@ -1,146 +1,80 @@ package com.tangem.features.managetokens.ui import android.content.res.Configuration -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.FabPosition -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -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.core.ui.components.PrimaryButton import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle -import com.tangem.core.ui.components.isOpened -import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.managetokens.component.AddCustomTokenComponent import com.tangem.features.managetokens.component.preview.PreviewAddCustomTokenComponent -import com.tangem.features.managetokens.entity.AddCustomTokenButtonUM -import com.tangem.features.managetokens.entity.AddCustomTokenUM -import com.tangem.features.managetokens.entity.SelectedNetworkUM +import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenConfig +import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath +import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork import com.tangem.features.managetokens.impl.R @Composable -internal fun AddCustomTokenBottomSheet(config: TangemBottomSheetConfig, content: LazyListScope.() -> Unit) { - TangemBottomSheet( +internal fun AddCustomTokenBottomSheet(config: TangemBottomSheetConfig, content: @Composable (Modifier) -> Unit) { + TangemBottomSheet( config = config, + addBottomInsets = false, title = { model -> Title(model) }, containerColor = TangemTheme.colors.background.secondary, - content = { model -> - Content( - model = model, - content = content, - ) + content = { + val contentModifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxSize() + + content(contentModifier) }, ) } @Composable -private fun Title(model: AddCustomTokenUM, modifier: Modifier = Modifier) { - val showTokenNetworkTitle = model is AddCustomTokenUM.NetworkSelector && model.selectedNetwork != null - - if (showTokenNetworkTitle) { - TangemTopAppBar( - modifier = modifier, - title = resourceReference(R.string.custom_token_network_selector_title), - titleAlignment = Alignment.CenterHorizontally, - startButton = TopAppBarButtonUM.Back(model.popBack), - height = TangemTopAppBarHeight.BOTTOM_SHEET, - ) - } else { - TangemBottomSheetTitle( - modifier = modifier, - title = resourceReference(R.string.add_custom_token_title), - ) - } -} - -@Composable -private fun Content(model: AddCustomTokenUM, content: LazyListScope.() -> Unit, modifier: Modifier = Modifier) { - val density = LocalDensity.current - val keyboardState by keyboardAsState() - - var fabHeight by remember { mutableStateOf(0.dp) } - - Scaffold( - modifier = modifier.imePadding(), - containerColor = TangemTheme.colors.background.secondary, - floatingActionButtonPosition = FabPosition.Center, - floatingActionButton = { - AnimatedVisibility( - modifier = Modifier.onSizeChanged { - fabHeight = with(density) { it.height.toDp() } - }, - visible = model.addTokenButton.isVisible && !keyboardState.isOpened, - enter = fadeIn(), - exit = fadeOut(), - label = "Add button visibility", - ) { - PrimaryButton( - modifier = Modifier - .padding(bottom = TangemTheme.dimens.spacing16) - .padding(horizontal = TangemTheme.dimens.spacing16) - .fillMaxWidth(), - text = stringResource(id = R.string.custom_token_add_token), - enabled = model.addTokenButton.isEnabled, - onClick = model.addTokenButton.onClick, - ) - } - }, - ) { paddingValues -> - LazyColumn( - modifier = Modifier.padding(paddingValues), - contentPadding = PaddingValues( - start = TangemTheme.dimens.spacing16, - end = TangemTheme.dimens.spacing16, - bottom = TangemTheme.dimens.spacing32 + fabHeight, - ), - ) { - item { - if (model is AddCustomTokenUM.NetworkSelector && model.selectedNetwork != null) { - Spacer(modifier = Modifier.size(TangemTheme.dimens.spacing12)) - } else { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = TangemTheme.dimens.spacing16), - contentAlignment = Alignment.Center, - ) { - Text( - modifier = Modifier.fillMaxWidth(fraction = 0.7f), - text = stringResource(id = R.string.custom_token_subtitle), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.secondary, - textAlign = TextAlign.Center, - ) - } - } - } - - content() +private fun Title(model: AddCustomTokenConfig, modifier: Modifier = Modifier) { + when (model.step) { + AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR, + AddCustomTokenConfig.Step.FORM, + -> { + TangemBottomSheetTitle( + modifier = modifier, + title = resourceReference(R.string.add_custom_token_title), + ) + } + AddCustomTokenConfig.Step.NETWORK_SELECTOR -> { + TangemTopAppBar( + modifier = modifier, + title = resourceReference(R.string.custom_token_network_selector_title), + titleAlignment = Alignment.CenterHorizontally, + startButton = TopAppBarButtonUM.Back(model.popBack), + height = TangemTopAppBarHeight.BOTTOM_SHEET, + ) + } + AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR -> { + TangemTopAppBar( + modifier = modifier, + title = resourceReference(R.string.custom_token_derivation_path), + titleAlignment = Alignment.CenterHorizontally, + startButton = TopAppBarButtonUM.Back(model.popBack), + height = TangemTopAppBarHeight.BOTTOM_SHEET, + ) } } } @@ -153,7 +87,7 @@ private fun Preview_AddCustomTokenBottomSheet( @PreviewParameter(AddCustomTokenComponentPreviewProvider::class) component: AddCustomTokenComponent, ) { TangemThemePreview { - component.BottomSheet(isVisible = true, onDismiss = {}) + component.BottomSheet() } } @@ -162,24 +96,40 @@ private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider< get() = sequenceOf( PreviewAddCustomTokenComponent(), PreviewAddCustomTokenComponent( - initialState = AddCustomTokenUM.NetworkSelector( + initialState = AddCustomTokenConfig( + userWalletId = UserWalletId(stringValue = "321"), + step = AddCustomTokenConfig.Step.FORM, popBack = {}, - selectedNetwork = SelectedNetworkUM( - id = Network.ID(value = "0"), - name = "Ethereum", + selectedNetwork = SelectedNetwork( + id = Network.ID(value = "1"), + name = stringReference("Ethereum"), + derivationPath = Network.DerivationPath.None, + canHandleTokens = false, ), ), ), PreviewAddCustomTokenComponent( - initialState = AddCustomTokenUM.Form( + initialState = AddCustomTokenConfig( + userWalletId = UserWalletId(stringValue = "321"), + step = AddCustomTokenConfig.Step.NETWORK_SELECTOR, popBack = {}, - selectedNetwork = SelectedNetworkUM( - id = Network.ID(value = "1"), - name = "Ethereum", + selectedNetwork = SelectedNetwork( + id = Network.ID(value = "0"), + name = stringReference("Ethereum"), + derivationPath = Network.DerivationPath.None, + canHandleTokens = false, ), - addTokenButton = AddCustomTokenButtonUM.Visible( - isEnabled = false, - onClick = {}, + ), + ), + PreviewAddCustomTokenComponent( + initialState = AddCustomTokenConfig( + userWalletId = UserWalletId(stringValue = "321"), + step = AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR, + popBack = {}, + selectedDerivationPath = SelectedDerivationPath( + id = Network.ID(value = "0"), + value = Network.DerivationPath.None, + networkName = stringReference("Ethereum"), ), ), ), diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt index e898e44150..3565f4c326 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt @@ -2,97 +2,153 @@ package com.tangem.features.managetokens.ui import android.content.res.Configuration import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType 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.fastForEach +import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.block.information.InformationBlock import com.tangem.core.ui.components.fields.SimpleTextField +import com.tangem.core.ui.components.isOpened +import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.managetokens.component.preview.PreviewCustomTokenFormComponent -import com.tangem.features.managetokens.entity.ClickableFieldUM -import com.tangem.features.managetokens.entity.CustomTokenFormUM -import com.tangem.features.managetokens.entity.TextInputFieldUM +import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM +import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM +import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM +import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.ui.component.AddCustomTokenDescription -internal fun LazyListScope.customTokenFormContent(model: CustomTokenFormUM) { - item { - ClickableField( - modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), - model = model.networkName, - ) - } +@Composable +internal fun CustomTokenFormContent(model: CustomTokenFormUM, modifier: Modifier = Modifier) { + val keyboard by keyboardAsState() + val bottomBarHeight by animateDpAsState( + label = "Bottom bar height", + targetValue = if (keyboard.isOpened) { + TangemTheme.dimens.spacing0 + } else { + with(LocalDensity.current) { + WindowInsets.systemBars.getBottom(density = this).toDp() + } + }, + ) + + Box( + modifier = modifier + .imePadding() + .fillMaxSize() + .background(color = TangemTheme.colors.background.secondary), + ) { + val scrollState = rememberScrollState() - item { Column( modifier = Modifier - .padding(bottom = TangemTheme.dimens.spacing12) - .background( - color = TangemTheme.colors.background.action, - shape = TangemTheme.shapes.roundedCornersXMedium, - ), + .verticalScroll(scrollState) + .fillMaxSize() + .padding(bottom = TangemTheme.dimens.spacing76), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), ) { - TextField( - model = model.contractAddress, - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Next, - ), - ) - TextField( - model = model.tokenName, - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Next, - ), - ) - TextField( - model = model.tokenSymbol, - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Next, - ), - ) - TextField( - model = model.tokenDecimals, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Decimal, - imeAction = ImeAction.Next, - ), + AddCustomTokenDescription() + FormContent(model) + } + + PrimaryButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = TangemTheme.dimens.spacing16 + bottomBarHeight) + .fillMaxWidth(), + text = stringResource(id = R.string.custom_token_add_token), + enabled = model.canAddToken, + showProgress = model.isValidating, + onClick = model.saveToken, + ) + } +} + +@Composable +private fun FormContent(model: CustomTokenFormUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + ClickableField( + model = model.networkName, + ) + + val tokenForm = model.tokenForm + if (tokenForm != null) { + TokenForm(tokenForm) + } + + ClickableField( + model = model.derivationPath, + ) + + model.notifications.fastForEach { notification -> + Notification( + config = notification.config, + containerColor = TangemTheme.colors.button.disabled, ) } } +} - item { - ClickableField( - modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), - model = model.derivationPath, +@Composable +private fun TokenForm(tokenForm: CustomTokenFormUM.TokenFormUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) { + TextField( + model = tokenForm.contractAddress, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + ), ) - } - - items( - items = model.notifications, - key = { it.id }, - ) { notification -> - Notification( - modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), - config = notification.config, - containerColor = TangemTheme.colors.button.disabled, + TextField( + model = tokenForm.name, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + ), + ) + TextField( + model = tokenForm.symbol, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + ), + ) + TextField( + model = tokenForm.decimals, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Next, + ), ) } } @@ -104,14 +160,25 @@ private fun TextField( keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, ) { + var isFocused by remember { mutableStateOf(value = false) } + InformationBlock( modifier = modifier, title = { val color by animateColorAsState( - targetValue = if (model.error != null) { - TangemTheme.colors.text.warning - } else { - TangemTheme.colors.text.tertiary + targetValue = when { + !model.isEnabled -> { + TangemTheme.colors.text.disabled + } + model.error != null -> { + TangemTheme.colors.text.warning + } + model.value.isNotBlank() || isFocused -> { + TangemTheme.colors.text.tertiary + } + else -> { + TangemTheme.colors.text.disabled + } }, label = "Field label color", ) @@ -123,12 +190,27 @@ private fun TextField( ) }, content = { + val color by animateColorAsState( + targetValue = if (model.isEnabled) { + TangemTheme.colors.text.primary1 + } else { + TangemTheme.colors.text.disabled + }, + label = "Field value color", + ) + SimpleTextField( - modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + modifier = Modifier + .padding(bottom = TangemTheme.dimens.spacing12) + .fillMaxWidth() + .onFocusChanged { + isFocused = it.isFocused + }, value = model.value, + color = color, onValueChange = model.onValueChange, - readOnly = false, placeholder = model.placeholder, + readOnly = !model.isEnabled && !isFocused, singleLine = true, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, @@ -170,9 +252,7 @@ private fun Preview_CustomTokenFormContent( component: PreviewCustomTokenFormComponent, ) { TangemThemePreview { - LazyColumn( - modifier = Modifier.background(color = TangemTheme.colors.background.secondary), - ) { component.content(scope = this) } + component.Content(modifier = Modifier) } } @@ -181,16 +261,30 @@ private class PreviewCustomTokenFormComponentProvider : override val values: Sequence get() = sequenceOf( - PreviewCustomTokenFormComponent(), PreviewCustomTokenFormComponent( - contractAddress = TextInputFieldUM( - label = stringReference("Contract address"), - value = "0x1234567890", - error = stringReference("Contract address is invalid"), - placeholder = stringReference("0x1234567890"), - onValueChange = {}, + tokenForm = PreviewCustomTokenFormComponent.tokenForm.copy( + contractAddress = TextInputFieldUM( + label = stringReference("Contract address"), + value = "0x1234567890", + placeholder = stringReference("0x1234567890"), + onValueChange = {}, + ), ), ), + PreviewCustomTokenFormComponent( + tokenForm = PreviewCustomTokenFormComponent.tokenForm.copy( + contractAddress = TextInputFieldUM( + label = stringReference("Contract address"), + value = "0x1234567890", + error = stringReference("Contract address is invalid"), + placeholder = stringReference("0x1234567890"), + onValueChange = {}, + ), + ), + ), + PreviewCustomTokenFormComponent( + tokenForm = null, + ), ) } // endregion Preview \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenNetworkSelectorContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenNetworkSelectorContent.kt deleted file mode 100644 index 57abdb15b5..0000000000 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenNetworkSelectorContent.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.tangem.features.managetokens.ui - -import android.content.res.Configuration -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.rows.ChainRow -import com.tangem.core.ui.components.rows.model.ChainRowUM -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.domain.tokens.model.Network -import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent -import com.tangem.features.managetokens.component.preview.PreviewCustomTokenNetworkSelectorComponent -import com.tangem.features.managetokens.entity.CurrencyNetworkUM -import com.tangem.features.managetokens.entity.CustomTokenNetworkSelectorUM -import com.tangem.features.managetokens.entity.SelectedNetworkUM -import com.tangem.features.managetokens.impl.R - -internal fun LazyListScope.customTokenNetworkSelectorContent(model: CustomTokenNetworkSelectorUM) { - val lastIndex = model.networks.lastIndex - - if (model.showTitle) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = TangemTheme.dimens.size36) - .background( - color = TangemTheme.colors.background.primary, - shape = TangemTheme.shapes.bottomSheet, - ), - ) { - Text( - modifier = Modifier - .padding( - top = TangemTheme.dimens.spacing12, - bottom = TangemTheme.dimens.spacing6, - ) - .padding(horizontal = TangemTheme.dimens.spacing12), - text = stringResource(R.string.add_custom_token_choose_network), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) - } - } - } - - itemsIndexed( - items = model.networks, - key = { _, item -> item.id.value }, - ) { index, item -> - NetworkItem( - modifier = Modifier - .fillMaxWidth() - .clip( - shape = when { - !model.showTitle && index == 0 -> RoundedCornerShape( - topStart = TangemTheme.dimens.radius16, - topEnd = TangemTheme.dimens.radius16, - ) - index == lastIndex -> RoundedCornerShape( - bottomStart = TangemTheme.dimens.radius16, - bottomEnd = TangemTheme.dimens.radius16, - ) - else -> RectangleShape - }, - ) - .background(color = TangemTheme.colors.background.primary) - .clickable(onClick = { item.onSelectedStateChange(true) }) - .padding(horizontal = TangemTheme.dimens.spacing4), - model = item, - ) - } -} - -@Composable -private fun NetworkItem(model: CurrencyNetworkUM, modifier: Modifier = Modifier) { - ChainRow( - modifier = modifier, - model = with(model) { - ChainRowUM( - name = name, - type = type, - icon = CurrencyIconState.CoinIcon( - url = null, - fallbackResId = model.iconResId, - isGrayscale = false, - showCustomBadge = false, - ), - showCustom = false, - ) - }, - action = { - AnimatedVisibility( - modifier = Modifier.size(TangemTheme.dimens.size24), - visible = model.isSelected, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_check_24), - tint = TangemTheme.colors.icon.accent, - contentDescription = null, - ) - } - }, - ) -} - -// region Preview -@Composable -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun Preview_CustomTokenNetworkSelectorContent( - @PreviewParameter(CustomTokenNetworkSelectorComponentPreviewProvider::class) - component: CustomTokenNetworkSelectorComponent, -) { - TangemThemePreview { - LazyColumn { - component.content(this) - } - } -} - -private class CustomTokenNetworkSelectorComponentPreviewProvider : - PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - PreviewCustomTokenNetworkSelectorComponent(), - PreviewCustomTokenNetworkSelectorComponent( - params = CustomTokenNetworkSelectorComponent.Params( - userWalletId = UserWalletId(stringValue = "321"), - selectedNetwork = SelectedNetworkUM( - id = Network.ID(value = "0"), - name = "", - ), - onNetworkSelected = {}, - ), - ), - ) -} -// endregion Preview \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt new file mode 100644 index 0000000000..476433fb7d --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt @@ -0,0 +1,288 @@ +package com.tangem.features.managetokens.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +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.RectangleShape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.rows.ChainRow +import com.tangem.core.ui.components.rows.model.ChainRowUM +import com.tangem.core.ui.extensions.resolveReference +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.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.component.CustomTokenSelectorComponent +import com.tangem.features.managetokens.component.preview.PreviewCustomTokenSelectorComponent +import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM +import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath +import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork +import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM +import com.tangem.features.managetokens.entity.item.DerivationPathUM +import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.ui.component.AddCustomTokenDescription + +@Composable +internal fun CustomTokenSelectorContent(model: CustomTokenSelectorUM, modifier: Modifier = Modifier) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + val lastIndex = model.items.lastIndex + + LazyColumn( + modifier = modifier.background( + color = TangemTheme.colors.background.secondary, + ), + contentPadding = PaddingValues( + bottom = TangemTheme.dimens.spacing16 + bottomBarHeight, + ), + ) { + item { + Header(model.header) + } + + itemsIndexed( + items = model.items, + key = { _, item -> item.id }, + ) { index, item -> + val itemModifier = Modifier + .fillMaxWidth() + .clip( + shape = when { + model.header !is CustomTokenSelectorUM.HeaderUM.Description && index == 0 -> { + RoundedCornerShape( + topStart = TangemTheme.dimens.radius16, + topEnd = TangemTheme.dimens.radius16, + ) + } + index == lastIndex -> { + RoundedCornerShape( + bottomStart = TangemTheme.dimens.radius16, + bottomEnd = TangemTheme.dimens.radius16, + ) + } + else -> { + RectangleShape + } + }, + ) + .background(color = TangemTheme.colors.background.primary) + .clickable(onClick = { item.onSelectedStateChange(true) }) + .padding(horizontal = TangemTheme.dimens.spacing4) + + when (item) { + is CurrencyNetworkUM -> { + NetworkItem( + modifier = itemModifier, + model = item, + ) + } + is DerivationPathUM -> { + DerivationPathItem( + modifier = itemModifier, + model = item, + ) + } + } + } + } +} + +@Composable +private fun Header(header: CustomTokenSelectorUM.HeaderUM, modifier: Modifier = Modifier) { + when (header) { + is CustomTokenSelectorUM.HeaderUM.CustomDerivationButton -> { + CustomDerivationButton( + modifier = modifier.padding(vertical = TangemTheme.dimens.spacing16), + enteredDerivationPath = header.value, + isSelected = header.value != null, + onClick = header.onClick, + ) + } + is CustomTokenSelectorUM.HeaderUM.Description -> { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AddCustomTokenDescription() + Box( + modifier = modifier + .fillMaxWidth() + .heightIn(min = TangemTheme.dimens.size36) + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.bottomSheet, + ), + ) { + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing12) + .padding(horizontal = TangemTheme.dimens.spacing12), + text = stringResource(R.string.add_custom_token_choose_network), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } + is CustomTokenSelectorUM.HeaderUM.None -> { + Spacer(modifier = Modifier.size(TangemTheme.dimens.spacing16)) + } + } +} + +@Composable +private fun NetworkItem(model: CurrencyNetworkUM, modifier: Modifier = Modifier) { + ChainRow( + modifier = modifier, + model = with(model) { + ChainRowUM( + name = name, + type = type, + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = model.iconResId, + isGrayscale = false, + showCustomBadge = false, + ), + showCustom = false, + ) + }, + action = { + SelectedIcon(isVisible = model.isSelected) + }, + ) +} + +@Composable +private fun CustomDerivationButton( + enteredDerivationPath: String?, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + InformationBlock( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .clickable(onClick = onClick), + title = { + Column( + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + Text( + text = stringResource(id = R.string.custom_token_custom_derivation), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + ) + Text( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + text = enteredDerivationPath ?: stringResource(id = R.string.custom_token_custom_derivation_title), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + } + }, + action = { + SelectedIcon(isVisible = isSelected) + }, + ) +} + +@Composable +private fun DerivationPathItem(model: DerivationPathUM, modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier, + shape = RectangleShape, + title = { + Column( + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + Text( + text = model.networkName.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + ) + + Text( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + text = model.value, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + } + }, + action = { + SelectedIcon(isVisible = model.isSelected) + }, + ) +} + +@Composable +private fun SelectedIcon(isVisible: Boolean, modifier: Modifier = Modifier) { + AnimatedVisibility( + modifier = modifier.size(TangemTheme.dimens.size24), + visible = isVisible, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_check_24), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_CustomTokenNetworkSelectorContent( + @PreviewParameter(CustomTokenNetworkSelectorComponentPreviewProvider::class) + component: CustomTokenSelectorComponent, +) { + TangemThemePreview { + component.Content(modifier = Modifier) + } +} + +private class CustomTokenNetworkSelectorComponentPreviewProvider : + PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + PreviewCustomTokenSelectorComponent( + params = CustomTokenSelectorComponent.Params.DerivationPathSelector( + userWalletId = UserWalletId(stringValue = "321"), + selectedNetwork = SelectedNetwork( + id = Network.ID(value = "0"), + name = stringReference("Ethereum"), + derivationPath = Network.DerivationPath.Card("m/44'/0'/0'/0/0"), + canHandleTokens = true, + ), + selectedDerivationPath = SelectedDerivationPath( + id = Network.ID(value = "0"), + value = Network.DerivationPath.Card("m/44'/0'/0'/0/0"), + networkName = stringReference(""), + ), + onDerivationPathSelected = {}, + ), + ), + ) +} +// endregion Preview \ No newline at end of file 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 9f9584fac9..6c5489f29b 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 @@ -1,15 +1,18 @@ package com.tangem.features.managetokens.ui import android.content.res.Configuration -import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FabPosition import androidx.compose.material3.Icon @@ -20,49 +23,80 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource 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.fastForEachIndexed -import com.tangem.core.ui.components.BottomFade -import com.tangem.core.ui.components.PrimaryButtonIconEnd -import com.tangem.core.ui.components.TangemSwitch +import com.tangem.core.ui.components.* import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.components.rows.ArrowRow import com.tangem.core.ui.components.rows.BlockchainRow import com.tangem.core.ui.components.rows.ChainRow +import com.tangem.core.ui.components.rows.ChainRowContainer import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.components.rows.model.ChainRowUM +import com.tangem.core.ui.components.snackbar.TangemSnackbarHost +import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.LocalSnackbarHostState import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.WindowInsetsZero +import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.preview.PreviewManageTokensComponent -import com.tangem.features.managetokens.entity.CurrencyItemUM -import com.tangem.features.managetokens.entity.CurrencyItemUM.Basic.NetworksUM -import com.tangem.features.managetokens.entity.ManageTokensTopBarUM -import com.tangem.features.managetokens.entity.ManageTokensUM +import com.tangem.features.managetokens.entity.item.CurrencyItemUM +import com.tangem.features.managetokens.entity.item.CurrencyItemUM.Basic.NetworksUM +import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM +import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM import com.tangem.features.managetokens.impl.R import kotlinx.collections.immutable.ImmutableList private const val CHEVRON_ROTATION_EXPANDED = 180f private const val CHEVRON_ROTATION_COLLAPSED = 0f +private const val LOAD_ITEMS_BUFFER = 10 @Composable internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modifier) { - BackHandler(onBack = state.popBack) + val keyboardController = LocalSoftwareKeyboardController.current + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + keyboardController?.hide() + + return super.onPreScroll(available, source) + } + } + } Scaffold( - modifier = modifier, + modifier = modifier.nestedScroll(nestedScrollConnection), containerColor = TangemTheme.colors.background.primary, + contentWindowInsets = WindowInsetsZero, topBar = { ManageTokensTopBar( modifier = Modifier.statusBarsPadding(), topBar = state.topBar, + search = state.search, ) }, content = { innerPadding -> @@ -70,10 +104,13 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi modifier = Modifier .padding(innerPadding) .fillMaxSize(), - search = state.search, - items = state.items, - isLoading = state.isLoading, - hasChanges = state is ManageTokensUM.ManageContent && state.hasChanges, + state = state, + ) + }, + snackbarHost = { + TangemSnackbarHost( + modifier = Modifier.padding(all = TangemTheme.dimens.spacing16), + hostState = LocalSnackbarHostState.current, ) }, floatingActionButtonPosition = FabPosition.Center, @@ -81,10 +118,12 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi if (state is ManageTokensUM.ManageContent) { SaveChangesButton( modifier = Modifier + .navigationBarsPadding() .padding(horizontal = TangemTheme.dimens.spacing16) .fillMaxWidth(), isVisible = state.hasChanges, - onClick = state.onSaveClick, + showProgress = state.isSavingInProgress, + onClick = state.saveChanges, ) } }, @@ -92,20 +131,35 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi } @Composable -private fun ManageTokensTopBar(topBar: ManageTokensTopBarUM, modifier: Modifier = Modifier) { - TangemTopAppBar( - modifier = modifier, - title = topBar.title.resolveReference(), - startButton = TopAppBarButtonUM.Back(topBar.onBackButtonClick), - endButton = when (topBar) { - is ManageTokensTopBarUM.ManageContent -> topBar.endButton - is ManageTokensTopBarUM.ReadContent -> null - }, - ) +private fun ManageTokensTopBar(topBar: ManageTokensTopBarUM, search: SearchBarUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.background(TangemTheme.colors.background.primary), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + TangemTopAppBar( + title = topBar.title.resolveReference(), + startButton = TopAppBarButtonUM.Back(topBar.onBackButtonClick), + endButton = when (topBar) { + is ManageTokensTopBarUM.ManageContent -> topBar.endButton + is ManageTokensTopBarUM.ReadContent -> null + }, + ) + SearchBar( + modifier = Modifier + .padding(bottom = TangemTheme.dimens.spacing12) + .padding(horizontal = TangemTheme.dimens.spacing16), + state = search, + ) + } } @Composable -private fun SaveChangesButton(isVisible: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { +private fun SaveChangesButton( + isVisible: Boolean, + showProgress: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { AnimatedVisibility( modifier = modifier, visible = isVisible, @@ -116,86 +170,64 @@ private fun SaveChangesButton(isVisible: Boolean, onClick: () -> Unit, modifier: PrimaryButtonIconEnd( text = stringResource(id = R.string.common_save), iconResId = R.drawable.ic_tangem_24, + showProgress = showProgress, onClick = onClick, ) } } @Composable -private fun LoadingContent() { - Box( - modifier = Modifier - .fillMaxSize() - .background(color = TangemTheme.colors.background.primary), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = TangemTheme.colors.icon.accent) - } -} +private fun Content(state: ManageTokensUM, modifier: Modifier = Modifier) { + val listState = rememberLazyListState() -@Composable -private fun Content( - search: SearchBarUM, - items: ImmutableList, - isLoading: Boolean, - hasChanges: Boolean, - modifier: Modifier = Modifier, -) { Box(modifier = modifier) { Currencies( modifier = Modifier.fillMaxSize(), - items = items, - search = search, + listState = listState, + items = state.items, + showLoadingItem = state.isNextBatchLoading, + onLoadMore = state.loadMore, + isEditable = state is ManageTokensUM.ManageContent, ) - AnimatedVisibility( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth(), - visible = hasChanges, - label = "bottom_fade_visibility", - ) { - BottomFade() - } + BottomFade(modifier = Modifier.align(Alignment.BottomCenter)) } - Crossfade(targetState = isLoading, label = "ManageTokensLoadingContent") { - if (it) { - LoadingContent() - } + EventEffect(event = state.scrollToTop) { + listState.animateScrollToItem(index = 0) } } -@OptIn(ExperimentalFoundationApi::class) @Composable -private fun Currencies(items: ImmutableList, search: SearchBarUM, modifier: Modifier = Modifier) { +private fun Currencies( + listState: LazyListState, + items: ImmutableList, + showLoadingItem: Boolean, + isEditable: Boolean, + onLoadMore: () -> Boolean, + modifier: Modifier = Modifier, +) { + val bottomBarHeight = with(LocalDensity.current) { + WindowInsets.systemBars.getBottom(density = this).toDp() + } + LazyColumn( modifier = modifier, + state = listState, + contentPadding = PaddingValues( + bottom = TangemTheme.dimens.spacing76 + bottomBarHeight, + ), ) { - stickyHeader(key = "search") { - Column( - modifier = Modifier - .background(TangemTheme.colors.background.primary) - .padding( - top = TangemTheme.dimens.spacing16, - bottom = TangemTheme.dimens.spacing12, - ) - .padding(horizontal = TangemTheme.dimens.spacing16) - .fillMaxWidth(), - ) { - SearchBar(state = search) - } - } - items( items = items, - key = CurrencyItemUM::id, + key = { it.id.value }, ) { item -> when (item) { is CurrencyItemUM.Basic -> { BasicCurrencyItem( modifier = Modifier.fillMaxWidth(), item = item, + isEditable = isEditable, ) } is CurrencyItemUM.Custom -> { @@ -204,16 +236,78 @@ private fun Currencies(items: ImmutableList, search: SearchBarUM item = item, ) } + is CurrencyItemUM.Loading -> { + LoadingItem( + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + if (showLoadingItem) { + item(key = "loading_item") { + ProgressIndicator( + modifier = Modifier + .padding(vertical = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) } } } + + InfiniteListHandler( + listState = listState, + buffer = LOAD_ITEMS_BUFFER, + onLoadMore = onLoadMore, + ) +} + +@Composable +private fun ProgressIndicator(modifier: Modifier = Modifier) { + Box( + modifier = modifier.background(color = TangemTheme.colors.background.primary), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = TangemTheme.colors.icon.informative) + } +} + +@Composable +private fun LoadingItem(modifier: Modifier = Modifier) { + ChainRowContainer( + modifier = modifier, + icon = { + CurrencyIcon(CurrencyIconState.Loading) + }, + text = { + TextShimmer( + modifier = Modifier.width(70.dp), + style = TangemTheme.typography.subtitle2, + ) + }, + action = { + RectangleShimmer( + modifier = Modifier.size( + width = 24.dp, + height = 16.dp, + ), + ) + }, + ) } @Composable private fun CustomCurrencyItem(item: CurrencyItemUM.Custom, modifier: Modifier = Modifier) { ChainRow( modifier = modifier, - model = item.model, + model = with(item) { + ChainRowUM( + name = name, + type = symbol, + icon = icon, + showCustom = true, + ) + }, action = { SecondarySmallButton( config = SmallButtonConfig( @@ -226,13 +320,20 @@ private fun CustomCurrencyItem(item: CurrencyItemUM.Custom, modifier: Modifier = } @Composable -private fun BasicCurrencyItem(item: CurrencyItemUM.Basic, modifier: Modifier = Modifier) { +private fun BasicCurrencyItem(item: CurrencyItemUM.Basic, isEditable: Boolean, modifier: Modifier = Modifier) { val isExpanded = item.networks is NetworksUM.Expanded Column(modifier = modifier) { ChainRow( modifier = Modifier.clickable(onClick = item.onExpandClick), - model = item.model, + model = with(item) { + ChainRowUM( + name = name, + type = symbol, + icon = icon, + showCustom = false, + ) + }, action = { val rotation by animateFloatAsState( targetValue = if (isExpanded) { @@ -260,13 +361,22 @@ private fun BasicCurrencyItem(item: CurrencyItemUM.Basic, modifier: Modifier = M end = TangemTheme.dimens.spacing8, ), networks = item.networks, - currencyId = item.id, + currencyId = item.id.value, + isEditable = isEditable, ) } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun NetworksList(networks: NetworksUM, currencyId: String, modifier: Modifier = Modifier) { +private fun NetworksList( + networks: NetworksUM, + currencyId: String, + isEditable: Boolean, + modifier: Modifier = Modifier, +) { + val hapticManager = LocalHapticManager.current + AnimatedVisibility( modifier = modifier, visible = networks is NetworksUM.Expanded, @@ -286,8 +396,17 @@ private fun NetworksList(networks: NetworksUM, currencyId: String, modifier: Mod isLastItem = index == currentItems.lastIndex, content = { BlockchainRow( + modifier = Modifier + .padding(end = TangemTheme.dimens.spacing8) + .combinedClickable( + onLongClick = network.onLongClick, + onClick = {}, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ), model = with(network) { BlockchainRowUM( + id = id, name = name, type = type, iconResId = iconResId, @@ -296,10 +415,19 @@ private fun NetworksList(networks: NetworksUM, currencyId: String, modifier: Mod ) }, action = { - TangemSwitch( - checked = network.isSelected, - onCheckedChange = network.onSelectedStateChange, - ) + if (isEditable) { + TangemSwitch( + checked = network.isSelected, + onCheckedChange = { checked -> + if (checked) { + hapticManager.perform(TangemHapticEffect.View.ToggleOn) + } else { + hapticManager.perform(TangemHapticEffect.View.ToggleOff) + } + network.onSelectedStateChange(checked) + }, + ) + } }, ) }, @@ -313,9 +441,19 @@ private fun NetworksList(networks: NetworksUM, currencyId: String, modifier: Mod @Preview(showBackground = true, widthDp = 360, heightDp = 800) @Preview(showBackground = true, widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun Preview_ManageTokens() { +private fun Preview_ManageTokens( + @PreviewParameter(PreviewManageTokensComponentProvider::class) component: ManageTokensComponent, +) { TangemThemePreview { - PreviewManageTokensComponent().Content(Modifier.fillMaxWidth()) + component.Content(Modifier.fillMaxWidth()) } } + +private class PreviewManageTokensComponentProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + PreviewManageTokensComponent(), + PreviewManageTokensComponent(isLoading = true), + ) +} // endregion Preview \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/component/AddCustomTokenDescription.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/component/AddCustomTokenDescription.kt new file mode 100644 index 0000000000..3b95cd9e8c --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/component/AddCustomTokenDescription.kt @@ -0,0 +1,21 @@ +package com.tangem.features.managetokens.ui.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.managetokens.impl.R + +@Composable +internal fun AddCustomTokenDescription(modifier: Modifier = Modifier) { + Text( + modifier = modifier.fillMaxWidth(fraction = 0.7f), + text = stringResource(id = R.string.custom_token_subtitle), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/dialog/CurrencyUnsupportedDialog.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/dialog/CurrencyUnsupportedDialog.kt new file mode 100644 index 0000000000..659b007c29 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/dialog/CurrencyUnsupportedDialog.kt @@ -0,0 +1,22 @@ +package com.tangem.features.managetokens.ui.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.tangem.core.ui.components.BasicDialog +import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.features.managetokens.impl.R + +@Composable +internal fun CurrencyUnsupportedDialog(title: TextReference, message: TextReference, onDismiss: () -> Unit) { + BasicDialog( + title = title.resolveReference(), + message = message.resolveReference(), + confirmButton = DialogButtonUM( + title = stringResource(R.string.common_ok), + onClick = onDismiss, + ), + onDismissDialog = onDismiss, + ) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/dialog/CustomDerivationInputDialog.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/dialog/CustomDerivationInputDialog.kt new file mode 100644 index 0000000000..be41894576 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/dialog/CustomDerivationInputDialog.kt @@ -0,0 +1,71 @@ +package com.tangem.features.managetokens.ui.dialog + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.AdditionalTextInputDialogUM +import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.core.ui.components.TextInputDialog +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.managetokens.component.CustomTokenDerivationInputComponent +import com.tangem.features.managetokens.component.preview.PreviewCustomTokenDerivationInputComponent +import com.tangem.features.managetokens.entity.customtoken.CustomDerivationInputUM +import com.tangem.features.managetokens.impl.R + +@Composable +internal fun CustomDerivationInputDialog(model: CustomDerivationInputUM, onDismiss: () -> Unit) { + val value by rememberUpdatedState(newValue = model.value) + + TextInputDialog( + title = stringResource(id = R.string.custom_token_custom_derivation_title), + fieldValue = value, + confirmButton = DialogButtonUM( + title = stringResource(id = R.string.common_ok), + enabled = model.isConfirmEnabled, + onClick = model.onConfirm, + ), + dismissButton = DialogButtonUM( + title = stringResource(id = R.string.common_cancel), + onClick = onDismiss, + ), + onDismissDialog = onDismiss, + onValueChange = model.updateValue, + textFieldParams = AdditionalTextInputDialogUM( + label = model.error?.resolveReference() ?: stringResource(id = R.string.custom_token_derivation_path), + isError = model.error != null, + ), + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_CustomDerivationInputDialog( + @PreviewParameter(ComponentPreviewProvider::class) component: CustomTokenDerivationInputComponent, +) { + TangemThemePreview { + component.Dialog() + } +} + +private class ComponentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + PreviewCustomTokenDerivationInputComponent(), + PreviewCustomTokenDerivationInputComponent( + value = "m/44'/60'/0'/0/0", + ), + PreviewCustomTokenDerivationInputComponent( + value = "m/44'/60'/0'/0/0", + error = "Invalid derivation path", + ), + ) +} +// endregion Preview \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/dialog/HasLinkedTokensWarning.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/dialog/HasLinkedTokensWarning.kt new file mode 100644 index 0000000000..848d654e08 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/dialog/HasLinkedTokensWarning.kt @@ -0,0 +1,30 @@ +package com.tangem.features.managetokens.ui.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.tangem.core.ui.components.BasicDialog +import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.features.managetokens.impl.R + +@Composable +internal fun HasLinkedTokensWarning(currency: ManagedCryptoCurrency, network: Network, onDismiss: () -> Unit) { + BasicDialog( + title = stringResource( + R.string.token_details_unable_hide_alert_title, + currency.name, + ), + message = stringResource( + R.string.token_details_unable_hide_alert_message, + currency.name, + currency.symbol, + network.name, + ), + confirmButton = DialogButtonUM( + title = stringResource(R.string.common_ok), + onClick = onDismiss, + ), + onDismissDialog = onDismiss, + ) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/dialog/HideTokenWarning.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/dialog/HideTokenWarning.kt new file mode 100644 index 0000000000..46b9cc6a39 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/dialog/HideTokenWarning.kt @@ -0,0 +1,29 @@ +package com.tangem.features.managetokens.ui.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.tangem.core.ui.components.BasicDialog +import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.features.managetokens.impl.R + +@Composable +internal fun HideTokenWarning(currency: ManagedCryptoCurrency, onConfirm: () -> Unit, onDismiss: () -> Unit) { + BasicDialog( + title = stringResource( + R.string.token_details_hide_alert_title, + currency.name, + ), + message = stringResource(R.string.token_details_hide_alert_message), + confirmButton = DialogButtonUM( + title = stringResource(R.string.token_details_hide_alert_hide), + warning = true, + onClick = onConfirm, + ), + dismissButton = DialogButtonUM( + title = stringResource(R.string.common_cancel), + onClick = onDismiss, + ), + onDismissDialog = onDismiss, + ) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt new file mode 100644 index 0000000000..c4876c9914 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt @@ -0,0 +1,255 @@ +package com.tangem.features.managetokens.utils + +import arrow.core.getOrElse +import com.tangem.core.decompose.di.ComponentScoped +import com.tangem.domain.managetokens.CheckIsCurrencyNotAddedUseCase +import com.tangem.domain.managetokens.CreateCurrencyUseCase +import com.tangem.domain.managetokens.FindTokenUseCase +import com.tangem.domain.managetokens.ValidateTokenFormUseCase +import com.tangem.domain.managetokens.model.AddCustomTokenForm +import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException +import com.tangem.domain.managetokens.model.exceptoin.FindTokenException +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveInAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@ComponentScoped +internal class CustomCurrencyValidator @Inject constructor( + private val validateTokenFormUseCase: ValidateTokenFormUseCase, + private val createCustomCurrencyUseCase: CreateCurrencyUseCase, + private val findTokenUseCase: FindTokenUseCase, + private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase, +) { + + private val validateFormJobHolder = JobHolder() + private val state: MutableStateFlow = MutableStateFlow( + value = State( + prevValidatedForm = null, + prevFoundOrCreatedCurrency = null, + status = Status.NotStarted, + ), + ) + + suspend fun consumeUpdates(block: suspend (Status) -> Unit) { + state + .map { it.status } + .distinctUntilChanged() + .collectLatest { block(it) } + } + + suspend fun validateForm( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + formValues: AddCustomTokenForm.Raw, + ) = coroutineScope { + updateStatus(Status.Validating) + + val result = validateTokenFormUseCase( + networkId = networkId, + formValues = formValues, + ) + + val validatedForm = result.getOrElse { e -> + updateStatus(Status.FormValidationException(e)) + return@coroutineScope + } + + if (state.value.prevValidatedForm == validatedForm) { + return@coroutineScope + } else { + state.update { state -> + state.copy(prevValidatedForm = validatedForm) + } + } + + launch { + when (validatedForm) { + is AddCustomTokenForm.Validated.All -> { + findOrCreateCurrency(userWalletId, networkId, derivationPath, validatedForm) + } + is AddCustomTokenForm.Validated.ContractAddress -> { + findToken(userWalletId, networkId, derivationPath, validatedForm) + } + } + }.saveInAndJoin(validateFormJobHolder) + } + + suspend fun createCoin(userWalletId: UserWalletId, networkId: Network.ID, derivationPath: Network.DerivationPath) { + createCurrency(userWalletId, networkId, derivationPath, validatedForm = null) + } + + private suspend fun findOrCreateCurrency( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + validatedForm: AddCustomTokenForm.Validated.All, + ) { + val currentState = state.value + if (currentState.prevFoundOrCreatedCurrency is CryptoCurrency.Token && + currentState.prevFoundOrCreatedCurrency.contractAddress == validatedForm.contractAddress + ) { + // No need to search for token again if contract address is not changed + createCurrency(userWalletId, networkId, derivationPath, validatedForm) + return + } + + updateStatus(Status.SearchingToken) + + val foundToken = findTokenUseCase( + userWalletId = userWalletId, + contractAddress = validatedForm.contractAddress, + networkId = networkId, + derivationPath = derivationPath, + ).getOrElse { e -> + when (e) { + is FindTokenException.DataError -> { + Timber.e(e.cause, "Unable to find custom currency") + updateStatus(Status.UnexpectedException(e.cause)) + return + } + is FindTokenException.NotFound -> { + null + } + } + } + + if (foundToken != null) { + updateStateToValidated(userWalletId, foundToken, fillForm = true, isCustom = false) + } else { + createCurrency(userWalletId, networkId, derivationPath, validatedForm) + } + } + + private suspend fun findToken( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + validatedForm: AddCustomTokenForm.Validated.ContractAddress, + ) { + updateStatus(Status.SearchingToken) + + val token = findTokenUseCase( + userWalletId = userWalletId, + contractAddress = validatedForm.contractAddress, + networkId = networkId, + derivationPath = derivationPath, + ).getOrElse { e -> + val newStatus = when (e) { + is FindTokenException.DataError -> { + Timber.e(e.cause, "Unable to find custom currency") + Status.UnexpectedException(e.cause) + } + is FindTokenException.NotFound -> { + Status.TokenNotFound + } + } + updateStatus(newStatus) + + return + } + + updateStateToValidated(userWalletId, token, fillForm = true, isCustom = false) + } + + private suspend fun createCurrency( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + validatedForm: AddCustomTokenForm.Validated.All?, + ) { + val currency = createCustomCurrencyUseCase( + networkId = networkId, + derivationPath = derivationPath, + formValues = validatedForm, + ).getOrElse { e -> + Timber.e(e, "Unable to create custom currency") + updateStatus(Status.UnexpectedException(e)) + return + } + + updateStateToValidated(userWalletId, currency, fillForm = false, isCustom = validatedForm != null) + } + + private suspend fun updateStateToValidated( + userWalletId: UserWalletId, + currency: CryptoCurrency, + fillForm: Boolean, + isCustom: Boolean, + ) { + val currentStatus = state.value.status + if (currentStatus is Status.Validated && currentStatus.currency == currency) return + + val isNotAdded = checkIsCurrencyNotAddedUseCase( + userWalletId = userWalletId, + networkId = currency.network.id, + derivationPath = currency.network.derivationPath, + contractAddress = when (currency) { + is CryptoCurrency.Coin -> null + is CryptoCurrency.Token -> currency.contractAddress + }, + ).getOrElse { e -> + Timber.e(e, "Unable to check if currency is already added") + updateStatus(Status.UnexpectedException(e)) + + return + } + + state.update { state -> + state.copy( + status = Status.Validated( + currency = currency, + fillForm = fillForm, + isAlreadyAdded = !isNotAdded, + isCustom = isCustom, + ), + prevFoundOrCreatedCurrency = currency, + ) + } + } + + private fun updateStatus(status: Status) { + state.update { state -> + state.copy(status = status) + } + } + + data class State( + val prevValidatedForm: AddCustomTokenForm.Validated?, + val prevFoundOrCreatedCurrency: CryptoCurrency?, + val status: Status, + ) + + sealed class Status { + + data object NotStarted : Status() + + data object SearchingToken : Status() + + data object Validating : Status() + + data class Validated( + val currency: CryptoCurrency, + val fillForm: Boolean, + val isAlreadyAdded: Boolean, + val isCustom: Boolean, + ) : Status() + + data class FormValidationException( + val exceptions: List, + ) : Status() + + data object TokenNotFound : Status() + + data class UnexpectedException( + val cause: Throwable, + ) : Status() + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ChangedCurrenciesManager.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ChangedCurrenciesManager.kt new file mode 100644 index 0000000000..32aa63bf2a --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ChangedCurrenciesManager.kt @@ -0,0 +1,59 @@ +package com.tangem.features.managetokens.utils.list + +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.tokens.model.Network +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +internal typealias ChangedCurrencies = Map> + +internal class ChangedCurrenciesManager { + + val currenciesToAdd: MutableStateFlow = MutableStateFlow(emptyMap()) + val currenciesToRemove: MutableStateFlow = MutableStateFlow(emptyMap()) + + fun addCurrency(currency: ManagedCryptoCurrency.Token, network: Network) { + updateChangedItems(currency, network, currenciesToRemove, currenciesToAdd) + } + + fun removeCurrency(currency: ManagedCryptoCurrency.Token, network: Network) { + updateChangedItems(currency, network, currenciesToAdd, currenciesToRemove) + } + + fun containsCurrency(currency: ManagedCryptoCurrency.Token, network: Network): Boolean { + return network in currenciesToAdd.value[currency].orEmpty() || + network in currenciesToRemove.value[currency].orEmpty() + } + + private fun updateChangedItems( + currency: ManagedCryptoCurrency.Token, + network: Network, + removeFromIfPresent: MutableStateFlow, + addToIfNotPresent: MutableStateFlow, + ) { + val present = removeFromIfPresent.value[currency].orEmpty() + + if (network in present) { + removeFromIfPresent.update { items -> + items.toMutableMap().apply { + val ids = present - network + + if (ids.isEmpty()) { + remove(currency) + } else { + set(currency, ids) + } + } + } + } else { + addToIfNotPresent.update { items -> + val alreadyAdded = items[currency] ?: emptySet() + if (network in alreadyAdded) { + return@update items + } + + items + (currency to alreadyAdded + network) + } + } + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt new file mode 100644 index 0000000000..da24501ab1 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt @@ -0,0 +1,257 @@ +package com.tangem.features.managetokens.utils.list + +import arrow.core.getOrElse +import com.tangem.core.decompose.di.ComponentScoped +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.clipboard.ClipboardManager +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.managetokens.CheckCurrencyUnsupportedUseCase +import com.tangem.domain.managetokens.GetManagedTokensUseCase +import com.tangem.domain.managetokens.RemoveCustomManagedCryptoCurrencyUseCase +import com.tangem.domain.managetokens.CheckHasLinkedTokensUseCase +import com.tangem.domain.managetokens.model.* +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.entity.item.CurrencyItemUM +import com.tangem.features.managetokens.impl.R +import com.tangem.pagination.BatchAction +import com.tangem.pagination.BatchListState +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@Suppress("LongParameterList") +@ComponentScoped +internal class ManageTokensListManager @Inject constructor( + private val getManagedTokensUseCase: GetManagedTokensUseCase, + private val checkHasLinkedTokensUseCase: CheckHasLinkedTokensUseCase, + private val removeCustomCurrencyUseCase: RemoveCustomManagedCryptoCurrencyUseCase, + private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, + private val messageSender: UiMessageSender, + private val dispatchers: CoroutineDispatcherProvider, + clipboardManager: ClipboardManager, +) : ManageTokensUiActions { + + private lateinit var scope: CoroutineScope + + private val jobHolder = JobHolder() + private val actionsFlow: MutableSharedFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private val state: MutableStateFlow = MutableStateFlow(ManageTokensListState()) + + private val changedCurrenciesManager = ChangedCurrenciesManager() + private val uiManager = ManageTokensUiManager( + state = state, + messageSender = messageSender, + dispatchers = dispatchers, + actions = this, + scopeProvider = Provider { scope }, + clipboardManager = clipboardManager, + ) + + val currenciesToAdd: StateFlow = changedCurrenciesManager.currenciesToAdd.asStateFlow() + val currenciesToRemove: StateFlow = changedCurrenciesManager.currenciesToRemove.asStateFlow() + + @OptIn(ExperimentalCoroutinesApi::class) + val paginationStatus: Flow> = state + .mapLatest { it.status } + .distinctUntilChanged() + val uiItems: Flow> = uiManager.items + + suspend fun launchPagination(userWalletId: UserWalletId?) = coroutineScope { + scope = this + + val batchFlow = getManagedTokensUseCase( + context = ManageTokensListBatchingContext( + actionsFlow = actionsFlow, + coroutineScope = this, + ), + ) + + batchFlow.state + .onEach { state -> updateState(state, userWalletId) } + .flowOn(dispatchers.default) + .launchIn(scope = this) + .saveIn(jobHolder) + + // Initial load + reload(userWalletId) + } + + suspend fun reload(userWalletId: UserWalletId?) { + state.value = ManageTokensListState() + actionsFlow.emit( + BatchAction.Reload( + requestParams = ManageTokensListConfig(userWalletId, searchText = null), + ), + ) + } + + suspend fun loadMore(userWalletId: UserWalletId?, query: String) { + actionsFlow.emit( + BatchAction.LoadMore( + requestParams = ManageTokensListConfig(userWalletId, query), + ), + ) + } + + suspend fun search(userWalletId: UserWalletId?, query: String) { + state.value = ManageTokensListState() + actionsFlow.emit( + BatchAction.Reload( + requestParams = ManageTokensListConfig( + userWalletId = userWalletId, + searchText = query, + ), + ), + ) + } + + private fun updateState( + batchListState: BatchListState>, + userWalletId: UserWalletId?, + ) { + state.update { state -> + state.copy( + status = batchListState.status, + ) + } + + state.update { state -> + val newBatches = batchListState.data + val currentBatches = state.currencyBatches + + // Distinct until changed + if (newBatches.size == currentBatches.size && + newBatches.map { it.key } == currentBatches.map { it.key } && + newBatches.flatMap { it.data } == currentBatches.flatMap { it.data } + ) { + return + } + + val canEditItems = userWalletId != null + state.copy( + userWalletId = userWalletId, + currencyBatches = newBatches, + uiBatches = uiManager.createOrUpdateUiBatches(newBatches, canEditItems), + canEditItems = canEditItems, + ) + } + } + + override fun addCurrency(batchKey: Int, currency: ManagedCryptoCurrency.Token, network: Network) { + changedCurrenciesManager.addCurrency(currency, network) + + sendSelectCurrencyAction(batchKey, currency.id, network, isSelected = true) + } + + override fun removeCurrency(batchKey: Int, currency: ManagedCryptoCurrency.Token, network: Network) { + changedCurrenciesManager.removeCurrency(currency, network) + + sendSelectCurrencyAction(batchKey, currency.id, network, isSelected = false) + } + + override fun removeCustomCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom) { + scope.launch { + removeCustomCurrencyUseCase.invoke(userWalletId, currency) + .onRight { reload(userWalletId) } + .onLeft { Timber.e(it) } + } + } + + override fun checkNeedToShowRemoveNetworkWarning( + currency: ManagedCryptoCurrency.Token, + network: Network, + ): Boolean = !changedCurrenciesManager.containsCurrency(currency, network) + + private fun sendSelectCurrencyAction( + batchKey: Int, + currencyId: ManagedCryptoCurrency.ID, + network: Network, + isSelected: Boolean, + ) { + val request = ManageTokensUpdateAction.AddCurrency( + currencyId = currencyId, + network = network, + isSelected = isSelected, + ) + val action = BatchAction.UpdateBatches( + keys = setOf(batchKey), + async = true, + updateRequest = request, + ) + + actionsFlow.tryEmit(action) + } + + override suspend fun checkHasLinkedTokens(userWalletId: UserWalletId, network: Network): Boolean { + return checkHasLinkedTokensUseCase( + userWalletId = userWalletId, + network = network, + tempAddedTokens = changedCurrenciesManager.currenciesToAdd.value, + tempRemovedTokens = changedCurrenciesManager.currenciesToRemove.value, + ).getOrElse { + Timber.e( + it, + """ + Failed to check linked tokens + |- User wallet ID: $userWalletId + |- Network ID: ${network.id} + """.trimIndent(), + ) + + val message = SnackbarMessage( + message = it.localizedMessage + ?.let(::stringReference) + ?: resourceReference(R.string.common_error), + ) + messageSender.send(message) + + false + } + } + + override suspend fun checkCurrencyUnsupportedState( + userWalletId: UserWalletId, + sourceNetwork: ManagedCryptoCurrency.SourceNetwork, + ): CurrencyUnsupportedState? { + return checkCurrencyUnsupportedUseCase( + userWalletId = userWalletId, + sourceNetwork = sourceNetwork, + ).getOrElse { + Timber.e( + it, + """ + Failed to check currency unsupported state + |- User wallet ID: $userWalletId + |- Source Network: $sourceNetwork + """.trimIndent(), + ) + + val message = SnackbarMessage( + message = it.localizedMessage + ?.let(::stringReference) + ?: resourceReference(R.string.common_error), + ) + messageSender.send(message) + + null + } + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListState.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListState.kt new file mode 100644 index 0000000000..a140122502 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListState.kt @@ -0,0 +1,45 @@ +package com.tangem.features.managetokens.utils.list + +import com.tangem.domain.managetokens.model.ManageTokensListConfig +import com.tangem.domain.managetokens.model.ManageTokensUpdateAction +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.entity.item.CurrencyItemUM +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchAction +import com.tangem.pagination.PaginationStatus + +internal typealias ManageTokensBatchAction = BatchAction + +internal data class ManageTokensListState( + val status: PaginationStatus<*> = PaginationStatus.None, + val userWalletId: UserWalletId? = null, + val uiBatches: List>> = mutableListOf(), + val currencyBatches: List>> = mutableListOf(), + val canEditItems: Boolean = true, +) { + + fun batchIndexByCurrencyId(currencyId: ManagedCryptoCurrency.ID): Int { + return currencyBatches + .indexOfFirst { batch -> batch.data.any { it.id == currencyId } } + .takeIf { it != -1 } + ?: error("Batch with currency '$currencyId' not found") + } + + fun updateUiBatchesItem( + indexToBatch: Pair>>, + indexToItem: Pair, + ): ManageTokensListState { + val updatedUiBatch = indexToBatch.second.copy( + data = indexToBatch.second.data.toMutableList().apply { + set(indexToItem.first, indexToItem.second) + }, + ) + + return copy( + uiBatches = uiBatches.toMutableList().apply { + set(indexToBatch.first, updatedUiBatch) + }, + ) + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiActions.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiActions.kt new file mode 100644 index 0000000000..d5e583482e --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiActions.kt @@ -0,0 +1,24 @@ +package com.tangem.features.managetokens.utils.list + +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +internal interface ManageTokensUiActions { + + fun addCurrency(batchKey: Int, currency: ManagedCryptoCurrency.Token, network: Network) + + fun removeCurrency(batchKey: Int, currency: ManagedCryptoCurrency.Token, network: Network) + + fun removeCustomCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom) + + fun checkNeedToShowRemoveNetworkWarning(currency: ManagedCryptoCurrency.Token, network: Network): Boolean + + suspend fun checkHasLinkedTokens(userWalletId: UserWalletId, network: Network): Boolean + + suspend fun checkCurrencyUnsupportedState( + userWalletId: UserWalletId, + sourceNetwork: ManagedCryptoCurrency.SourceNetwork, + ): CurrencyUnsupportedState? +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiManager.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiManager.kt new file mode 100644 index 0000000000..167a7f88a7 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiManager.kt @@ -0,0 +1,257 @@ +package com.tangem.features.managetokens.utils.list + +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.ContentMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.features.managetokens.entity.item.CurrencyItemUM +import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.ui.dialog.CurrencyUnsupportedDialog +import com.tangem.features.managetokens.ui.dialog.HasLinkedTokensWarning +import com.tangem.features.managetokens.ui.dialog.HideTokenWarning +import com.tangem.features.managetokens.utils.mapper.toUiModel +import com.tangem.features.managetokens.utils.ui.toggleExpanded +import com.tangem.features.managetokens.utils.ui.update +import com.tangem.pagination.Batch +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.addOrReplace +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +internal class ManageTokensUiManager( + private val state: MutableStateFlow, + private val messageSender: UiMessageSender, + private val dispatchers: CoroutineDispatcherProvider, + private val scopeProvider: Provider, + private val actions: ManageTokensUiActions, + private val clipboardManager: ClipboardManager, +) { + + private val scope: CoroutineScope + get() = scopeProvider() + + @OptIn(ExperimentalCoroutinesApi::class) + val items: Flow> = state + .mapLatest { state -> + state.uiBatches.asSequence() + .flatMap { it.data } + .toImmutableList() + } + .distinctUntilChanged() + + fun createOrUpdateUiBatches( + newCurrencyBatches: List>>, + canEditItems: Boolean, + ): List>> { + val currentUiBatches = state.value.uiBatches + val batches = currentUiBatches.toMutableList() + + newCurrencyBatches.forEach { (key, data) -> + val indexToUpdate = currentUiBatches.indexOfFirst { it.key == key } + val currencyBatch = state.value.currencyBatches.getOrNull(indexToUpdate) + + if (indexToUpdate == -1 || currencyBatch == null) { + val newBatch = Batch( + key = key, + data = data.map { item -> + item.toUiModel( + isEditable = canEditItems, + onRemoveCustomCurrencyClick = ::removeCustomCurrency, + onExpandNetworksClick = ::toggleCurrencyNetworksVisibility, + ) + }, + ) + + batches.addOrReplace(newBatch) { it.key == key } + } else { + val uiBatchToUpdate = currentUiBatches[indexToUpdate] + + if (uiBatchToUpdate.data == data) { + return@forEach + } + + val updatedBatch = uiBatchToUpdate.copy( + data = data.mapIndexed { index, item -> + if (item == currencyBatch.data[index]) { + return@mapIndexed uiBatchToUpdate.data[index] + } + + val previousUiItem = uiBatchToUpdate.data.getOrNull(index) + if (previousUiItem == null || previousUiItem.id != item.id) { + item.toUiModel( + isEditable = canEditItems, + onRemoveCustomCurrencyClick = ::removeCustomCurrency, + onExpandNetworksClick = ::toggleCurrencyNetworksVisibility, + ) + } else { + previousUiItem.update(item) + } + }, + ) + + batches[indexToUpdate] = updatedBatch + } + } + + return batches + } + + private fun removeCustomCurrency(currency: ManagedCryptoCurrency.Custom) = scope.launch(dispatchers.default) { + showRemoveNetworkWarning( + currency = currency, + network = currency.network, + isCoin = currency is ManagedCryptoCurrency.Custom.Coin, + onConfirm = { + val userWalletId = requireNotNull(state.value.userWalletId) { "UserWalletId is null. Can not remove" } + actions.removeCustomCurrency(userWalletId = userWalletId, currency = currency) + }, + ) + } + + private fun toggleCurrencyNetworksVisibility(currency: ManagedCryptoCurrency.Token) = scope.launch( + dispatchers.default, + ) { + state.update { batches -> + val batchIndex = batches.batchIndexByCurrencyId(currency.id) + val currencyBatch = batches.currencyBatches[batchIndex] + val currencyIndex = currencyBatch.currencyIndexById(currency.id) + + val uiBatch = batches.uiBatches[batchIndex] + val updatedUiItem = uiBatch.data[currencyIndex].toggleExpanded( + currency = currencyBatch.data[currencyIndex], + isEditable = batches.canEditItems, + onSelectCurrencyNetwork = { networkId, isSelected -> + selectNetwork(currencyBatch.key, currency, networkId, isSelected) + }, + onLongTap = ::copyContractAddress, + ) + + batches.updateUiBatchesItem( + indexToBatch = batchIndex to uiBatch, + indexToItem = currencyIndex to updatedUiItem, + ) + } + } + + private fun copyContractAddress(source: ManagedCryptoCurrency.SourceNetwork) { + if (source is ManagedCryptoCurrency.SourceNetwork.Default) { + clipboardManager.setText(text = source.contractAddress) + showSnackbarMessage(resourceReference(R.string.contract_address_copied_message)) + } + } + + private fun showSnackbarMessage(messageText: TextReference) { + val message = SnackbarMessage(message = messageText) + messageSender.send(message) + } + + private fun selectNetwork( + batchKey: Int, + currency: ManagedCryptoCurrency, + source: ManagedCryptoCurrency.SourceNetwork, + isSelected: Boolean, + ) = scope.launch(dispatchers.default) { + if (currency !is ManagedCryptoCurrency.Token) return@launch + + if (isSelected) { + val userWalletId = state.value.userWalletId + val unsupportedState = userWalletId?.let { actions.checkCurrencyUnsupportedState(it, source) } + if (unsupportedState != null) { + showUnsupportedWarning(unsupportedState) + } else { + actions.addCurrency(batchKey, currency, source.network) + } + } else { + if (actions.checkNeedToShowRemoveNetworkWarning(currency, source.network)) { + showRemoveNetworkWarning( + currency = currency, + network = source.network, + isCoin = source is ManagedCryptoCurrency.SourceNetwork.Main, + onConfirm = { + actions.removeCurrency(batchKey, currency, source.network) + }, + ) + } else { + actions.removeCurrency(batchKey, currency, source.network) + } + } + } + + private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) { + val message = ContentMessage { onDismiss -> + CurrencyUnsupportedDialog( + title = resourceReference(R.string.common_warning), + message = when (unsupportedState) { + is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + }, + onDismiss = onDismiss, + ) + } + + messageSender.send(message) + } + + private suspend fun showRemoveNetworkWarning( + currency: ManagedCryptoCurrency, + network: Network, + isCoin: Boolean, + onConfirm: () -> Unit, + ) { + val userWalletId = state.value.userWalletId + val hasLinkedTokens = if (userWalletId == null || !isCoin) { + false + } else { + actions.checkHasLinkedTokens(userWalletId, network) + } + + val message = ContentMessage { onDismiss -> + if (hasLinkedTokens) { + HasLinkedTokensWarning( + currency = currency, + network = network, + onDismiss = onDismiss, + ) + } else { + HideTokenWarning( + currency = currency, + onConfirm = { + onConfirm() + onDismiss() + }, + onDismiss = onDismiss, + ) + } + } + + messageSender.send(message) + } + + private fun Batch>.currencyIndexById(id: ManagedCryptoCurrency.ID): Int { + return data + .indexOfFirst { it.id == id } + .takeIf { it != -1 } + ?: error("Currency with currency '$id' not found in batch #$key") + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyItemMapper.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyItemMapper.kt new file mode 100644 index 0000000000..d37da299a6 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyItemMapper.kt @@ -0,0 +1,77 @@ +package com.tangem.features.managetokens.utils.mapper + +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.getTintForTokenIcon +import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.features.managetokens.entity.item.CurrencyItemUM +import com.tangem.features.managetokens.entity.item.CurrencyItemUM.Basic.NetworksUM +import com.tangem.features.managetokens.utils.ui.getIconRes + +internal fun ManagedCryptoCurrency.toUiModel( + isEditable: Boolean, + onExpandNetworksClick: (ManagedCryptoCurrency.Token) -> Unit, + onRemoveCustomCurrencyClick: (ManagedCryptoCurrency.Custom) -> Unit, +): CurrencyItemUM = when (this) { + is ManagedCryptoCurrency.Custom -> toUiModel(onRemoveCustomCurrencyClick) + is ManagedCryptoCurrency.Token -> toUiModel(isEditable, onExpandNetworksClick) +} + +private fun ManagedCryptoCurrency.Custom.toUiModel( + onRemoveCustomCurrency: (ManagedCryptoCurrency.Custom) -> Unit, +): CurrencyItemUM = CurrencyItemUM.Custom( + id = id, + name = name, + symbol = symbol, + icon = when (this) { + is ManagedCryptoCurrency.Custom.Coin -> { + CurrencyIconState.CoinIcon( + url = iconUrl, + fallbackResId = network.id.getIconRes(isColored = true), + isGrayscale = false, + showCustomBadge = true, + ) + } + is ManagedCryptoCurrency.Custom.Token -> { + val background = tryGetBackgroundForTokenIcon(contractAddress) + + CurrencyIconState.TokenIcon( + url = iconUrl, + fallbackBackground = background, + fallbackTint = getTintForTokenIcon(background), + topBadgeIconResId = network.id.getIconRes(isColored = true), + isGrayscale = false, + showCustomBadge = true, + ) + } + }, + onRemoveClick = { + onRemoveCustomCurrency(this) + }, +) + +private fun ManagedCryptoCurrency.Token.toUiModel( + isEditable: Boolean, + onExpandNetworksClick: (ManagedCryptoCurrency.Token) -> Unit, +): CurrencyItemUM { + val background = TangemColorPalette.Black + + return CurrencyItemUM.Basic( + id = id, + name = name, + symbol = symbol, + icon = CurrencyIconState.TokenIcon( + url = iconUrl, + topBadgeIconResId = null, + isGrayscale = if (isEditable) !isAdded else false, + showCustomBadge = false, + fallbackTint = getTintForTokenIcon(background), + fallbackBackground = background, + ), + networks = NetworksUM.Collapsed, + onExpandClick = { + onExpandNetworksClick(this) + }, + ) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyNetworksMapper.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyNetworksMapper.kt new file mode 100644 index 0000000000..7bb461b84b --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyNetworksMapper.kt @@ -0,0 +1,67 @@ +package com.tangem.features.managetokens.utils.mapper + +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork +import com.tangem.domain.tokens.model.Network +import com.tangem.features.managetokens.entity.item.CurrencyItemUM.Basic.NetworksUM +import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM +import com.tangem.features.managetokens.utils.ui.getIconRes +import kotlinx.collections.immutable.toImmutableList + +internal fun ManagedCryptoCurrency.Token.toUiNetworksModel( + isExpanded: Boolean, + isItemsEditable: Boolean, + onSelectedStateChange: (SourceNetwork, Boolean) -> Unit, + onLongTap: (SourceNetwork) -> Unit, +): NetworksUM { + return if (isExpanded) { + NetworksUM.Expanded( + networks = availableNetworks.map { + it.toCurrencyNetworkModel( + isSelected = it.network in addedIn, + isEditable = isItemsEditable, + onSelectedStateChange = onSelectedStateChange, + onLongTap = onLongTap, + ) + }.toImmutableList(), + ) + } else { + NetworksUM.Collapsed + } +} + +internal fun Network.toCurrencyNetworkModel( + isSelected: Boolean, + onSelectedStateChange: (Boolean) -> Unit, +): CurrencyNetworkUM { + return CurrencyNetworkUM( + network = this, + name = name, + iconResId = id.getIconRes(isColored = true), + isSelected = isSelected, + type = standardType.name, + onLongClick = {}, + isMainNetwork = false, + onSelectedStateChange = onSelectedStateChange, + ) +} + +private fun SourceNetwork.toCurrencyNetworkModel( + isSelected: Boolean, + isEditable: Boolean, + onSelectedStateChange: (SourceNetwork, Boolean) -> Unit, + onLongTap: (SourceNetwork) -> Unit, +): CurrencyNetworkUM { + return CurrencyNetworkUM( + network = network, + name = network.name.uppercase(), + iconResId = id.getIconRes(isColored = isSelected || !isEditable), + isSelected = isSelected || !isEditable, + type = typeName, + onLongClick = { onLongTap(this) }, + isMainNetwork = this is SourceNetwork.Main, + onSelectedStateChange = { selected -> + onSelectedStateChange(this, selected) + }, + ) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/DerivationPathMapper.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/DerivationPathMapper.kt new file mode 100644 index 0000000000..2e843ec716 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/DerivationPathMapper.kt @@ -0,0 +1,34 @@ +package com.tangem.features.managetokens.utils.mapper + +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.tokens.model.Network +import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork +import com.tangem.features.managetokens.entity.item.DerivationPathUM +import com.tangem.features.managetokens.impl.R + +internal fun Network.toDerivationPathModel( + isSelected: Boolean, + onSelectedStateChange: (Boolean) -> Unit, +): DerivationPathUM? { + return DerivationPathUM( + id = id.value, + value = derivationPath.value ?: return null, + networkName = stringReference(name), + isSelected = isSelected, + onSelectedStateChange = onSelectedStateChange, + ) +} + +internal fun SelectedNetwork.toDerivationPathModel( + isSelected: Boolean, + onSelectedStateChange: (Boolean) -> Unit, +): DerivationPathUM? { + return DerivationPathUM( + id = id.value, + value = derivationPath.value ?: return null, + networkName = resourceReference(R.string.custom_token_derivation_path_default), + isSelected = isSelected, + onSelectedStateChange = onSelectedStateChange, + ) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/TokenFormMapper.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/TokenFormMapper.kt new file mode 100644 index 0000000000..fd9a7bd140 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/TokenFormMapper.kt @@ -0,0 +1,13 @@ +package com.tangem.features.managetokens.utils.mapper + +import com.tangem.domain.managetokens.model.AddCustomTokenForm +import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM + +internal fun CustomTokenFormUM.TokenFormUM.mapToDomainModel(): AddCustomTokenForm.Raw { + return AddCustomTokenForm.Raw( + contractAddress = contractAddress.value, + symbol = symbol.value, + name = name.value, + decimals = decimals.value, + ) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CurrencyItemOperations.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CurrencyItemOperations.kt new file mode 100644 index 0000000000..dc27478f94 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CurrencyItemOperations.kt @@ -0,0 +1,72 @@ +package com.tangem.features.managetokens.utils.ui + +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork +import com.tangem.features.managetokens.entity.item.CurrencyItemUM +import com.tangem.features.managetokens.entity.item.CurrencyItemUM.Basic.NetworksUM +import com.tangem.features.managetokens.utils.mapper.toUiNetworksModel +import kotlinx.collections.immutable.toImmutableList + +internal fun CurrencyItemUM.toggleExpanded( + currency: ManagedCryptoCurrency, + isEditable: Boolean, + onSelectCurrencyNetwork: (SourceNetwork, Boolean) -> Unit, + onLongTap: (SourceNetwork) -> Unit, +): CurrencyItemUM { + if (currency !is ManagedCryptoCurrency.Token) return this + + return when (this) { + is CurrencyItemUM.Custom, + is CurrencyItemUM.Loading, + -> this + is CurrencyItemUM.Basic -> { + val isExpanded = networks !is NetworksUM.Expanded + + copy( + icon = icon.copySealed( + isGrayscale = if (isEditable) !currency.isAdded && !isExpanded else false, + ), + networks = currency.toUiNetworksModel( + isExpanded = isExpanded, + isItemsEditable = isEditable, + onSelectedStateChange = onSelectCurrencyNetwork, + onLongTap = onLongTap, + ), + ) + } + } +} + +internal fun CurrencyItemUM.update(currency: ManagedCryptoCurrency): CurrencyItemUM { + return when (this) { + is CurrencyItemUM.Custom, + is CurrencyItemUM.Loading, + -> this + is CurrencyItemUM.Basic -> { + if (currency !is ManagedCryptoCurrency.Token) { + return this + } + + copy( + icon = icon.copySealed( + isGrayscale = networks is NetworksUM.Collapsed && !currency.isAdded, + ), + networks = updateNetworks(currency), + ) + } + } +} + +private fun CurrencyItemUM.Basic.updateNetworks(currency: ManagedCryptoCurrency.Token): NetworksUM = when (networks) { + is NetworksUM.Collapsed -> networks + is NetworksUM.Expanded -> networks.copy( + networks = networks.networks.map { network -> + val isSelected = network.network in currency.addedIn + + network.copy( + iconResId = network.network.id.getIconRes(isSelected), + isSelected = isSelected, + ) + }.toImmutableList(), + ) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CurrencyNetworkOperations.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CurrencyNetworkOperations.kt new file mode 100644 index 0000000000..fbc93fce2c --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CurrencyNetworkOperations.kt @@ -0,0 +1,21 @@ +package com.tangem.features.managetokens.utils.ui + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.extensions.getGreyedOutIconRes +import com.tangem.domain.tokens.model.Network +import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM + +internal fun CurrencyNetworkUM.select(isSelected: Boolean): CurrencyNetworkUM { + return copy( + iconResId = network.id.getIconRes(isSelected), + isSelected = isSelected, + ) +} + +@DrawableRes +internal fun Network.ID.getIconRes(isColored: Boolean): Int = if (isColored) { + getActiveIconRes(value) +} else { + getGreyedOutIconRes(value) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CustomCurrencyFormOperations.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CustomCurrencyFormOperations.kt new file mode 100644 index 0000000000..ae05953156 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CustomCurrencyFormOperations.kt @@ -0,0 +1,157 @@ +package com.tangem.features.managetokens.utils.ui + +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.wrappedList +import com.tangem.domain.managetokens.ValidateTokenFormUseCase +import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM +import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM +import com.tangem.features.managetokens.impl.R +import kotlinx.collections.immutable.mutate +import kotlinx.collections.immutable.persistentListOf + +internal fun CustomTokenFormUM.updateTokenForm( + block: CustomTokenFormUM.TokenFormUM.() -> CustomTokenFormUM.TokenFormUM, +): CustomTokenFormUM { + val form = tokenForm ?: return this + + val updatedForm = form.block() + + return copy(tokenForm = updatedForm) +} + +internal fun TextInputFieldUM.updateValue( + value: String = this.value, + error: TextReference? = this.error, + isEnabled: Boolean = this.isEnabled, + clearError: Boolean = false, +): TextInputFieldUM { + return copy( + value = value, + error = if (clearError) null else error, + isEnabled = isEnabled, + ) +} + +internal fun CustomTokenFormUM.updateWithProgress( + showProgress: Boolean, + isWasFilled: Boolean = this.tokenForm?.wasFilled ?: false, + canAddToken: Boolean = this.canAddToken, + clearNotifications: Boolean = false, + clearFieldErrors: Boolean = false, + disableSecondaryFields: Boolean = false, +): CustomTokenFormUM { + return copy( + isValidating = showProgress, + canAddToken = canAddToken, + notifications = if (clearNotifications) persistentListOf() else notifications, + ).updateTokenForm { + copy( + contractAddress = contractAddress.updateValue( + clearError = clearFieldErrors, + ), + name = name.updateValue( + isEnabled = !showProgress && !disableSecondaryFields, + clearError = clearFieldErrors, + ), + symbol = symbol.updateValue( + isEnabled = !showProgress && !disableSecondaryFields, + clearError = clearFieldErrors, + ), + decimals = decimals.updateValue( + isEnabled = !showProgress && !disableSecondaryFields, + clearError = clearFieldErrors, + ), + wasFilled = isWasFilled, + ) + } +} + +internal fun CustomTokenFormUM.updateWithCurrency(currency: CryptoCurrency): CustomTokenFormUM { + return updateTokenForm { + copy( + contractAddress = contractAddress.updateValue(error = null), + name = name.updateValue(currency.name), + symbol = symbol.updateValue(currency.symbol), + decimals = decimals.updateValue(currency.decimals.toString()), + ) + } +} + +internal fun CustomTokenFormUM.updateWithContractAddressException( + exception: CustomTokenFormValidationException.ContractAddress, +): CustomTokenFormUM { + return updateTokenForm { + copy( + contractAddress = contractAddress.updateValue( + error = when (exception) { + CustomTokenFormValidationException.ContractAddress.Empty -> { + null + } + CustomTokenFormValidationException.ContractAddress.Invalid -> { + resourceReference(R.string.custom_token_creation_error_invalid_contract_address) + } + }, + ), + ) + } +} + +internal fun CustomTokenFormUM.updateWithDecimalsException( + exception: CustomTokenFormValidationException.Decimals, +): CustomTokenFormUM { + return updateTokenForm { + copy( + decimals = decimals.updateValue( + error = when (exception) { + is CustomTokenFormValidationException.Decimals.Empty -> { + null + } + is CustomTokenFormValidationException.Decimals.Invalid -> { + resourceReference( + R.string.custom_token_creation_error_wrong_decimals, + wrappedList(ValidateTokenFormUseCase.MAX_DECIMALS), + ) + } + }, + ), + ) + } +} + +internal fun CustomTokenFormUM.updateWithCurrencyNotFoundNotification(): CustomTokenFormUM { + val notification = CustomTokenFormUM.NotificationUM( + id = "currency_not_found", + config = NotificationConfig( + title = resourceReference(R.string.custom_token_validation_error_not_found_title), + subtitle = resourceReference(R.string.custom_token_validation_error_not_found_description), + iconResId = R.drawable.img_attention_20, + ), + ) + + return copy( + notifications = notifications.mutate { + it.add(notification) + }, + ) +} + +internal fun CustomTokenFormUM.updateWithCurrencyAlreadyAddedNotification(): CustomTokenFormUM { + val notification = CustomTokenFormUM.NotificationUM( + id = "currency_already_added", + config = NotificationConfig( + title = resourceReference(R.string.custom_token_creation_error_token_already_exist_title), + subtitle = resourceReference(R.string.custom_token_creation_error_token_already_exist_message), + iconResId = R.drawable.img_attention_20, + ), + ) + + return copy( + notifications = notifications.mutate { + it.add(notification) + }, + ) +} \ No newline at end of file diff --git a/features/markets/api/build.gradle.kts b/features/markets/api/build.gradle.kts index 0248feb86d..7257245162 100644 --- a/features/markets/api/build.gradle.kts +++ b/features/markets/api/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) id("kotlin-parcelize") id("configuration") } @@ -15,4 +16,10 @@ dependencies { /* Project - Core */ implementation(projects.core.decompose) implementation(projects.core.ui) + + /* Project - Domain */ + implementation(projects.domain.core) + implementation(projects.domain.tokens.models) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.markets.models) } \ No newline at end of file diff --git a/features/markets/api/src/main/kotlin/com/tangem/features/markets/details/MarketsTokenDetailsComponent.kt b/features/markets/api/src/main/kotlin/com/tangem/features/markets/details/MarketsTokenDetailsComponent.kt new file mode 100644 index 0000000000..81ecc42dcc --- /dev/null +++ b/features/markets/api/src/main/kotlin/com/tangem/features/markets/details/MarketsTokenDetailsComponent.kt @@ -0,0 +1,40 @@ +package com.tangem.features.markets.details + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.markets.entry.BottomSheetState +import kotlinx.serialization.Serializable + +@Stable +interface MarketsTokenDetailsComponent : ComposableContentComponent { + + @Serializable + data class Params( + val token: TokenMarketParams, + val appCurrency: AppCurrency, + val showPortfolio: Boolean, + val analyticsParams: AnalyticsParams?, + ) + + @Serializable + data class AnalyticsParams( + val blockchain: String?, + val source: String, + ) + + @Composable + fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/markets/api/src/main/kotlin/com/tangem/features/markets/component/BottomSheetState.kt b/features/markets/api/src/main/kotlin/com/tangem/features/markets/entry/BottomSheetState.kt similarity index 57% rename from features/markets/api/src/main/kotlin/com/tangem/features/markets/component/BottomSheetState.kt rename to features/markets/api/src/main/kotlin/com/tangem/features/markets/entry/BottomSheetState.kt index 5c8cc47920..07aee6c5d9 100644 --- a/features/markets/api/src/main/kotlin/com/tangem/features/markets/component/BottomSheetState.kt +++ b/features/markets/api/src/main/kotlin/com/tangem/features/markets/entry/BottomSheetState.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.component +package com.tangem.features.markets.entry enum class BottomSheetState { EXPANDED, diff --git a/features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsEntryComponent.kt b/features/markets/api/src/main/kotlin/com/tangem/features/markets/entry/MarketsEntryComponent.kt similarity index 92% rename from features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsEntryComponent.kt rename to features/markets/api/src/main/kotlin/com/tangem/features/markets/entry/MarketsEntryComponent.kt index 21ec5c5772..a30498b380 100644 --- a/features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsEntryComponent.kt +++ b/features/markets/api/src/main/kotlin/com/tangem/features/markets/entry/MarketsEntryComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.component +package com.tangem.features.markets.entry import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable diff --git a/features/markets/api/src/main/kotlin/com/tangem/features/markets/token/block/TokenMarketBlockComponent.kt b/features/markets/api/src/main/kotlin/com/tangem/features/markets/token/block/TokenMarketBlockComponent.kt new file mode 100644 index 0000000000..270528b0c7 --- /dev/null +++ b/features/markets/api/src/main/kotlin/com/tangem/features/markets/token/block/TokenMarketBlockComponent.kt @@ -0,0 +1,20 @@ +package com.tangem.features.markets.token.block + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.tokens.model.CryptoCurrency +import kotlinx.serialization.Serializable + +@Stable +interface TokenMarketBlockComponent : ComposableContentComponent { + + @Serializable + data class Params( + val cryptoCurrency: CryptoCurrency, + ) + + interface Factory { + fun create(appComponentContext: AppComponentContext, params: Params): TokenMarketBlockComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/build.gradle.kts b/features/markets/impl/build.gradle.kts index 7d8b7d0667..bd7451e801 100644 --- a/features/markets/impl/build.gradle.kts +++ b/features/markets/impl/build.gradle.kts @@ -17,9 +17,26 @@ dependencies { implementation(projects.core.navigation) /* Domain */ - implementation(projects.domain.markets) 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.manageTokens) + implementation(projects.domain.markets) + implementation(projects.domain.staking.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) + + // FIXME [REDACTED_TASK_KEY] + // Remove the "Buy" and "Sell" actions from the redux middleware. + // Instead, create some kind of interface for such cases. + /* Redux -_- */ + implementation(projects.domain.legacy) + implementation(deps.reKotlin) /* Compose */ implementation(deps.compose.coil) @@ -31,6 +48,7 @@ dependencies { implementation(deps.compose.ui.utils) implementation(deps.lifecycle.compose) implementation(deps.androidx.activity.compose) + implementation(deps.markdown.composeview) /* DI */ implementation(deps.hilt.android) @@ -45,7 +63,15 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.ui) implementation(projects.core.featuretoggles) + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + /* Common */ implementation(projects.common.ui) implementation(projects.common.uiCharts) + implementation(projects.common.routing) + + /* Libs */ + implementation(projects.libs.crypto) + implementation(projects.libs.blockchainSdk) } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt deleted file mode 100644 index a166e55e22..0000000000 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.tangem.features.markets - -import androidx.compose.animation.Animatable -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import com.arkivanov.decompose.ExperimentalDecomposeApi -import com.arkivanov.decompose.extensions.compose.jetpack.stack.Children -import com.arkivanov.decompose.extensions.compose.jetpack.stack.animation.* -import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState -import com.arkivanov.decompose.router.stack.* -import com.arkivanov.decompose.value.Value -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.childByContext -import com.tangem.core.ui.res.LocalMainBottomSheetColor -import com.tangem.core.ui.res.TangemTheme -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.markets.TokenMarket -import com.tangem.features.markets.component.BottomSheetState -import com.tangem.features.markets.component.MarketsEntryComponent -import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent -import com.tangem.features.markets.details.api.toSerializable -import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -@Stable -internal class DefaultMarketsEntryComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - private val marketsEntryChildFactory: MarketsEntryChildFactory, -) : MarketsEntryComponent, AppComponentContext by context { - - private val stackNavigation = StackNavigation() - - val stack: Value> = childStack( - key = "main", - source = stackNavigation, - serializer = MarketsEntryChildFactory.Child.serializer(), - initialConfiguration = MarketsEntryChildFactory.Child.TokenList, - handleBackButton = true, - childFactory = { configuration, componentContext -> - marketsEntryChildFactory.createChild( - child = configuration, - appComponentContext = childByContext(componentContext), - onTokenSelected = ::marketsListTokenSelected, - onDetailsBack = ::onDetailsBack, - ) - }, - ) - - @Suppress("LongMethod") - @Composable - override fun BottomSheetContent( - bottomSheetState: State, - onHeaderSizeChange: (Dp) -> Unit, - modifier: Modifier, - ) { - val primary = TangemTheme.colors.background.primary - val secondary = TangemTheme.colors.background.secondary - val backgroundColor = remember { Animatable(primary) } - val stackState = stack.subscribeAsState() - - LocalMainBottomSheetColor.current.value = backgroundColor.value - - Children( - stack = stackState.value, - animation = stackAnimation(slide()), - ) { - when (it.configuration) { - is MarketsEntryChildFactory.Child.TokenDetails -> { - (it.instance as MarketsTokenDetailsComponent).BottomSheetContent( - bottomSheetState = bottomSheetState, - onHeaderSizeChange = onHeaderSizeChange, - modifier = modifier, - ) - } - MarketsEntryChildFactory.Child.TokenList -> { - (it.instance as MarketsTokenListComponent).BottomSheetContent( - bottomSheetState = bottomSheetState, - onHeaderSizeChange = onHeaderSizeChange, - modifier = modifier, - ) - } - } - } - - // order of LaunchedEffects is important here - - val activeChild = stackState.value.active.configuration - - LaunchedEffect(activeChild) { - when (activeChild) { - is MarketsEntryChildFactory.Child.TokenDetails -> { - backgroundColor.animateTo( - secondary, - animationSpec = tween(durationMillis = 500), - ) - } - MarketsEntryChildFactory.Child.TokenList -> { - backgroundColor.animateTo( - primary, - animationSpec = tween(durationMillis = 500), - ) - } - } - } - - LaunchedEffect(bottomSheetState.value) { - if (activeChild is MarketsEntryChildFactory.Child.TokenDetails) { - when (bottomSheetState.value) { - BottomSheetState.EXPANDED -> { - backgroundColor.animateTo( - secondary, - animationSpec = tween(durationMillis = 100), - ) - } - BottomSheetState.COLLAPSED -> { - backgroundColor.animateTo( - primary, - animationSpec = tween(durationMillis = 100), - ) - } - } - } - } - - LaunchedEffect(primary, secondary) { - if (backgroundColor.isRunning) return@LaunchedEffect - - when (activeChild) { - is MarketsEntryChildFactory.Child.TokenDetails -> { - backgroundColor.snapTo(secondary) - } - MarketsEntryChildFactory.Child.TokenList -> { - backgroundColor.snapTo(primary) - } - } - } - } - - @OptIn(ExperimentalDecomposeApi::class) - private fun marketsListTokenSelected(token: TokenMarket, appCurrency: AppCurrency) { - stackNavigation.pushNew( - configuration = MarketsEntryChildFactory.Child.TokenDetails( - params = MarketsTokenDetailsComponent.Params( - token = token.toSerializable(), - appCurrency = appCurrency, - ), - ), - ) - } - - private fun onDetailsBack() { - stackNavigation.popWhile { it != MarketsEntryChildFactory.Child.TokenList } - } - - @AssistedFactory - interface Factory : MarketsEntryComponent.Factory { - override fun create(context: AppComponentContext): DefaultMarketsEntryComponent - } -} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/MarketsTokenDetailsComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/MarketsTokenDetailsComponent.kt deleted file mode 100644 index a706e5850e..0000000000 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/MarketsTokenDetailsComponent.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.tangem.features.markets.details.api - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.features.markets.component.BottomSheetState -import kotlinx.serialization.Serializable - -@Stable -interface MarketsTokenDetailsComponent { - - @Serializable - data class Params( - val token: TokenMarketSerializable, - val appCurrency: AppCurrency, - ) - - @Composable - fun BottomSheetContent( - bottomSheetState: State, - onHeaderSizeChange: (Dp) -> Unit, - modifier: Modifier, - ) - - interface Factory { - fun create(context: AppComponentContext, params: Params, onBack: () -> Unit): MarketsTokenDetailsComponent - } -} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt index 402356487a..681d36de1b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt @@ -1,28 +1,85 @@ package com.tangem.features.markets.details.impl +import androidx.activity.compose.BackHandler import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.markets.component.BottomSheetState -import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.markets.details.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.MarketsTokenDetailsComponent.Params +import com.tangem.features.markets.details.impl.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel +import com.tangem.features.markets.details.impl.model.state.TokenNetworksState import com.tangem.features.markets.details.impl.ui.MarketsTokenDetailsContent +import com.tangem.features.markets.entry.BottomSheetState +import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch @Stable internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, - @Assisted params: MarketsTokenDetailsComponent.Params, - @Assisted private val onBack: () -> Unit, + @Assisted params: Params, + analyticsEventHandler: AnalyticsEventHandler, + portfolioComponentFactory: MarketsPortfolioComponent.Factory, ) : AppComponentContext by appComponentContext, MarketsTokenDetailsComponent { - private val model: MarketsTokenDetailsModel = getOrCreateModel(params) + // applying l2 compatibility + private val updatedParams = params.copy( + token = params.token.copy( + id = getTokenIdIfL2Network(params.token.id), + ), + ) + private val analyticsParams = params.analyticsParams + + private val model: MarketsTokenDetailsModel = getOrCreateModel(updatedParams) + + private val portfolioComponent: MarketsPortfolioComponent? = if (updatedParams.showPortfolio) { + portfolioComponentFactory.create( + context = child("my_portfolio"), + params = MarketsPortfolioComponent.Params( + updatedParams.token, + analyticsParams = analyticsParams?.source?.let { MarketsPortfolioComponent.AnalyticsParams(it) }, + ), + ) + } else { + null + } + + init { + componentScope.launch { + model.networksState.collectLatest { + when (it) { + is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(it.networks) + TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable() + else -> {} + } + } + } + + // === Analytics === + if (analyticsParams != null) { + analyticsEventHandler.send( + MarketDetailsAnalyticsEvent.EventBuilder( + token = params.token, + ).screenOpened( + blockchain = analyticsParams.blockchain, + source = analyticsParams.source, + ), + ) + } + } @Composable override fun BottomSheetContent( @@ -41,23 +98,66 @@ internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor( val bsState by bottomSheetState LaunchedEffect(bsState) { - model.containerBottomSheetState.value = bsState + model.isVisibleOnScreen.value = bsState == BottomSheetState.EXPANDED + } + + BackHandler(enabled = bsState == BottomSheetState.EXPANDED) { + navigateBack() } MarketsTokenDetailsContent( - state = state, - onBackClick = onBack, - onHeaderSizeChange = onHeaderSizeChange, modifier = modifier, + backgroundColor = LocalMainBottomSheetColor.current.value, + addTopBarStatusBarPadding = false, + state = state, + onBackClick = { + if (bsState == BottomSheetState.EXPANDED) { + navigateBack() + } + }, + onHeaderSizeChange = onHeaderSizeChange, + portfolioBlock = portfolioComponent?.let { component -> + { blockModifier -> + component.Content(blockModifier) + } + }, ) } + @Composable + override fun Content(modifier: Modifier) { + BackHandler { + navigateBack() + } + + LifecycleStartEffect(Unit) { + model.isVisibleOnScreen.value = true + onStopOrDispose { + model.isVisibleOnScreen.value = false + } + } + + val state by model.state.collectAsStateWithLifecycle() + + MarketsTokenDetailsContent( + modifier = modifier, + backgroundColor = TangemTheme.colors.background.tertiary, + addTopBarStatusBarPadding = true, + state = state, + onBackClick = ::navigateBack, + onHeaderSizeChange = {}, + portfolioBlock = portfolioComponent?.let { component -> + { blockModifier -> + component.Content(blockModifier) + } + }, + ) + } + + private fun navigateBack() = router.pop() + @AssistedFactory interface Factory : MarketsTokenDetailsComponent.Factory { - override fun create( - context: AppComponentContext, - params: MarketsTokenDetailsComponent.Params, - onBack: () -> Unit, - ): DefaultMarketsTokenDetailsComponent + override fun create(context: AppComponentContext, params: Params): DefaultMarketsTokenDetailsComponent } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/analytics/MarketDetailsAnalyticsEvent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/analytics/MarketDetailsAnalyticsEvent.kt new file mode 100644 index 0000000000..545104f625 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/analytics/MarketDetailsAnalyticsEvent.kt @@ -0,0 +1,64 @@ +package com.tangem.features.markets.details.impl.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.domain.markets.TokenMarketParams + +internal class MarketDetailsAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) { + + data class EventBuilder( + val token: TokenMarketParams, + ) { + fun screenOpened(blockchain: String?, source: String) = MarketDetailsAnalyticsEvent( + event = "Token Chart Screen Opened", + params = buildMap { + put("Token", token.symbol) + blockchain?.let { put("blockchain", it) } + put("Source", source) + }, + ) + + fun intervalChanged(intervalType: IntervalType, interval: PriceChangeInterval) = MarketDetailsAnalyticsEvent( + event = "Button - Period", + params = mapOf( + "Token" to token.symbol, + "Period" to interval.toAnalyticsString(), + "Source" to intervalType.source, + ), + ) + + fun readMoreClicked() = MarketDetailsAnalyticsEvent( + event = "Button - Read More", + params = mapOf( + "Token" to token.symbol, + ), + ) + + fun linkClicked(linkTitle: String) = MarketDetailsAnalyticsEvent( + event = "Button - Links", + params = mapOf( + "Token" to token.symbol, + "Link" to linkTitle, + ), + ) + } + + enum class IntervalType(val source: String) { + Chart("Chart"), + PricePerformance("Price"), + Insights("Insights"), + } +} + +private fun PriceChangeInterval.toAnalyticsString() = when (this) { + PriceChangeInterval.H24 -> "24h" + PriceChangeInterval.WEEK -> "7d" + PriceChangeInterval.MONTH -> "1m" + PriceChangeInterval.MONTH3 -> "3m" + PriceChangeInterval.MONTH6 -> "6m" + PriceChangeInterval.YEAR -> "1y" + PriceChangeInterval.ALL_TIME -> "All" +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ComponentModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ComponentModule.kt index eb07f7d3b3..8ef6de84d7 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ComponentModule.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ComponentModule.kt @@ -1,6 +1,6 @@ package com.tangem.features.markets.details.impl.di -import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.MarketsTokenDetailsComponent import com.tangem.features.markets.details.impl.DefaultMarketsTokenDetailsComponent import dagger.Binds import dagger.Module diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt index 2a40b0c83e..5f12f70b59 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt @@ -2,7 +2,11 @@ package com.tangem.features.markets.details.impl.model import androidx.compose.runtime.Stable import arrow.core.getOrElse -import com.tangem.common.ui.charts.state.* +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.MarketChartDataProducer +import com.tangem.common.ui.charts.state.sorted +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ComponentScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener @@ -10,24 +14,26 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.event.consumedEvent -import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* -import com.tangem.features.markets.component.BottomSheetState -import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.impl.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.markets.details.impl.model.converters.DescriptionConverter import com.tangem.features.markets.details.impl.model.converters.TokenMarketInfoConverter import com.tangem.features.markets.details.impl.model.formatter.* import com.tangem.features.markets.details.impl.model.formatter.formatAsPrice import com.tangem.features.markets.details.impl.model.formatter.getChangePercentBetween import com.tangem.features.markets.details.impl.model.formatter.getPercentByInterval +import com.tangem.features.markets.details.impl.model.state.QuotesStateUpdater +import com.tangem.features.markets.details.impl.model.state.TokenNetworksState import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM import com.tangem.features.markets.impl.R +import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder @@ -43,18 +49,21 @@ import javax.inject.Inject @Suppress("LargeClass", "LongParameterList") @Stable +@ComponentScoped internal class MarketsTokenDetailsModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, - private val getTokenQuotesUseCase: GetTokenQuotesUseCase, + private val getTokenFullQuotesUseCase: GetTokenFullQuotesUseCase, private val urlOpener: UrlOpener, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private var quotesJob = JobHolder() private val params = paramsContainer.require() + private val analyticsEventBuilder = MarketDetailsAnalyticsEvent.EventBuilder(token = params.token) private val currentAppCurrency = getSelectedAppCurrencyUseCase() .map { maybeAppCurrency -> @@ -70,13 +79,36 @@ internal class MarketsTokenDetailsModel @Inject constructor( onInfoClick = { showInfoBottomSheet(it) }, - onLinkClick = { - urlOpener.openUrl(it.url) + onLinkClick = { link -> + urlOpener.openUrl(link.url) + // === Analytics === + analyticsEventHandler.send(analyticsEventBuilder.linkClicked(linkTitle = link.title)) }, + // === Analytics === + onPricePerformanceIntervalChanged = { + analyticsEventHandler.send( + analyticsEventBuilder.intervalChanged( + intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance, + interval = it, + ), + ) + }, + onInsightsIntervalChanged = { + analyticsEventHandler.send( + analyticsEventBuilder.intervalChanged( + intervalType = MarketDetailsAnalyticsEvent.IntervalType.Insights, + interval = it, + ), + ) + }, + // ================== ) + private val descriptionConverter = DescriptionConverter( onReadModeClicked = { showInfoBottomSheet(it) + // === Analytics === + analyticsEventHandler.send(analyticsEventBuilder.readMoreClicked()) }, ) @@ -93,7 +125,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( BigDecimalFormatter.formatFiatPriceUncapped( fiatAmount = value, fiatCurrencyCode = currentAppCurrency.value.code, - fiatCurrencySymbol = "", + fiatCurrencySymbol = currentAppCurrency.value.symbol, ) }, ) @@ -113,10 +145,11 @@ internal class MarketsTokenDetailsModel @Inject constructor( ), ) - private var lastUpdatedTimestamp: Long = DateTime.now().millis + private val currentTokenInfo = MutableStateFlow(null) + private val lastUpdatedTimestamp = MutableStateFlow(DateTime.now().millis) - val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED) val isVisibleOnScreen = MutableStateFlow(false) + val networksState = MutableStateFlow(TokenNetworksState.Loading) val state = MutableStateFlow( MarketsTokenDetailsUM( @@ -127,19 +160,16 @@ internal class MarketsTokenDetailsModel @Inject constructor( fiatCurrencySymbol = currentAppCurrency.value.symbol, ), dateTimeText = resourceReference(R.string.common_today), - priceChangePercentText = BigDecimalFormatter.formatPercent( - percent = params.token.tokenQuotes.h24Percent, - useAbsoluteValue = true, - ), - priceChangeType = if (params.token.tokenQuotes.h24Percent < BigDecimal.ZERO) { - PriceChangeType.DOWN - } else { - PriceChangeType.UP + priceChangePercentText = params.token.tokenQuotes.h24Percent?.let { + BigDecimalFormatter.formatPercent( + percent = it, + useAbsoluteValue = true, + ) }, + priceChangeType = params.token.tokenQuotes.h24Percent.percentChangeType(), iconUrl = params.token.imageUrl, chartState = MarketsTokenDetailsUM.ChartState( dataProducer = chartDataProducer, - chartLook = MarketChartLook(), onLoadRetryClick = ::onLoadRetryClicked, status = MarketsTokenDetailsUM.ChartState.Status.LOADING, onMarkerPointSelected = ::onMarkerPointSelected, @@ -157,6 +187,22 @@ internal class MarketsTokenDetailsModel @Inject constructor( ), ) + private val quotesStateUpdater = QuotesStateUpdater( + currentAppCurrency = Provider { currentAppCurrency.value }, + state = state, + currentQuotes = currentQuotes, + lastUpdatedTimestamp = lastUpdatedTimestamp, + currentTokenInfo = currentTokenInfo, + onPricePerformanceIntervalChanged = { + analyticsEventHandler.send( + analyticsEventBuilder.intervalChanged( + intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance, + interval = it, + ), + ) + }, + ) + private val loadChartJobHolder = JobHolder() init { @@ -180,7 +226,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( private fun loadQuotes() { modelScope.launch { - val result = getTokenQuotesUseCase( + val result = getTokenFullQuotesUseCase( tokenId = params.token.id, appCurrency = currentAppCurrency.value, ) @@ -209,6 +255,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( appCurrency = currentAppCurrency.value, interval = interval, tokenId = params.token.id, + tokenSymbol = params.token.symbol, + preview = false, ) state.update { @@ -225,9 +273,9 @@ internal class MarketsTokenDetailsModel @Inject constructor( chart.onRight { chartDataProducer.runTransactionSuspend { chartData = MarketChartData.Data( - x = it.timeStamps.map { it.toBigDecimal() }.toImmutableList(), y = it.priceY.toImmutableList(), - ) + x = it.timeStamps.map { it.toBigDecimal() }.toImmutableList(), + ).sorted() updateLook { it.copy( @@ -242,6 +290,11 @@ internal class MarketsTokenDetailsModel @Inject constructor( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.DATA, ), + body = if (it.body is MarketsTokenDetailsUM.Body.Nothing) { + MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked) + } else { + it.body + }, ) } }.onLeft { @@ -272,34 +325,11 @@ internal class MarketsTokenDetailsModel @Inject constructor( val tokenMarketInfo = getTokenMarketInfoUseCase( appCurrency = currentAppCurrency.value, tokenId = params.token.id, + tokenSymbol = params.token.symbol, ) tokenMarketInfo.fold( - ifRight = { result -> - currentQuotes.value = result.quotes - val percent = result.quotes.getPercentByInterval(interval = state.value.selectedInterval) - state.update { - it.copy( - priceText = result.quotes.currentPrice.formatAsPrice(currentAppCurrency.value), - priceChangePercentText = result.quotes.getFormattedPercentByInterval( - interval = it.selectedInterval, - ), - priceChangeType = percent.percentChangeType(), - body = MarketsTokenDetailsUM.Body.Content( - description = descriptionConverter.convert(result), - infoBlocks = infoConverter.convert(result), - ), - ) - } - - chartDataProducer.runTransaction { - updateLook { - it.copy( - type = getChartTypeByPercent(percent), - ) - } - } - }, + ifRight = { result -> updateInfo(result) }, ifLeft = { state.update { if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { @@ -319,48 +349,54 @@ internal class MarketsTokenDetailsModel @Inject constructor( } } - private suspend fun updateQuotes(newQuotes: TokenQuotes) { - val triggerPriceChangeType = getFormattedPriceChange( - currentPrice = currentQuotes.value.currentPrice, - updatedPrice = newQuotes.currentPrice, - ) - val trigger = if (triggerPriceChangeType != PriceChangeType.NEUTRAL) { - triggeredEvent( - data = triggerPriceChangeType, - onConsume = { - state.update { it.copy(triggerPriceChange = consumedEvent()) } - }, + private fun updateInfo(newInfo: TokenMarketInfo) { + lastUpdatedTimestamp.value = DateTime.now().millis + + currentTokenInfo.value = newInfo + currentQuotes.value = newInfo.quotes + + val percent = newInfo.quotes.getPercentByInterval(interval = state.value.selectedInterval) + + state.update { + it.copy( + priceText = newInfo.quotes.currentPrice.formatAsPrice(currentAppCurrency.value), + priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval( + interval = it.selectedInterval, + ), + priceChangeType = percent.percentChangeType(), + body = MarketsTokenDetailsUM.Body.Content( + description = descriptionConverter.convert(newInfo), + infoBlocks = infoConverter.convert(newInfo), + ), ) - } else { - consumedEvent() } - val percent = newQuotes.getPercentByInterval(interval = state.value.selectedInterval) - val priceChangeType = percent.percentChangeType() + val networks = newInfo.networks?.filter { + BlockchainUtils.isSupportedNetworkId(it.networkId) + } - // wait until marker is removed - state.first { it.markerSet.not() } - - currentQuotes.value = newQuotes - lastUpdatedTimestamp = DateTime.now().millis - - state.update { stateToUpdate -> - stateToUpdate.copy( - priceText = newQuotes.currentPrice.formatAsPrice(currentAppCurrency.value), - priceChangePercentText = newQuotes.getFormattedPercentByInterval( - interval = stateToUpdate.selectedInterval, - ), - priceChangeType = priceChangeType, - triggerPriceChange = trigger, - dateTimeText = getDefaultDateTimeString(stateToUpdate.selectedInterval), - ) + networksState.value = if (networks.isNullOrEmpty()) { + TokenNetworksState.NoNetworksAvailable + } else { + TokenNetworksState.NetworksAvailable(networks) } chartDataProducer.runTransaction { updateLook { - it.copy( - type = getChartTypeByPercent(percent), - ) + it.copy(type = percent.percentChangeType().toChartType()) + } + } + } + + private suspend fun updateQuotes(newQuotes: TokenQuotes) { + quotesStateUpdater.updateQuotes(newQuotes) + + val percent = newQuotes + .getPercentByInterval(interval = state.value.selectedInterval) + + chartDataProducer.runTransaction { + updateLook { + it.copy(type = percent.percentChangeType().toChartType()) } } } @@ -368,6 +404,15 @@ internal class MarketsTokenDetailsModel @Inject constructor( private fun onSelectedIntervalChange(interval: PriceChangeInterval) { if (state.value.selectedInterval == interval) return + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.intervalChanged( + intervalType = MarketDetailsAnalyticsEvent.IntervalType.Chart, + interval = interval, + ), + ) + // ================== + val quotes = currentQuotes.value val priceChangePercent = quotes.getFormattedPercentByInterval(interval) @@ -428,7 +473,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( chartDataProducer.runTransaction { updateLook { it.copy( - type = getChartTypeByPercent(percent), + type = percent.percentChangeType().toChartType(), ) } } @@ -475,9 +520,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( launch { while (true) { delay(timeMillis) - // Update quotes only when the container bottom sheet is in the expanded state - containerBottomSheetState.first { it == BottomSheetState.EXPANDED } - // and is visible on the screen + // Update quotes only when content is visible on the screen isVisibleOnScreen.first { it } loadQuotes() @@ -490,7 +533,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( interval = interval, startTimestamp = MarketsDateTimeFormatters.getStartTimestampByInterval( interval = interval, - currentTimestamp = lastUpdatedTimestamp, + currentTimestamp = lastUpdatedTimestamp.value, ), ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt index 40d76daa99..7b16182d58 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt @@ -32,6 +32,7 @@ internal class DescriptionConverter( ), ), body = stringReference(value.fullDescription ?: ""), + showGeneratedAINotification = true, ), ) }, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt index 9bbcff51d9..adb9f89f72 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt @@ -2,9 +2,10 @@ package com.tangem.features.markets.details.impl.model.converters import androidx.compose.runtime.Stable 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.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.TokenMarketInfo import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent import com.tangem.features.markets.details.impl.ui.state.InfoPointUM @@ -14,13 +15,14 @@ import com.tangem.utils.Provider import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal @Stable internal class InsightsConverter( private val appCurrency: Provider, private val onInfoClick: (InfoBottomSheetContent) -> Unit, + private val onIntervalChanged: (PriceChangeInterval) -> Unit, ) : Converter { override fun convert(value: TokenMarketInfo.Insights): InsightsUM { @@ -48,10 +50,14 @@ internal class InsightsConverter( onInfoClick( InfoBottomSheetContent( title = resourceReference(R.string.markets_token_details_insights), - body = stringReference("//TODO"), + body = resourceReference( + R.string.markets_insights_info_description_message, + wrappedList(value.sourceNetworks.joinToString { it.name }), + ), ), ) }, + onIntervalChanged = onIntervalChanged, ) } } @@ -62,74 +68,79 @@ internal class InsightsConverter( liquidityChange: BigDecimal?, buyPressureChange: BigDecimal?, ): ImmutableList { - return persistentListOf( - InfoPointUM( - title = resourceReference(R.string.markets_token_details_experienced_buyers), - value = experiencedBuyerChange.convertChange(), - change = experiencedBuyerChange.changeType(), - onInfoClick = { - onInfoClick( - InfoBottomSheetContent( - title = resourceReference(R.string.markets_token_details_experienced_buyers), - body = resourceReference(R.string.markets_token_details_experienced_buyers_description), - ), - ) - }, - ), - InfoPointUM( - title = resourceReference(R.string.markets_token_details_buy_pressure), - value = buyPressureChange.convertChange(isFiatValue = true), - change = buyPressureChange.changeType(), - onInfoClick = { - onInfoClick( - InfoBottomSheetContent( - title = resourceReference(R.string.markets_token_details_buy_pressure), - body = resourceReference(R.string.markets_token_details_buy_pressure_description), - ), - ) - }, - ), - InfoPointUM( - title = resourceReference(R.string.markets_token_details_holders), - value = holdersChange.convertChange(), - change = holdersChange.changeType(), - onInfoClick = { - onInfoClick( - InfoBottomSheetContent( - title = resourceReference(R.string.markets_token_details_holders), - body = resourceReference(R.string.markets_token_details_holders_description), - ), - ) - }, - ), - InfoPointUM( - title = resourceReference(R.string.markets_token_details_liquidity), - value = liquidityChange.convertChange(), - change = liquidityChange.changeType(), - onInfoClick = { - onInfoClick( - InfoBottomSheetContent( - title = resourceReference(R.string.markets_token_details_liquidity), - body = resourceReference(R.string.markets_token_details_liquidity_description), - ), - ) - }, - ), - ) + return listOfNotNull( + experiencedBuyerChange?.let { + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = experiencedBuyerChange.convertChange(), + change = experiencedBuyerChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + body = resourceReference(R.string.markets_token_details_experienced_buyers_description), + ), + ) + }, + ) + }, + buyPressureChange?.let { + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = buyPressureChange.convertChange(isFiatValue = true), + change = buyPressureChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_buy_pressure), + body = resourceReference(R.string.markets_token_details_buy_pressure_description), + ), + ) + }, + ) + }, + holdersChange?.let { + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = holdersChange.convertChange(), + change = holdersChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_holders), + body = resourceReference(R.string.markets_token_details_holders_description), + ), + ) + }, + ) + }, + liquidityChange?.let { + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = liquidityChange.convertChange(), + change = liquidityChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_liquidity), + body = resourceReference(R.string.markets_token_details_liquidity_description), + ), + ) + }, + ) + }, + ).toImmutableList() } - private fun BigDecimal?.changeType(): InfoPointUM.ChangeType? { + private fun BigDecimal.changeType(): InfoPointUM.ChangeType? { return when { - this == null -> null this > BigDecimal.ZERO -> InfoPointUM.ChangeType.UP this < BigDecimal.ZERO -> InfoPointUM.ChangeType.DOWN else -> null } } - private fun BigDecimal?.convertChange(isFiatValue: Boolean = false): String { - if (this == null) return StringsSigns.DASH_SIGN - + private fun BigDecimal.convertChange(isFiatValue: Boolean = false): String { val value = if (isFiatValue) { val currency = appCurrency() BigDecimalFormatter.formatCompactFiatAmount( @@ -141,11 +152,9 @@ internal class InsightsConverter( BigDecimalFormatter.formatCompactAmount(amount = this.abs()) } - val spacing = if (isFiatValue) " " else "" - return when { - this > BigDecimal.ZERO -> StringsSigns.PLUS + spacing + value - this < BigDecimal.ZERO -> StringsSigns.MINUS + spacing + value + this > BigDecimal.ZERO -> StringsSigns.PLUS + value + this < BigDecimal.ZERO -> StringsSigns.MINUS + value this == BigDecimal.ZERO -> value else -> StringsSigns.DASH_SIGN } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/LinksConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/LinksConverter.kt index 1b571e168f..ca676ee2c5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/LinksConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/LinksConverter.kt @@ -1,9 +1,9 @@ package com.tangem.features.markets.details.impl.model.converters import androidx.compose.runtime.Stable -import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.markets.TokenMarketInfo import com.tangem.features.markets.details.impl.ui.state.LinksUM +import com.tangem.features.markets.details.impl.ui.state.LinksUM.Link import com.tangem.features.markets.impl.R import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList @@ -25,7 +25,7 @@ internal class LinksConverter( private fun TokenMarketInfo.Link.convert(): LinksUM.Link { return LinksUM.Link( - title = stringReference(title), + title = title, iconRes = getIconById(id), url = link, ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt index 4bf9f1e111..5cd11e6cd3 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt @@ -14,9 +14,6 @@ import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal -import java.math.RoundingMode -import java.text.NumberFormat -import java.util.Locale @Stable internal class MetricsConverter( @@ -115,20 +112,16 @@ internal class MetricsConverter( } private fun BigDecimal?.formatAmount(crypto: Boolean = false): String { + if (this == null) return StringsSigns.DASH_SIGN + return if (crypto) { - val formatter = NumberFormat.getNumberInstance(Locale.getDefault()).apply { - maximumFractionDigits = 0 - isGroupingUsed = true - roundingMode = RoundingMode.HALF_UP - } - formatter.format(this) + BigDecimalFormatter.formatCompactAmount(amount = this) } else { val currency = appCurrency() - BigDecimalFormatter.formatFiatAmount( - fiatAmount = this, + BigDecimalFormatter.formatCompactFiatAmount( + amount = this, fiatCurrencyCode = currency.code, fiatCurrencySymbol = currency.symbol, - decimals = 0, ) } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt index 0ccf01cde7..d1800c88c5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt @@ -3,29 +3,31 @@ package com.tangem.features.markets.details.impl.model.converters import androidx.compose.runtime.Stable import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.TokenMarketInfo import com.tangem.features.markets.details.impl.ui.state.PricePerformanceUM import com.tangem.utils.Provider import com.tangem.utils.StringsSigns -import com.tangem.utils.converter.Converter import java.math.BigDecimal import java.math.RoundingMode @Stable internal class PricePerformanceConverter( private val appCurrency: Provider, -) : Converter { + private val onIntervalChanged: (PriceChangeInterval) -> Unit, +) { - override fun convert(value: TokenMarketInfo.PricePerformance): PricePerformanceUM { + fun convert(value: TokenMarketInfo.PricePerformance, currentPrice: BigDecimal): PricePerformanceUM { return PricePerformanceUM( - h24 = value.day.convert(), - month = value.month.convert(), - all = value.allTime.convert(), + h24 = value.day.convert(currentPrice), + month = value.month.convert(currentPrice), + all = value.allTime.convert(currentPrice), + onIntervalChanged = onIntervalChanged, ) } - private fun TokenMarketInfo.Range?.convert(): PricePerformanceUM.Value { - if (this == null) { + private fun TokenMarketInfo.Range?.convert(currentPrice: BigDecimal): PricePerformanceUM.Value { + if (this == null || this.low == null || this.high == null) { return PricePerformanceUM.Value( low = StringsSigns.DASH_SIGN, high = StringsSigns.DASH_SIGN, @@ -36,7 +38,7 @@ internal class PricePerformanceConverter( return PricePerformanceUM.Value( low = low.convert(), high = high.convert(), - indicatorFraction = calculateFraction(), + indicatorFraction = calculateFraction(currentPrice), ) } @@ -50,10 +52,15 @@ internal class PricePerformanceConverter( ) } - private fun TokenMarketInfo.Range.calculateFraction(): Float { - if (low == null || high == null || low == BigDecimal.ZERO) return 0f - return (high!! - low!!).divide(low!!, RoundingMode.HALF_UP) - .setScale(2, RoundingMode.HALF_UP) - .toFloat().coerceAtMost(1f) + private fun TokenMarketInfo.Range.calculateFraction(currentPrice: BigDecimal): Float { + return when { + low == null || high == null || high == BigDecimal.ZERO || currentPrice < low -> 0f + currentPrice > high || low == high -> 1f + else -> { + (currentPrice - low!!).divide(high!! - low!!, RoundingMode.HALF_UP) + .setScale(2, RoundingMode.HALF_UP) + .toFloat().coerceAtMost(1f) + } + } } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt index f03cbff9c4..4f3145408b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt @@ -2,6 +2,7 @@ package com.tangem.features.markets.details.impl.model.converters import androidx.compose.runtime.Stable import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.TokenMarketInfo import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent import com.tangem.features.markets.details.impl.ui.state.LinksUM @@ -14,20 +15,37 @@ internal class TokenMarketInfoConverter( appCurrency: Provider, onInfoClick: (InfoBottomSheetContent) -> Unit, onLinkClick: (LinksUM.Link) -> Unit, + onPricePerformanceIntervalChanged: (PriceChangeInterval) -> Unit, + onInsightsIntervalChanged: (PriceChangeInterval) -> Unit, ) : Converter { - private val insightsConverter = InsightsConverter(appCurrency = appCurrency, onInfoClick = onInfoClick) + private val insightsConverter = InsightsConverter( + appCurrency = appCurrency, + onInfoClick = onInfoClick, + onIntervalChanged = onInsightsIntervalChanged, + ) + + @Suppress("UnusedPrivateMember") + // TODO second markets iteration private val securityScoreConverter = SecurityScoreConverter(onInfoClick = onInfoClick) private val metricsConverter = MetricsConverter(appCurrency = appCurrency, onInfoClick = onInfoClick) - private val pricePerformanceConverter = PricePerformanceConverter(appCurrency = appCurrency) + private val pricePerformanceConverter = PricePerformanceConverter( + appCurrency = appCurrency, + onIntervalChanged = onPricePerformanceIntervalChanged, + ) private val linksConverter = LinksConverter(onLinkClick = onLinkClick) override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.InformationBlocks { return MarketsTokenDetailsUM.InformationBlocks( insights = value.insights?.let { insightsConverter.convert(it) }, - securityScore = securityScoreConverter.convert(Unit), + securityScore = null, metrics = value.metrics?.let { metricsConverter.convert(it) }, - pricePerformance = value.pricePerformance?.let { pricePerformanceConverter.convert(it) }, + pricePerformance = value.pricePerformance?.let { + pricePerformanceConverter.convert( + value = it, + currentPrice = value.quotes.currentPrice, + ) + }, links = value.links?.let { linksConverter.convert(it) }, ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt index f164289fe6..4c185c04e3 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt @@ -48,11 +48,13 @@ internal fun TokenQuotes.getPercentByInterval(interval: PriceChangeInterval): Bi } } +@Suppress("MagicNumber") internal fun BigDecimal?.percentChangeType(): PriceChangeType { + val scaled = this?.setScale(4, RoundingMode.HALF_UP) return when { - this == null -> PriceChangeType.NEUTRAL - this > BigDecimal.ZERO -> PriceChangeType.UP - this < BigDecimal.ZERO -> PriceChangeType.DOWN + scaled == null -> PriceChangeType.NEUTRAL + scaled > BigDecimal.ZERO -> PriceChangeType.UP + scaled < BigDecimal.ZERO -> PriceChangeType.DOWN else -> PriceChangeType.NEUTRAL } } @@ -67,10 +69,8 @@ internal fun getChangePercentBetween(currentPrice: BigDecimal, previousPrice: Bi } internal fun getFormattedPriceChange(currentPrice: BigDecimal, updatedPrice: BigDecimal): PriceChangeType { - val updatedPriceDecimals = BigDecimalFormatter.getProperFiatPriceDecimals(updatedPrice) - - val current = currentPrice.setScale(updatedPriceDecimals, RoundingMode.HALF_UP) - val updated = updatedPrice.setScale(updatedPriceDecimals, RoundingMode.HALF_UP) + val current = BigDecimalFormatter.getFiatPriceUncappedWithScale(value = currentPrice).first + val updated = BigDecimalFormatter.getFiatPriceUncappedWithScale(value = updatedPrice).first return when { updated > current -> PriceChangeType.UP @@ -85,15 +85,4 @@ internal fun PriceChangeType.toChartType(): MarketChartLook.Type { PriceChangeType.DOWN -> MarketChartLook.Type.Falling PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral } -} - -@Suppress("MagicNumber") -internal fun getChartTypeByPercent(percent: BigDecimal?): MarketChartLook.Type { - val scaled = percent?.setScale(4, RoundingMode.HALF_UP) - return when { - scaled == null -> return MarketChartLook.Type.Neutral - scaled > BigDecimal.ZERO -> MarketChartLook.Type.Growing - scaled < BigDecimal.ZERO -> MarketChartLook.Type.Falling - else -> MarketChartLook.Type.Neutral - } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/MarketsDateTimeFormatters.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/MarketsDateTimeFormatters.kt index acc0ec44fa..5abc296917 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/MarketsDateTimeFormatters.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/MarketsDateTimeFormatters.kt @@ -23,7 +23,7 @@ internal object MarketsDateTimeFormatters { private val dateFormatter = DateTimeFormatters.dateDDMMYYYY - internal fun getChartXFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String { + fun getChartXFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String { return when (interval) { PriceChangeInterval.H24 -> { value: BigDecimal -> value.toLong().formatAsDateTime(DateTimeFormatters.timeFormatter) @@ -44,7 +44,7 @@ internal object MarketsDateTimeFormatters { } } - internal fun formatDateByInterval(interval: PriceChangeInterval, startTimestamp: Long): TextReference { + fun formatDateByInterval(interval: PriceChangeInterval, startTimestamp: Long): TextReference { return when (interval) { PriceChangeInterval.H24 -> resourceReference(R.string.common_today) PriceChangeInterval.WEEK, @@ -78,10 +78,7 @@ internal object MarketsDateTimeFormatters { } } - internal fun formatDateByIntervalWithMarker( - interval: PriceChangeInterval, - markerTimestamp: BigDecimal, - ): TextReference { + fun formatDateByIntervalWithMarker(interval: PriceChangeInterval, markerTimestamp: BigDecimal): TextReference { return when (interval) { PriceChangeInterval.H24, PriceChangeInterval.WEEK, @@ -127,4 +124,14 @@ internal object MarketsDateTimeFormatters { PriceChangeInterval.ALL_TIME -> 0 } } + + fun getDefaultDateTimeString(interval: PriceChangeInterval, currentTimestamp: Long): TextReference { + return formatDateByInterval( + interval = interval, + startTimestamp = MarketsDateTimeFormatters.getStartTimestampByInterval( + interval = interval, + currentTimestamp = currentTimestamp, + ), + ) + } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/QuotesStateUpdater.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/QuotesStateUpdater.kt new file mode 100644 index 0000000000..4bf8b58e27 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/QuotesStateUpdater.kt @@ -0,0 +1,88 @@ +package com.tangem.features.markets.details.impl.model.state + +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.event.triggeredEvent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenQuotes +import com.tangem.features.markets.details.impl.model.converters.PricePerformanceConverter +import com.tangem.features.markets.details.impl.model.formatter.* +import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.utils.Provider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import org.joda.time.DateTime +import java.math.BigDecimal + +internal class QuotesStateUpdater( + private val currentAppCurrency: Provider, + private val state: MutableStateFlow, + private val currentQuotes: MutableStateFlow, + private val lastUpdatedTimestamp: MutableStateFlow, + private val currentTokenInfo: MutableStateFlow, + private val onPricePerformanceIntervalChanged: (PriceChangeInterval) -> Unit, +) { + private val pricePerformanceConverter = PricePerformanceConverter( + currentAppCurrency, + onIntervalChanged = onPricePerformanceIntervalChanged, + ) + + suspend fun updateQuotes(newQuotes: TokenQuotes) { + val triggerPriceChangeType = getFormattedPriceChange( + currentPrice = currentQuotes.value.currentPrice, + updatedPrice = newQuotes.currentPrice, + ) + val trigger = if (triggerPriceChangeType != PriceChangeType.NEUTRAL) { + triggeredEvent( + data = triggerPriceChangeType, + onConsume = { + state.update { it.copy(triggerPriceChange = consumedEvent()) } + }, + ) + } else { + consumedEvent() + } + + val percent = newQuotes.getPercentByInterval(interval = state.value.selectedInterval) + val priceChangeType = percent.percentChangeType() + + // wait until marker is removed + state.first { it.markerSet.not() } + + currentQuotes.value = newQuotes + lastUpdatedTimestamp.value = DateTime.now().millis + + state.update { stateToUpdate -> + stateToUpdate.copy( + priceText = newQuotes.currentPrice.formatAsPrice(currentAppCurrency()), + priceChangePercentText = newQuotes.getFormattedPercentByInterval( + interval = stateToUpdate.selectedInterval, + ), + priceChangeType = priceChangeType, + triggerPriceChange = trigger, + dateTimeText = MarketsDateTimeFormatters.getDefaultDateTimeString( + stateToUpdate.selectedInterval, + currentTimestamp = lastUpdatedTimestamp.value, + ), + body = stateToUpdate.body.updatePricePerformance(newQuotes.currentPrice), + ) + } + } + + private fun MarketsTokenDetailsUM.Body.updatePricePerformance(price: BigDecimal): MarketsTokenDetailsUM.Body { + val currentPricePerformance = currentTokenInfo.value?.pricePerformance ?: return this + + return if (this is MarketsTokenDetailsUM.Body.Content) { + copy( + infoBlocks = infoBlocks.copy( + pricePerformance = pricePerformanceConverter.convert(currentPricePerformance, price), + ), + ) + } else { + this + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/TokenNetworksState.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/TokenNetworksState.kt new file mode 100644 index 0000000000..dbe01ddcdc --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/TokenNetworksState.kt @@ -0,0 +1,12 @@ +package com.tangem.features.markets.details.impl.model.state + +import com.tangem.domain.markets.TokenMarketInfo + +internal sealed class TokenNetworksState { + + data object Loading : TokenNetworksState() + + data object NoNetworksAvailable : TokenNetworksState() + + data class NetworksAvailable(val networks: List) : TokenNetworksState() +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt index ecfc715abd..bce07d98c0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt @@ -1,22 +1,23 @@ package com.tangem.features.markets.details.impl.ui +import android.content.res.Configuration import androidx.compose.animation.Animatable import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Text -import androidx.compose.runtime.* +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.drawBehind +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import com.tangem.common.ui.charts.state.MarketChartDataProducer -import com.tangem.common.ui.charts.state.MarketChartLook import com.tangem.core.ui.components.* import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM @@ -33,10 +34,8 @@ 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.stringReference -import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.utils.disableNestedScroll import com.tangem.domain.markets.PriceChangeInterval import com.tangem.features.markets.details.impl.ui.components.InfoBottomSheet import com.tangem.features.markets.details.impl.ui.components.MarketTokenDetailsChart @@ -45,49 +44,59 @@ import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM import com.tangem.features.markets.impl.R import kotlinx.collections.immutable.persistentListOf -@Suppress("UnusedPrivateMember") +@Suppress("LongParameterList") @Composable internal fun MarketsTokenDetailsContent( state: MarketsTokenDetailsUM, + backgroundColor: Color, + addTopBarStatusBarPadding: Boolean, onBackClick: () -> Unit, onHeaderSizeChange: (Dp) -> Unit, + portfolioBlock: @Composable ((Modifier) -> Unit)?, modifier: Modifier = Modifier, ) { Content( modifier = modifier, + backgroundColor = backgroundColor, state = state, onBackClick = onBackClick, onHeaderSizeChange = onHeaderSizeChange, + portfolioBlock = portfolioBlock, + addTopBarStatusBarInsets = addTopBarStatusBarPadding, ) InfoBottomSheet(config = state.infoBottomSheet) } -@Suppress("UnusedPrivateMember") +@Suppress("LongParameterList") @Composable private fun Content( state: MarketsTokenDetailsUM, + backgroundColor: Color, + addTopBarStatusBarInsets: Boolean, onBackClick: () -> Unit, onHeaderSizeChange: (Dp) -> Unit, + portfolioBlock: @Composable ((Modifier) -> Unit)?, modifier: Modifier = Modifier, ) { - val backgroundColor = LocalMainBottomSheetColor.current.value val density = LocalDensity.current val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } Column( modifier = modifier .drawBehind { drawRect(backgroundColor) } + .let { if (addTopBarStatusBarInsets) it.statusBarsPadding() else it } .fillMaxSize(), ) { TangemTopAppBar( - modifier = Modifier.onGloballyPositioned { - if (it.size.height > 0) { - with(density) { - onHeaderSizeChange(it.size.height.toDp()) + modifier = Modifier + .onGloballyPositioned { + if (it.size.height > 0) { + with(density) { + onHeaderSizeChange(it.size.height.toDp()) + } } - } - }, + }, title = state.tokenName, startButton = TopAppBarButtonUM.Back(onBackClick), ) @@ -95,7 +104,6 @@ private fun Content( SpacerH4() LazyColumn( - modifier = Modifier.disableNestedScroll(), contentPadding = PaddingValues(bottom = bottomBarHeight), ) { item("header") { @@ -111,17 +119,17 @@ private fun Content( IntervalSelector( trendInterval = state.selectedInterval, onIntervalClick = state.onSelectedIntervalChange, + isEnabled = state.body !is MarketsTokenDetailsUM.Body.Nothing, modifier = Modifier .padding(horizontal = TangemTheme.dimens.spacing16) .fillMaxWidth(), ) } item { SpacerH32() } - item( - contentType = "chart", - ) { + item("chart") { MarketTokenDetailsChart( modifier = Modifier.fillMaxWidth(), + backgroundColor = backgroundColor, state = state.chartState, ) } @@ -129,6 +137,7 @@ private fun Content( tokenMarketDetailsBody( state = state.body, + portfolioBlock = portfolioBlock, ) } } @@ -140,9 +149,7 @@ private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween, ) { - Column( - Modifier.weight(1f), - ) { + Column(modifier = Modifier.weight(1f)) { TokenPriceText( price = state.priceText, triggerPriceChange = state.triggerPriceChange, @@ -153,11 +160,13 @@ private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, ) - PriceChangeInPercent( - valueInPercent = state.priceChangePercentText, - type = state.priceChangeType, - textStyle = TangemTheme.typography.caption2, - ) + if (state.priceChangePercentText != null) { + PriceChangeInPercent( + valueInPercent = state.priceChangePercentText, + type = state.priceChangeType, + textStyle = TangemTheme.typography.caption2, + ) + } } } SpacerW4() @@ -181,7 +190,7 @@ private fun TokenPriceText( val fallColor = TangemTheme.colors.text.warning val generalColor = TangemTheme.colors.text.primary1 - val color = remember { Animatable(generalColor) } + val color = remember(generalColor) { Animatable(generalColor) } EventEffect(triggerPriceChange) { val nextColor = when (it) { @@ -195,10 +204,11 @@ private fun TokenPriceText( color.animateTo(generalColor, tween(durationMillis = 500)) } - Text( - modifier = modifier, + ResizableText( text = price, + modifier = modifier, color = color.value, + maxLines = 1, style = TangemTheme.typography.head, ) } @@ -206,6 +216,7 @@ private fun TokenPriceText( @Composable private fun IntervalSelector( trendInterval: PriceChangeInterval, + isEnabled: Boolean, onIntervalClick: (PriceChangeInterval) -> Unit, modifier: Modifier = Modifier, ) { @@ -222,6 +233,7 @@ private fun IntervalSelector( color = TangemTheme.colors.button.secondary, initialSelectedItem = trendInterval, onClick = onIntervalClick, + isEnabled = isEnabled, modifier = modifier, ) { Box( @@ -236,7 +248,11 @@ private fun IntervalSelector( modifier = Modifier.align(Alignment.Center), text = it.getText().resolveReference(), style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.primary1, + color = if (isEnabled) { + TangemTheme.colors.text.primary1 + } else { + TangemTheme.colors.text.disabled + }, ) } } @@ -256,11 +272,11 @@ fun PriceChangeInterval.getText(): TextReference { } @Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun Preview() { TangemThemePreview { - Content( - modifier = Modifier.background(TangemTheme.colors.background.tertiary), + MarketsTokenDetailsContent( state = MarketsTokenDetailsUM( tokenName = "Token Name", priceText = "$0.00000000324", @@ -270,7 +286,6 @@ private fun Preview() { priceChangeType = PriceChangeType.UP, chartState = MarketsTokenDetailsUM.ChartState( dataProducer = MarketChartDataProducer.build { }, - chartLook = MarketChartLook(), onLoadRetryClick = {}, status = MarketsTokenDetailsUM.ChartState.Status.LOADING, onMarkerPointSelected = { _, _ -> }, @@ -288,6 +303,9 @@ private fun Preview() { ), onHeaderSizeChange = {}, onBackClick = {}, + backgroundColor = TangemTheme.colors.background.tertiary, + portfolioBlock = {}, + addTopBarStatusBarPadding = false, ) } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/Description.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/Description.kt index e895b421f5..aca7dd32ae 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/Description.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/Description.kt @@ -19,6 +19,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.PreviewShimmerContainer import com.tangem.features.markets.impl.R +import com.tangem.utils.StringsSigns @Composable internal fun Description( @@ -33,7 +34,7 @@ internal fun Description( append(description.resolveReference()) } withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) { - append(" " + stringResource(R.string.common_read_more)) + append(" " + stringResource(R.string.common_read_more).replace(' ', StringsSigns.NON_BREAKING_SPACE)) } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt index 0d44a41f91..f0d31afa7b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt @@ -1,12 +1,8 @@ package com.tangem.features.markets.details.impl.ui.components -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity @@ -14,9 +10,14 @@ import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent +import com.tangem.features.markets.impl.R +import dev.jeziellago.compose.markdowntext.MarkdownText @Composable internal fun InfoBottomSheet(config: TangemBottomSheetConfig) { @@ -26,22 +27,47 @@ internal fun InfoBottomSheet(config: TangemBottomSheetConfig) { config = config, skipPartiallyExpanded = false, addBottomInsets = false, - title = { - TangemBottomSheetTitle(title = it.title) - }, + title = { TangemBottomSheetTitle(title = it.title) }, content = { Column( modifier = Modifier .verticalScroll(rememberScrollState()) - .padding(horizontal = TangemTheme.dimens.spacing28), + .padding(horizontal = TangemTheme.dimens.spacing16), ) { - Text( - text = it.body.resolveReference(), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, + MarkdownText( + markdown = it.body.resolveReference(), + disableLinkMovementMethod = true, + linkifyMask = 0, + syntaxHighlightColor = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.body2.copy( + color = TangemTheme.colors.text.secondary, + ), ) + + if (it.showGeneratedAINotification) { + AdditionalInfoNotification( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing12, bottom = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) + } + SpacerH(bottomBarHeight) } }, ) +} + +@Composable +private fun AdditionalInfoNotification(modifier: Modifier = Modifier) { + Notification( + config = NotificationConfig( + subtitle = TextReference.Res(id = R.string.information_generated_with_ai), + iconResId = R.drawable.ic_magic_28, + ), + modifier = modifier, + subtitleColor = TangemTheme.colors.text.primary1, + containerColor = TangemTheme.colors.button.disabled, + iconTint = TangemTheme.colors.icon.accent, + ) } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt index df5dab7cce..e13502279d 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt @@ -47,7 +47,10 @@ internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) { PriceChangeInterval.MONTH, ), initialSelectedItem = PriceChangeInterval.H24, - onClick = { currentInterval = it }, + onClick = { + currentInterval = it + state.onIntervalChanged(it) + }, ) { Box( Modifier @@ -177,6 +180,7 @@ private fun ContentPreview() { ), ), onInfoClick = {}, + onIntervalChanged = {}, ), ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt index ca7e4d4390..de51bd5a8f 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt @@ -10,12 +10,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach -import com.tangem.core.ui.components.SmallButtonShimmer +import com.tangem.core.ui.components.ChipShimmer import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.block.information.InformationBlock -import com.tangem.core.ui.components.buttons.SecondarySmallButton -import com.tangem.core.ui.components.buttons.SmallButtonConfig -import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.buttons.chip.Chip import com.tangem.core.ui.components.inputrow.inner.DividerContainer import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme @@ -60,6 +58,7 @@ internal fun LinksBlock(state: LinksUM, modifier: Modifier = Modifier) { title = stringResource(id = R.string.markets_token_details_blockchain_site), links = state.blockchainSite, onLinkClick = state.onLinkClick, + lastBlock = true, ) } }, @@ -97,12 +96,10 @@ private fun SubBlock( verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { links.fastForEach { - SecondarySmallButton( - config = SmallButtonConfig( - text = it.title, - onClick = { onLinkClick(it) }, - icon = TangemButtonIconPosition.Start(iconResId = it.iconRes), - ), + Chip( + text = stringReference(it.title), + iconResId = it.iconRes, + onClick = { onLinkClick(it) }, ) } } @@ -148,9 +145,8 @@ private fun SubBlockPlaceholder(modifier: Modifier = Modifier, lastBlock: Boolea horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { repeat(times = 3) { - SmallButtonShimmer( + ChipShimmer( modifier = Modifier.weight(1f), - withIcon = true, ) } } @@ -167,36 +163,36 @@ private fun ContentPreview() { state = LinksUM( officialLinks = persistentListOf( LinksUM.Link( - title = stringReference("Website"), + title = "Website", iconRes = R.drawable.ic_plus_24, url = "https://tangem.com", ), LinksUM.Link( - title = stringReference("Website"), + title = "Website", iconRes = R.drawable.ic_plus_24, url = "https://tangem.com", ), LinksUM.Link( - title = stringReference("Website"), + title = "Website", iconRes = R.drawable.ic_plus_24, url = "https://tangem.com", ), ), social = persistentListOf( LinksUM.Link( - title = stringReference("Twitter"), + title = "Twitter", iconRes = R.drawable.ic_plus_24, url = "https://tangem.com", ), LinksUM.Link( - title = stringReference("Facebook"), + title = "Facebook", iconRes = R.drawable.ic_plus_24, url = "https://tangem.com", ), ), repository = persistentListOf( LinksUM.Link( - title = stringReference("Github"), + title = "Github", iconRes = R.drawable.ic_plus_24, url = "https://tangem.com", ), diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt index 83bc41555e..39970e9671 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt @@ -9,17 +9,21 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color import com.tangem.common.ui.charts.MarketChart import com.tangem.common.ui.charts.getMarketChartBottomAxisHeight import com.tangem.common.ui.charts.state.MarketChartLook import com.tangem.common.ui.charts.state.rememberMarketChartState -import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData @Composable -internal fun MarketTokenDetailsChart(state: MarketsTokenDetailsUM.ChartState, modifier: Modifier = Modifier) { +internal fun MarketTokenDetailsChart( + state: MarketsTokenDetailsUM.ChartState, + backgroundColor: Color, + modifier: Modifier = Modifier, +) { val growingColor = TangemTheme.colors.icon.accent val fallingColor = TangemTheme.colors.icon.warning val neutralColor = TangemTheme.colors.icon.informative @@ -36,7 +40,6 @@ internal fun MarketTokenDetailsChart(state: MarketsTokenDetailsUM.ChartState, mo onMarkerShown = state.onMarkerPointSelected, ) - val backgroundColor = LocalMainBottomSheetColor.current.value val bottomChartAxisHeight = getMarketChartBottomAxisHeight() Box(modifier) { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt index 66a09fb533..c111de7630 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt @@ -19,6 +19,7 @@ import com.tangem.core.ui.components.SpacerW8 import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.block.information.InformationBlock import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.components.progressbar.LinearProgressIndicator import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemAnimations import com.tangem.core.ui.res.TangemTheme @@ -53,7 +54,10 @@ internal fun PricePerformanceBlock(state: PricePerformanceUM, modifier: Modifier PriceChangeInterval.ALL_TIME, ), initialSelectedItem = PriceChangeInterval.H24, - onClick = { currentInterval = it }, + onClick = { + currentInterval = it + state.onIntervalChanged(it) + }, ) { Box( Modifier @@ -119,20 +123,21 @@ private fun Content(state: PricePerformanceUM.Value, modifier: Modifier = Modifi .fillMaxWidth(), progress = { animatedIndicatorFraction }, color = TangemTheme.colors.text.accent, - trackColor = TangemTheme.colors.background.tertiary, + backgroundColor = TangemTheme.colors.background.tertiary, strokeCap = StrokeCap.Round, ) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), ) { Text( + modifier = Modifier.weight(1f), text = state.low, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, ) - SpacerW8() Text( + modifier = Modifier.weight(1f), text = state.high, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, @@ -160,7 +165,7 @@ internal fun PricePerformanceBlockPlaceholder(modifier: Modifier = Modifier) { }, content = { Column( - modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8), + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing8), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { Row( @@ -225,6 +230,7 @@ private fun ContentPreview() { high = "\$580,5M", indicatorFraction = 0.2f, ), + onIntervalChanged = {}, ), ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt index 40cc2fc2f5..d7ae7737c6 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt @@ -11,16 +11,35 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData -internal fun LazyListScope.tokenMarketDetailsBody(state: MarketsTokenDetailsUM.Body) { +internal fun LazyListScope.tokenMarketDetailsBody( + state: MarketsTokenDetailsUM.Body, + portfolioBlock: @Composable ((Modifier) -> Unit)?, +) { when (state) { MarketsTokenDetailsUM.Body.Loading -> { - loading() + item("description-loading") { + DescriptionPlaceholder(modifier = Modifier.blockPaddings()) + } + + if (portfolioBlock != null) { + item(key = "portfolio") { + portfolioBlock(Modifier.blockPaddings()) + } + } + + loadingInfoBlocks() } is MarketsTokenDetailsUM.Body.Content -> { if (state.description != null) { description(state.description) } + if (portfolioBlock != null) { + item(key = "portfolio") { + portfolioBlock(Modifier.blockPaddings()) + } + } + infoBlocksList(state.infoBlocks) } is MarketsTokenDetailsUM.Body.Error -> { @@ -106,18 +125,15 @@ internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.Informati } } -private fun LazyListScope.loading() { - item("description-loading") { - DescriptionPlaceholder(modifier = Modifier.blockPaddings()) - } - +private fun LazyListScope.loadingInfoBlocks() { item("insights-loading") { InsightsBlockPlaceholder(modifier = Modifier.blockPaddings()) } - item("securityScore-loading") { - SecurityScorePlaceHolder(modifier = Modifier.blockPaddings()) - } + // TODO second markets iteration + // item("securityScore-loading") { + // SecurityScorePlaceHolder(modifier = Modifier.blockPaddings()) + // } item("metrics-loading") { MetricsBlockPlaceholder(modifier = Modifier.blockPaddings()) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoBottomSheetContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoBottomSheetContent.kt index 858fc017c5..c68995460f 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoBottomSheetContent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoBottomSheetContent.kt @@ -6,4 +6,5 @@ import com.tangem.core.ui.extensions.TextReference internal data class InfoBottomSheetContent( val title: TextReference, val body: TextReference, + val showGeneratedAINotification: Boolean = false, ) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InsightsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InsightsUM.kt index 9156f9bf1b..e2d0b3270b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InsightsUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InsightsUM.kt @@ -1,5 +1,6 @@ package com.tangem.features.markets.details.impl.ui.state +import com.tangem.domain.markets.PriceChangeInterval import kotlinx.collections.immutable.ImmutableList internal data class InsightsUM( @@ -7,4 +8,5 @@ internal data class InsightsUM( val weekInfo: ImmutableList, val monthInfo: ImmutableList, val onInfoClick: () -> Unit, + val onIntervalChanged: (PriceChangeInterval) -> Unit, ) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/LinksUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/LinksUM.kt index d1a6d13860..5b4ea87e18 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/LinksUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/LinksUM.kt @@ -1,7 +1,6 @@ package com.tangem.features.markets.details.impl.ui.state import androidx.annotation.DrawableRes -import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList internal data class LinksUM( @@ -13,7 +12,7 @@ internal data class LinksUM( ) { data class Link( @DrawableRes val iconRes: Int, - val title: TextReference, + val title: String, val url: String, ) } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt index 473bc3a518..cec823bccb 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt @@ -2,7 +2,6 @@ package com.tangem.features.markets.details.impl.ui.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.charts.state.MarketChartDataProducer -import com.tangem.common.ui.charts.state.MarketChartLook import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.event.StateEvent @@ -13,9 +12,9 @@ import java.math.BigDecimal internal data class MarketsTokenDetailsUM( val tokenName: String, val priceText: String, - val iconUrl: String, + val iconUrl: String?, val dateTimeText: TextReference, - val priceChangePercentText: String, + val priceChangePercentText: String?, val priceChangeType: PriceChangeType, val selectedInterval: PriceChangeInterval, val markerSet: Boolean, @@ -29,7 +28,6 @@ internal data class MarketsTokenDetailsUM( data class ChartState( val status: Status, val dataProducer: MarketChartDataProducer, - val chartLook: MarketChartLook, val onLoadRetryClick: () -> Unit, val onMarkerPointSelected: (time: BigDecimal?, price: BigDecimal?) -> Unit, ) { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MetricsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MetricsUM.kt index b1982093d4..8b28533fb3 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MetricsUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MetricsUM.kt @@ -1,7 +1,7 @@ package com.tangem.features.markets.details.impl.ui.state -import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.ImmutableList internal data class MetricsUM( - val metrics: PersistentList, + val metrics: ImmutableList, ) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/PricePerformanceUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/PricePerformanceUM.kt index e56e10312e..9448472a0d 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/PricePerformanceUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/PricePerformanceUM.kt @@ -1,11 +1,13 @@ package com.tangem.features.markets.details.impl.ui.state import androidx.annotation.FloatRange +import com.tangem.domain.markets.PriceChangeInterval internal data class PricePerformanceUM( val h24: Value, val month: Value, val all: Value, + val onIntervalChanged: (PriceChangeInterval) -> Unit, ) { data class Value( val low: String, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ComponentModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ComponentModule.kt deleted file mode 100644 index c48bcbd802..0000000000 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ComponentModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.features.markets.di - -import com.tangem.features.markets.component.MarketsEntryComponent -import com.tangem.features.markets.DefaultMarketsEntryComponent -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 ComponentModule { - - @Binds - @Singleton - fun bindMarketsListComponent(factory: DefaultMarketsEntryComponent.Factory): MarketsEntryComponent.Factory -} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/DefaultMarketsEntryComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/DefaultMarketsEntryComponent.kt new file mode 100644 index 0000000000..79865f0661 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/DefaultMarketsEntryComponent.kt @@ -0,0 +1,106 @@ +package com.tangem.features.markets.entry.impl + +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState +import com.arkivanov.decompose.router.stack.* +import com.arkivanov.decompose.value.Value +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.ui.UiDependencies +import com.tangem.core.ui.message.EventMessageEffect +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarket +import com.tangem.features.markets.entry.BottomSheetState +import com.tangem.features.markets.entry.MarketsEntryComponent +import com.tangem.features.markets.details.MarketsTokenDetailsComponent +import com.tangem.domain.markets.toSerializableParam +import com.tangem.features.markets.entry.impl.MarketsEntryChildFactory.Child +import com.tangem.features.markets.entry.impl.ui.EntryBottomSheetContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Stable +internal class DefaultMarketsEntryComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + private val marketsEntryChildFactory: MarketsEntryChildFactory, + private val uiDependencies: UiDependencies, +) : MarketsEntryComponent, AppComponentContext by context { + + private val stackNavigation = StackNavigation() + + val stack: Value> = childStack( + key = "main", + source = stackNavigation, + serializer = Child.serializer(), + initialConfiguration = Child.TokenList, + handleBackButton = true, + childFactory = { configuration, factoryContext -> + marketsEntryChildFactory.createChild( + child = configuration, + appComponentContext = childByContext( + componentContext = factoryContext, + router = createRouter(configuration), + ), + onTokenSelected = ::marketsListTokenSelected, + ) + }, + ) + + @Suppress("LongMethod") + @Composable + override fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) { + EntryBottomSheetContent( + bottomSheetState = bottomSheetState, + onHeaderSizeChange = onHeaderSizeChange, + stackState = stack.subscribeAsState(), + modifier = modifier, + ) + EventMessageEffect( + messageHandler = uiDependencies.eventMessageHandler, + snackbarHostState = uiDependencies.globalSnackbarHostState, + ) + } + + @OptIn(ExperimentalDecomposeApi::class) + private fun marketsListTokenSelected(token: TokenMarket, appCurrency: AppCurrency) { + stackNavigation.pushNew( + configuration = Child.TokenDetails( + params = MarketsTokenDetailsComponent.Params( + token = token.toSerializableParam(), + appCurrency = appCurrency, + showPortfolio = true, + analyticsParams = MarketsTokenDetailsComponent.AnalyticsParams( + blockchain = null, + source = "Market", + ), + ), + ), + ) + } + + private fun AppComponentContext.createRouter(child: Child): Router { + return when (child) { + is Child.TokenDetails -> { + MarketTokenDetailsRouter( + contextRouter = this.router, + stackNavigation = stackNavigation, + ) + } + else -> this.router + } + } + + @AssistedFactory + interface Factory : MarketsEntryComponent.Factory { + override fun create(context: AppComponentContext): DefaultMarketsEntryComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketTokenDetailsRouter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketTokenDetailsRouter.kt new file mode 100644 index 0000000000..fb1d1b1eff --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketTokenDetailsRouter.kt @@ -0,0 +1,25 @@ +package com.tangem.features.markets.entry.impl + +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.popWhile +import com.tangem.core.decompose.navigation.Route +import com.tangem.core.decompose.navigation.Router +import kotlin.reflect.KClass + +internal class MarketTokenDetailsRouter( + private val contextRouter: Router, + private val stackNavigation: StackNavigation, +) : Router by contextRouter { + + override fun pop(onComplete: (isSuccess: Boolean) -> Unit) { + stackNavigation.popWhile({ it != MarketsEntryChildFactory.Child.TokenList }, onComplete) + } + + override fun popTo(route: Route, onComplete: (isSuccess: Boolean) -> Unit) { + /** Not allowed */ + } + + override fun popTo(routeClass: KClass, onComplete: (isSuccess: Boolean) -> Unit) { + /** Not allowed */ + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/MarketsEntryChildFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketsEntryChildFactory.kt similarity index 88% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/MarketsEntryChildFactory.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketsEntryChildFactory.kt index 0db378d0f5..31e6f5e1e0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/MarketsEntryChildFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketsEntryChildFactory.kt @@ -1,10 +1,11 @@ -package com.tangem.features.markets +package com.tangem.features.markets.entry.impl import androidx.compose.runtime.Immutable import com.tangem.core.decompose.context.AppComponentContext import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket -import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.MarketsTokenDetailsComponent +import com.tangem.features.markets.entry.impl.MarketsEntryChildFactory.Child import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent import kotlinx.serialization.Serializable import javax.inject.Inject @@ -31,14 +32,12 @@ internal class MarketsEntryChildFactory @Inject constructor( child: Child, appComponentContext: AppComponentContext, onTokenSelected: (TokenMarket, AppCurrency) -> Unit, - onDetailsBack: () -> Unit, ): Any { return when (child) { is Child.TokenDetails -> { tokenDetailsComponentFactory.create( context = appComponentContext, params = child.params, - onBack = onDetailsBack, ) } is Child.TokenList -> { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/di/ComponentModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/di/ComponentModule.kt new file mode 100644 index 0000000000..4603041300 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/di/ComponentModule.kt @@ -0,0 +1,18 @@ +package com.tangem.features.markets.entry.impl.di + +import com.tangem.features.markets.entry.MarketsEntryComponent +import com.tangem.features.markets.entry.impl.DefaultMarketsEntryComponent +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 ComponentModule { + + @Binds + @Singleton + fun bindMarketsEntryComponent(factory: DefaultMarketsEntryComponent.Factory): MarketsEntryComponent.Factory +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt new file mode 100644 index 0000000000..5f30e89585 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt @@ -0,0 +1,127 @@ +package com.tangem.features.markets.entry.impl.ui + +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector4D +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import com.arkivanov.decompose.extensions.compose.jetpack.stack.Children +import com.arkivanov.decompose.extensions.compose.jetpack.stack.animation.slide +import com.arkivanov.decompose.extensions.compose.jetpack.stack.animation.stackAnimation +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.markets.details.MarketsTokenDetailsComponent +import com.tangem.features.markets.entry.BottomSheetState +import com.tangem.features.markets.entry.impl.MarketsEntryChildFactory +import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent + +@Composable +internal fun EntryBottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + stackState: State>, + modifier: Modifier = Modifier, +) { + val primary = TangemTheme.colors.background.primary + val backgroundColor = remember { Animatable(primary) } + + LocalMainBottomSheetColor.current.value = backgroundColor.value + + Children( + stack = stackState.value, + animation = stackAnimation(slide()), + ) { + when (it.configuration) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + (it.instance as MarketsTokenDetailsComponent).BottomSheetContent( + bottomSheetState = bottomSheetState, + onHeaderSizeChange = onHeaderSizeChange, + modifier = modifier, + ) + } + MarketsEntryChildFactory.Child.TokenList -> { + (it.instance as MarketsTokenListComponent).BottomSheetContent( + bottomSheetState = bottomSheetState, + onHeaderSizeChange = onHeaderSizeChange, + modifier = modifier, + ) + } + } + } + + val activeChild = stackState.value.active.configuration + + BackgroundColorEffects( + activeChild = activeChild, + backgroundColor = backgroundColor, + bottomSheetState = bottomSheetState, + ) +} + +@Composable +private fun BackgroundColorEffects( + activeChild: MarketsEntryChildFactory.Child, + backgroundColor: Animatable, + bottomSheetState: State, +) { + val primary = TangemTheme.colors.background.primary + val tertiary = TangemTheme.colors.background.tertiary + + // Order of LaunchedEffects is important here + + LaunchedEffect(activeChild) { + when (activeChild) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + backgroundColor.animateTo( + tertiary, + animationSpec = tween(durationMillis = 500), + ) + } + MarketsEntryChildFactory.Child.TokenList -> { + backgroundColor.animateTo( + primary, + animationSpec = tween(durationMillis = 500), + ) + } + } + } + + LaunchedEffect(bottomSheetState.value) { + if (activeChild is MarketsEntryChildFactory.Child.TokenDetails) { + when (bottomSheetState.value) { + BottomSheetState.EXPANDED -> { + backgroundColor.animateTo( + tertiary, + animationSpec = tween(durationMillis = 100), + ) + } + BottomSheetState.COLLAPSED -> { + backgroundColor.animateTo( + primary, + animationSpec = tween(durationMillis = 100), + ) + } + } + } + } + + LaunchedEffect(primary, tertiary) { + if (backgroundColor.isRunning) return@LaunchedEffect + + when (activeChild) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + backgroundColor.snapTo(tertiary) + } + MarketsEntryChildFactory.Child.TokenList -> { + backgroundColor.snapTo(primary) + } + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/api/MarketsPortfolioComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/api/MarketsPortfolioComponent.kt new file mode 100644 index 0000000000..62babbdc62 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/api/MarketsPortfolioComponent.kt @@ -0,0 +1,29 @@ +package com.tangem.features.markets.portfolio.api + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import kotlinx.serialization.Serializable + +@Stable +interface MarketsPortfolioComponent : ComposableContentComponent { + + @Serializable + data class Params( + val token: TokenMarketParams, + val analyticsParams: AnalyticsParams?, + ) + + @Serializable + data class AnalyticsParams( + val source: String, + ) + + fun setTokenNetworks(networks: List) + + fun setNoNetworksAvailable() + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt new file mode 100644 index 0000000000..73841ec05d --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt @@ -0,0 +1,43 @@ +package com.tangem.features.markets.portfolio.impl + +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.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.markets.portfolio.impl.model.MarketsPortfolioModel +import com.tangem.features.markets.portfolio.impl.ui.MyPortfolio +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Stable +internal class DefaultMarketsPortfolioComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: MarketsPortfolioComponent.Params, +) : AppComponentContext by context, MarketsPortfolioComponent { + + private val model: MarketsPortfolioModel = getOrCreateModel(params) + + override fun setTokenNetworks(networks: List) = model.setTokenNetworks(networks) + override fun setNoNetworksAvailable() = model.setNoNetworksAvailable() + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + + MyPortfolio(modifier = modifier, state = state) + } + + @AssistedFactory + interface Factory : MarketsPortfolioComponent.Factory { + override fun create( + context: AppComponentContext, + params: MarketsPortfolioComponent.Params, + ): DefaultMarketsPortfolioComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt new file mode 100644 index 0000000000..5f7ff68ab5 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt @@ -0,0 +1,50 @@ +package com.tangem.features.markets.portfolio.impl.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM + +internal class PortfolioAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) { + + data class EventBuilder( + val token: TokenMarketParams, + val source: String?, + ) { + + fun addToPortfolioClicked() = PortfolioAnalyticsEvent( + event = "Button - Add To Portfolio", + params = mapOf( + "Token" to token.symbol, + ), + ) + + fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent(event = "Wallet Selected") + + fun addToPortfolioContinue(blockchainNames: List) = PortfolioAnalyticsEvent( + event = "Token Network Selected", + params = mapOf( + "Count" to blockchainNames.size.toString(), + "Token" to token.symbol, + "blockchain" to blockchainNames.joinToString(separator = ", "), + ), + ) + + fun quickActionClick(actionUM: TokenActionsBSContentUM.Action, blockchainName: String) = + PortfolioAnalyticsEvent( + event = when (actionUM) { + TokenActionsBSContentUM.Action.Buy -> "Button - Buy" + TokenActionsBSContentUM.Action.Receive -> "Button - Receive" + TokenActionsBSContentUM.Action.Exchange -> "Button - Swap" + else -> "error" + }, + params = buildMap { + put("Token", token.symbol) + source?.let { put("Source", source) } + put("blockchain", blockchainName) + }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/di/ComponentModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/di/ComponentModule.kt new file mode 100644 index 0000000000..d011fbf799 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/di/ComponentModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.markets.portfolio.impl.di + +import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.markets.portfolio.impl.DefaultMarketsPortfolioComponent +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 ComponentModule { + + @Binds + @Singleton + fun bindMarketsPortfolioComponent( + factory: DefaultMarketsPortfolioComponent.Factory, + ): MarketsPortfolioComponent.Factory +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/di/ModelModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/di/ModelModule.kt new file mode 100644 index 0000000000..38ea8f8688 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/di/ModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.markets.portfolio.impl.di + +import com.tangem.core.decompose.di.DecomposeComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.markets.portfolio.impl.model.MarketsPortfolioModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(DecomposeComponent::class) +internal interface ModelModule { + + @Binds + @IntoMap + @ClassKey(MarketsPortfolioModel::class) + fun provideMarketsPortfolioModel(model: MarketsPortfolioModel): Model +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioData.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioData.kt new file mode 100644 index 0000000000..ece4a0a217 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioData.kt @@ -0,0 +1,33 @@ +package com.tangem.features.markets.portfolio.impl.loader + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.domain.tokens.model.TotalFiatBalance +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Portfolio data. Combined data from all flows that required to setup portfolio + * + * @property walletsWithCurrencies wallets with crypto currency statuses + * @property appCurrency app currency + * @property isBalanceHidden flag that indicates if balance should be hidden + * @property walletsWithBalance wallets with total balance + * +[REDACTED_AUTHOR] + */ +internal data class PortfolioData( + val walletsWithCurrencies: Map>, + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val walletsWithBalance: Map>, +) { + data class CryptoCurrencyData( + val userWallet: UserWallet, + val status: CryptoCurrencyStatus, + val actions: List, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioDataLoader.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioDataLoader.kt new file mode 100644 index 0000000000..ac016c1bbf --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioDataLoader.kt @@ -0,0 +1,146 @@ +package com.tangem.features.markets.portfolio.impl.loader + +import arrow.core.getOrElse +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.tokens.GetAllWalletsCryptoCurrencyStatusesUseCase +import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase +import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.model.TotalFiatBalance +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import timber.log.Timber +import javax.inject.Inject + +/** + * Loader of portfolio data + * + * @property getAllWalletsCryptoCurrencyStatusesUseCase use case for getting all wallets crypto currency statuses + * @property getSelectedAppCurrencyUseCase use case for getting selected app currency + * @property getBalanceHidingSettingsUseCase use case for getting balance hiding settings + * @property getWalletTotalBalanceUseCase use case for getting wallet total balance + * +[REDACTED_AUTHOR] + */ +internal class PortfolioDataLoader @Inject constructor( + private val getAllWalletsCryptoCurrencyStatusesUseCase: GetAllWalletsCryptoCurrencyStatusesUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val getWalletTotalBalanceUseCase: GetWalletTotalBalanceUseCase, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, +) { + + /** Load data by [currencyRawId] */ + @OptIn(ExperimentalCoroutinesApi::class) + fun load(currencyRawId: String): Flow { + return combine( + flow = getAllWalletsCryptoCurrenciesData(currencyRawId = currencyRawId), + flow2 = getSelectedAppCurrencyFlow(), + flow3 = getBalanceHidingSettingsFlow(), + ) { walletsWithCurrencies, appCurrency, isBalanceHidden -> + PortfolioData( + walletsWithCurrencies = walletsWithCurrencies, + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + walletsWithBalance = emptyMap(), + ) + } + // setup balances for wallets from walletsWithCurrencyStatuses + .flatMapLatest { portfolioData -> + getWalletsWithTotalBalanceFlow( + ids = portfolioData.walletsWithCurrencies.keys.map(UserWallet::walletId), + ) + .map { portfolioData.copy(walletsWithBalance = it) } + .onEmpty { emit(portfolioData) } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun getAllWalletsCryptoCurrenciesData( + currencyRawId: String, + ): Flow>> { + return getAllWalletsCryptoCurrencyStatusesUseCase(currencyRawId) + .distinctUntilChanged() + .map { walletsWithMaybeStatuses -> + walletsWithMaybeStatuses.mapValues { entry -> + entry.value.mapNotNull { it.getOrNull() } + } + } + .flatMapLatest { walletsWithStatuses -> + val actionsFlows = walletsWithStatuses.flatMap { (wallet, statuses) -> + statuses.map { status -> + getCryptoCurrencyActionsUseCase(wallet, status) + .map { + PortfolioData.CryptoCurrencyData( + userWallet = wallet, + status = status, + actions = it.states, + ) + } + } + } + + combine(actionsFlows) { actions -> + walletsWithStatuses.mapValues { entry -> + entry.value.mapNotNull { status -> + actions.firstOrNull { + it.userWallet == entry.key && it.status == status + } + } + } + } + .onEmpty { + emit( + walletsWithStatuses.mapValues { (wallet, statuses) -> + statuses.map { + PortfolioData.CryptoCurrencyData( + userWallet = wallet, + status = it, + actions = emptyList(), + ) + } + }, + ) + } + } + .distinctUntilChanged() + } + + private fun getSelectedAppCurrencyFlow(): Flow { + return getSelectedAppCurrencyUseCase() + .map { + it.getOrElse { e -> + Timber.e("Failed to load app currency: $e") + AppCurrency.Default + } + } + .distinctUntilChanged() + } + + private fun getBalanceHidingSettingsFlow(): Flow { + return getBalanceHidingSettingsUseCase() + .map { it.isBalanceHidden } + .distinctUntilChanged() + } + + private fun getWalletsWithTotalBalanceFlow( + ids: List, + ): Flow>> { + return combine( + flows = ids + .map { userWalletId -> + getWalletTotalBalanceUseCase(userWalletId) + .map { userWalletId to it } + .distinctUntilChanged() + }, + transform = { it.toMap() }, + ) + .distinctUntilChanged() + .onEmpty { ids.associateWith { Lce.Loading(partialContent = null) } } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt new file mode 100644 index 0000000000..3ce401a818 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt @@ -0,0 +1,132 @@ +package com.tangem.features.markets.portfolio.impl.model + +import com.tangem.common.ui.userwallet.converter.UserWalletItemUMConverter +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.markets.portfolio.impl.loader.PortfolioData +import com.tangem.features.markets.portfolio.impl.ui.state.AddToPortfolioBSContentUM +import com.tangem.features.markets.portfolio.impl.ui.state.WalletSelectorBSContentUM +import kotlinx.collections.immutable.toImmutableList + +/** + * Factory to create AddToPortfolio bottom sheet content [TangemBottomSheetConfig] + * + * @property token token params + * @property onAddToPortfolioVisibilityChange callback is invoked when add to portfolio visibility is changed + * @property onWalletSelectorVisibilityChange callback is invoked when wallet selector visibility is changed + * @property onNetworkSwitchClick callback is invoked when network switch is clicked + * @property onWalletSelect callback is invoked when wallet is selected + * @property onContinueClick callback is invoked when continue button is clicked + * +[REDACTED_AUTHOR] + */ +internal class AddToPortfolioBSContentUMFactory( + private val token: TokenMarketParams, + private val onAddToPortfolioVisibilityChange: (Boolean) -> Unit, + private val onWalletSelectorVisibilityChange: (Boolean) -> Unit, + private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit, + private val onWalletSelect: (UserWalletId) -> Unit, + private val onContinueClick: (selectedWalletId: UserWalletId, addedNetworks: Set) -> Unit, +) { + + /** + * Create [TangemBottomSheetConfig] + * + * @param portfolioData portfolio data + * @param portfolioUIData portfolio bottom sheet visibility model + * @param selectedWallet selected wallet + * @param alreadyAddedNetworks already added networks + */ + fun create( + portfolioData: PortfolioData, + portfolioUIData: PortfolioUIData, + selectedWallet: UserWallet, + alreadyAddedNetworks: Set, + ): TangemBottomSheetConfig { + return TangemBottomSheetConfig( + isShow = portfolioUIData.portfolioBSVisibilityModel.addToPortfolioBSVisibility, + onDismissRequest = { onAddToPortfolioVisibilityChange(false) }, + content = AddToPortfolioBSContentUM( + selectedWallet = selectedWallet.toSelectedUserWalletItemUM(), + selectNetworkUM = SelectNetworkUMConverter( + networksWithToggle = portfolioUIData.addToPortfolioData.associateWithToggle( + userWalletId = selectedWallet.walletId, + alreadyAddedNetworkIds = alreadyAddedNetworks, + ), + alreadyAddedNetworks = alreadyAddedNetworks, + onNetworkSwitchClick = onNetworkSwitchClick, + ).convert(value = token), + isScanCardNotificationVisible = portfolioUIData.hasMissedDerivations, + continueButtonEnabled = portfolioUIData.addToPortfolioData.isUserChangedNetworks( + userWalletId = selectedWallet.walletId, + ), + onContinueButtonClick = { + val alreadyAddedNetworkIds = portfolioData.walletsWithCurrencies[selectedWallet].orEmpty() + .map { it.status.currency.network.backendId } + .toSet() + + onContinueClick( + selectedWallet.walletId, + portfolioUIData.addToPortfolioData.getAddedNetworks( + userWalletId = selectedWallet.walletId, + alreadyAddedNetworkIds = alreadyAddedNetworkIds, + ), + ) + }, + walletSelectorConfig = crateWalletSelectorBSConfig( + isShow = portfolioUIData.portfolioBSVisibilityModel.walletSelectorBSVisibility, + portfolioData = portfolioData, + selectedWalletId = selectedWallet.walletId, + ), + isWalletBlockVisible = portfolioData.walletsWithCurrencies + .filterKeys(UserWallet::isMultiCurrency).size > 1, + ), + ) + } + + private fun UserWallet.toSelectedUserWalletItemUM(): UserWalletItemUM { + return UserWalletItemUMConverter( + onClick = { onWalletSelectorVisibilityChange(true) }, + endIcon = UserWalletItemUM.EndIcon.Arrow, + ).convert(value = this) + } + + private fun crateWalletSelectorBSConfig( + isShow: Boolean, + portfolioData: PortfolioData, + selectedWalletId: UserWalletId, + ): TangemBottomSheetConfig { + return TangemBottomSheetConfig( + isShow = isShow, + onDismissRequest = { onWalletSelectorVisibilityChange(false) }, + content = WalletSelectorBSContentUM( + userWallets = portfolioData.walletsWithCurrencies + .filterKeys(UserWallet::isMultiCurrency) + .map { it.key } + .map { userWallet -> + val balance = portfolioData.walletsWithBalance[userWallet.walletId] + + UserWalletItemUMConverter( + onClick = onWalletSelect, + appCurrency = portfolioData.appCurrency, + balance = balance?.getOrNull(), + isLoading = balance?.isLoading() == true, + isBalanceHidden = portfolioData.isBalanceHidden, + endIcon = if (userWallet.walletId == selectedWalletId) { + UserWalletItemUM.EndIcon.Checkmark + } else { + UserWalletItemUM.EndIcon.None + }, + ).convert(userWallet) + } + .toImmutableList(), + onBack = { onWalletSelectorVisibilityChange(false) }, + ), + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioManager.kt new file mode 100644 index 0000000000..be5ee4d9ed --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioManager.kt @@ -0,0 +1,186 @@ +package com.tangem.features.markets.portfolio.impl.model + +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import timber.log.Timber +import javax.inject.Inject + +internal typealias WalletsWithNetworks = Map> + +/** + * Manager for tracking changing networks in AddToPortfolio + * +[REDACTED_AUTHOR] + */ +internal class AddToPortfolioManager @Inject constructor() { + + private val availableNetworks = MutableStateFlow?>(value = null) + private val addedNetworks = MutableStateFlow(value = emptyMap()) + private val removedNetworks = MutableStateFlow(value = emptyMap()) + + /** Get [AddToPortfolioData] as flow */ + fun getAddToPortfolioData(): Flow { + return combine( + flow = availableNetworks, + flow2 = addedNetworks, + flow3 = removedNetworks, + transform = ::AddToPortfolioData, + ) + } + + /** Set available networks [networks] */ + fun setAvailableNetworks(networks: List) { + availableNetworks.value = networks.toSet() + } + + /** Add network [networkId] to [userWalletId] */ + fun addNetwork(userWalletId: UserWalletId, networkId: String) { + addedNetworks.add(userWalletId, networkId) + + removedNetworks.cancelPrevChangeIfExist(userWalletId = userWalletId, networkId = networkId) + } + + /** Remove network [networkId] from [userWalletId] */ + fun removeNetwork(userWalletId: UserWalletId, networkId: String) { + removedNetworks.add(userWalletId, networkId) + + addedNetworks.cancelPrevChangeIfExist( + userWalletId = userWalletId, + networkId = networkId, + ) + } + + /** Remove all networks by [userWalletId] */ + fun removeAllChanges(userWalletId: UserWalletId) { + addedNetworks.update { + it.toMutableMap().apply { remove(userWalletId) } + } + + removedNetworks.update { + it.toMutableMap().apply { remove(userWalletId) } + } + } + + private fun MutableStateFlow.cancelPrevChangeIfExist( + userWalletId: UserWalletId, + networkId: String, + ) { + if (value[userWalletId].orEmpty().any { it.networkId == networkId }) remove(userWalletId, networkId) + } + + private fun MutableStateFlow.add(userWalletId: UserWalletId, networkId: String) { + change(userWalletId = userWalletId, networkId = networkId, isAddAction = true) + } + + private fun MutableStateFlow.remove(userWalletId: UserWalletId, networkId: String) { + change(userWalletId = userWalletId, networkId = networkId, isAddAction = false) + } + + private fun MutableStateFlow.change( + userWalletId: UserWalletId, + networkId: String, + isAddAction: Boolean, + ) { + val network = availableNetworks.value.orEmpty().firstOrNull { it.networkId == networkId } + + if (network == null) { + Timber.d( + "Network [$networkId] doesn't contain in available networks [%s]", + availableNetworks.value?.joinToString { it.networkId }, + ) + + return + } + + update { + it.toMutableMap().apply { + this[userWalletId] = if (isAddAction) { + this[userWalletId].orEmpty() + network + } else { + this[userWalletId].orEmpty() - network + } + } + } + } + + /** + * Add to portfolio data + * + * @property availableNetworks available networks that user can add to portfolio + * @property addedNetworks networks that user toggled on, but it might have already been added to the wallet + * @property removedNetworks networks that user toggled off, but it might haven't been added to the wallet + * + * Example for [addedNetworks] and [removedNetworks]. This lists will include new networks when user just + * toggle it. But when we will save user changes, we will check what tokens have already been added or + * haven't been added to the wallet. See [getAddedNetworks] and [getRemovedNetworks] + */ + data class AddToPortfolioData( + val availableNetworks: Set?, + val addedNetworks: WalletsWithNetworks, + private val removedNetworks: WalletsWithNetworks, + ) { + + /** + * Associate network with toggle. If user changed toggle state then use it, otherwise check state by already + * added networks. + * + * @param userWalletId user wallet id + * @param alreadyAddedNetworkIds already added network ids + */ + fun associateWithToggle( + userWalletId: UserWalletId, + alreadyAddedNetworkIds: Set, + ): Map { + // Use user choice or check already added networks + return availableNetworks?.associateWith { availableNetwork -> + val isAddedByUser = addedNetworks[userWalletId]?.contains(availableNetwork) + + if (isAddedByUser == true) return@associateWith true + + val isRemovedByUser = removedNetworks[userWalletId]?.contains(availableNetwork) + + if (isRemovedByUser == true) return@associateWith false + + val isAddedBefore = alreadyAddedNetworkIds.any { it == availableNetwork.networkId } + + isAddedBefore + } + .orEmpty() + } + + fun isUserChangedNetworks(userWalletId: UserWalletId): Boolean { + return addedNetworks[userWalletId].orEmpty().isNotEmpty() || + removedNetworks[userWalletId].orEmpty().isNotEmpty() + } + + /** Get new networks that user [userWalletId] added using [alreadyAddedNetworkIds] */ + fun getAddedNetworks( + userWalletId: UserWalletId, + alreadyAddedNetworkIds: Set, + ): Set { + val addedNetworksByUser = addedNetworks[userWalletId].orEmpty() + + return addedNetworksByUser.map { it.networkId } + .minus(alreadyAddedNetworkIds) + .mapNotNull { networkId -> addedNetworksByUser.firstOrNull { it.networkId == networkId } } + .toSet() + } + + /** Get networks that user [userWalletId] removed using [alreadyAddedNetworkIds] */ + fun getRemovedNetworks( + userWalletId: UserWalletId, + alreadyAddedNetworkIds: Set, + ): Set { + val removedNetworksByUser = removedNetworks[userWalletId].orEmpty() + + return alreadyAddedNetworkIds + .minus(removedNetworksByUser.map { it.networkId }.toSet()) + .mapNotNull { networkId -> removedNetworksByUser.firstOrNull { it.networkId == networkId } } + .toSet() + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/BlockchainRowUMConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/BlockchainRowUMConverter.kt new file mode 100644 index 0000000000..c9ec3f67cb --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/BlockchainRowUMConverter.kt @@ -0,0 +1,66 @@ +package com.tangem.features.markets.portfolio.impl.model + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.extensions.getGreyedOutIconRes +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.converter.Converter + +/** + * Converter from [TokenMarketInfo.Network] to [BlockchainRowUM] + * + * @property alreadyAddedNetworks set of already added networks + * +[REDACTED_AUTHOR] + */ +internal class BlockchainRowUMConverter( + private val alreadyAddedNetworks: Set, +) : Converter, BlockchainRowUM> { + + override fun convert(value: Pair): BlockchainRowUM { + val (network, isSelected) = value + + val blockchainInfo = BlockchainUtils.getNetworkInfo(networkId = network.networkId) + ?: error("Can't find blockchain info for ${network.networkId}") + + val isMainNetwork = network.contractAddress == null + + val isEnabled = !alreadyAddedNetworks.contains(network.networkId) + + return BlockchainRowUM( + id = network.networkId, + name = blockchainInfo.name, + type = getNetworkType(network, blockchainInfo), + iconResId = if (isEnabled) { + if (isSelected) { + getActiveIconRes(blockchainInfo.blockchainId) + } else { + getGreyedOutIconRes(blockchainInfo.blockchainId) + } + } else { + getGreyedOutIconRes(blockchainInfo.blockchainId) + }, + isMainNetwork = isMainNetwork, + isSelected = isSelected, + isEnabled = isEnabled, + ) + } + + private fun getNetworkType( + network: TokenMarketInfo.Network, + blockchainInfo: BlockchainUtils.BlockchainInfo, + ): String { + val isMainNetwork = network.contractAddress == null + return when { + BlockchainUtils.isL2Network(networkId = network.networkId) -> MAIN_NETWORK_L2_TYPE_NAME + isMainNetwork -> MAIN_NETWORK_TYPE_NAME + else -> blockchainInfo.protocolName + } + } + + private companion object { + const val MAIN_NETWORK_TYPE_NAME = "MAIN" + const val MAIN_NETWORK_L2_TYPE_NAME = "MAIN L2" + } +} \ 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 new file mode 100644 index 0000000000..9c6c4bcba1 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt @@ -0,0 +1,327 @@ +package com.tangem.features.markets.portfolio.impl.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ComponentScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.ContentMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.card.HasMissedDerivationsUseCase +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.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase +import com.tangem.features.markets.impl.R +import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.markets.portfolio.impl.loader.PortfolioDataLoader +import com.tangem.features.markets.portfolio.impl.ui.WarningDialog +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@Suppress("LongParameterList") +@Stable +@ComponentScoped +internal class MarketsPortfolioModel @Inject constructor( + paramsContainer: ParamsContainer, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + tokenActionsIntentsFactory: TokenActionsHandler.Factory, + override val dispatchers: CoroutineDispatcherProvider, + private val messageSender: UiMessageSender, + private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, + private val getSelectedWalletUseCase: GetSelectedWalletUseCase, + private val portfolioDataLoader: PortfolioDataLoader, + private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase, + private val saveMarketTokensUseCase: SaveMarketTokensUseCase, + private val addToPortfolioManager: AddToPortfolioManager, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model() { + + val state: StateFlow get() = _state + private val _state: MutableStateFlow = MutableStateFlow(value = MyPortfolioUM.Loading) + + private val params = paramsContainer.require() + private val analyticsEventBuilder = PortfolioAnalyticsEvent.EventBuilder( + token = params.token, + source = params.analyticsParams?.source, + ) + + /** Multi-wallet [UserWalletId] that user uses to add new tokens in AddToPortfolio bottom sheet */ + private val selectedMultiWalletIdFlow = MutableStateFlow(value = null) + + private val portfolioBSVisibilityModelFlow = MutableStateFlow(value = PortfolioBSVisibilityModel()) + + private val currentAppCurrency = getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + + private val factory = MyPortfolioUMFactory( + onAddClick = { + onAddToPortfolioBSVisibilityChange(isShow = true) + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.addToPortfolioClicked(), + ) + }, + addToPortfolioBSContentUMFactory = AddToPortfolioBSContentUMFactory( + token = params.token, + onAddToPortfolioVisibilityChange = ::onAddToPortfolioBSVisibilityChange, + onWalletSelectorVisibilityChange = ::onWalletSelectorVisibilityChange, + onNetworkSwitchClick = ::onNetworkSwitchClick, + onWalletSelect = { + onWalletSelect(it) + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.addToPortfolioWalletChanged(), + ) + }, + onContinueClick = { selectedWalletId, addedNetworks -> + onContinueClick(selectedWalletId, addedNetworks) + + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.addToPortfolioContinue( + blockchainNames = addedNetworks.mapNotNull { + BlockchainUtils.getNetworkInfo(it.networkId)?.name + }, + ), + ) + }, + ), + currentState = Provider { _state.value }, + tokenActionsHandler = tokenActionsIntentsFactory.create( + currentAppCurrency = Provider { currentAppCurrency.value }, + updateTokenReceiveBSConfig = { updateBlock -> + updateTokensState { it.copy(tokenReceiveBSConfig = updateBlock(it.tokenReceiveBSConfig)) } + }, + onHandleQuickAction = { handledAction -> + analyticsEventHandler.send( + analyticsEventBuilder.quickActionClick( + actionUM = handledAction.action, + blockchainName = handledAction.cryptoCurrencyData.status.currency.network.name, + ), + ) + }, + ), + updateTokens = { updateBlock -> + updateTokensState { state -> + state.copy(tokens = updateBlock(state.tokens)) + } + }, + ) + + init { + // Subscribe on selected wallet flow to support actual selected wallet + subscribeOnSelectedMultiWalletUpdates() + + subscribeOnStateUpdates() + } + + fun setTokenNetworks(networks: List) { + addToPortfolioManager.setAvailableNetworks(networks) + } + + fun setNoNetworksAvailable() { + addToPortfolioManager.setAvailableNetworks(emptyList()) + } + + private fun subscribeOnSelectedMultiWalletUpdates() { + getSelectedWalletUseCase() + .getOrElse { e -> + Timber.e("Failed to load selected wallet: $e") + error("Failed to load selected wallet") + } + .onEach { + selectedMultiWalletIdFlow.value = it.takeIf { it.isMultiCurrency }?.walletId + } + .launchIn(modelScope) + } + + private fun subscribeOnStateUpdates() { + combine( + flow = portfolioDataLoader.load(params.token.id), + flow2 = getPortfolioUIDataFlow(), + transform = factory::create, + ) + .onEach { _state.value = it } + .launchIn(modelScope) + } + + private fun getPortfolioUIDataFlow(): Flow { + return combine( + flow = portfolioBSVisibilityModelFlow, + flow2 = selectedMultiWalletIdFlow, + flow3 = addToPortfolioManager.getAddToPortfolioData(), + transform = { portfolioBSVisibilityModel, selectedWalletId, addToPortfolioData -> + PortfolioUIData( + portfolioBSVisibilityModel = portfolioBSVisibilityModel, + selectedWalletId = selectedWalletId, + addToPortfolioData = addToPortfolioData, + hasMissedDerivations = hasMissedDerivations(selectedWalletId, addToPortfolioData), + ) + }, + ) + } + + private suspend fun hasMissedDerivations( + selectedWalletId: UserWalletId?, + addToPortfolioData: AddToPortfolioManager.AddToPortfolioData, + ): Boolean { + return if (selectedWalletId != null) { + hasMissedDerivationsUseCase.invoke( + userWalletId = selectedWalletId, + networksWithDerivationPath = addToPortfolioData.addedNetworks[selectedWalletId].orEmpty() + .associate { Network.ID(it.networkId) to null }, + ) + } else { + false + } + } + + private fun onNetworkSwitchClick(blockchainRowUM: BlockchainRowUM, isChecked: Boolean) { + val selectedWalletId = selectedMultiWalletIdFlow.value + + if (selectedWalletId == null) { + Timber.e("Impossible to switch network when selected wallet is null") + return + } + + if (isChecked) { + modelScope.launch { + val unsupportedState = checkCurrencyUnsupportedState( + userWalletId = selectedWalletId, + rawNetworkId = blockchainRowUM.id, + isMainNetwork = blockchainRowUM.isMainNetwork, + ) + if (unsupportedState != null) { + showUnsupportedWarning(unsupportedState) + } else { + addToPortfolioManager.addNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id) + } + } + } else { + addToPortfolioManager.removeNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id) + } + } + + private suspend fun checkCurrencyUnsupportedState( + userWalletId: UserWalletId, + rawNetworkId: String, + isMainNetwork: Boolean, + ): CurrencyUnsupportedState? { + return checkCurrencyUnsupportedUseCase( + userWalletId = userWalletId, + networkId = rawNetworkId, + isMainNetwork = isMainNetwork, + ).getOrElse { + Timber.e( + it, + """ + Failed to check currency unsupported state + |- User wallet ID: $userWalletId + |- Network ID: $rawNetworkId + |- Is main network: $isMainNetwork + """.trimIndent(), + ) + + val message = SnackbarMessage( + message = it.localizedMessage + ?.let(::stringReference) + ?: resourceReference(R.string.common_error), + ) + messageSender.send(message) + + null + } + } + + private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) { + val message = ContentMessage { onDismiss -> + WarningDialog( + message = when (unsupportedState) { + is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + }, + onDismiss = onDismiss, + ) + } + + messageSender.send(message) + } + + private fun onWalletSelect(userWalletId: UserWalletId) { + selectedMultiWalletIdFlow.update { prevUserWalletId -> + prevUserWalletId?.let(addToPortfolioManager::removeAllChanges) + + userWalletId + } + } + + private fun onContinueClick(userWalletId: UserWalletId, addedNetworks: Set) { + modelScope.launch { + saveMarketTokensUseCase( + userWalletId = userWalletId, + tokenMarketParams = params.token, + addedNetworks = addedNetworks, + removedNetworks = emptySet(), + ) + + onAddToPortfolioBSVisibilityChange(isShow = false) + + addToPortfolioManager.removeAllChanges(userWalletId) + } + } + + private fun onAddToPortfolioBSVisibilityChange(isShow: Boolean) { + portfolioBSVisibilityModelFlow.update { + it.copy(addToPortfolioBSVisibility = isShow, walletSelectorBSVisibility = false) + } + } + + private fun onWalletSelectorVisibilityChange(isShow: Boolean) { + portfolioBSVisibilityModelFlow.update { + it.copy(addToPortfolioBSVisibility = true, walletSelectorBSVisibility = isShow) + } + } + + private fun updateTokensState(block: (MyPortfolioUM.Tokens) -> MyPortfolioUM) { + _state.update { stateToUpdate -> + val tokensState = stateToUpdate as? MyPortfolioUM.Tokens ?: return@update stateToUpdate + block(tokensState) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt new file mode 100644 index 0000000000..83af1ca3c2 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt @@ -0,0 +1,135 @@ +package com.tangem.features.markets.portfolio.impl.model + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.markets.portfolio.impl.loader.PortfolioData +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState +import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.utils.Provider +import kotlinx.collections.immutable.ImmutableList + +/** + * Factory for creating [MyPortfolioUM] + * + * @property onAddClick callback when user wants to add new token + * @property onTokenItemClick callback when user wants to see actions with token + * +[REDACTED_AUTHOR] + */ +internal class MyPortfolioUMFactory( + private val onAddClick: () -> Unit, + private val addToPortfolioBSContentUMFactory: AddToPortfolioBSContentUMFactory, + private val tokenActionsHandler: TokenActionsHandler, + private val currentState: Provider, + private val updateTokens: ((ImmutableList) -> ImmutableList) -> Unit, +) { + + fun create(portfolioData: PortfolioData, portfolioUIData: PortfolioUIData): MyPortfolioUM { + val addToPortfolioData = portfolioUIData.addToPortfolioData + + val hasAvailableNetworks = addToPortfolioData.availableNetworks?.isEmpty() == true + val isOnlySingleWalletsAdded = portfolioData.walletsWithCurrencies.keys.all { !it.isMultiCurrency } + if (hasAvailableNetworks || isOnlySingleWalletsAdded) return MyPortfolioUM.Unavailable + + val walletsWithCurrencies = if (addToPortfolioData.availableNetworks == null) { + portfolioData.walletsWithCurrencies + } else { + portfolioData.walletsWithCurrencies.filterAvailableNetworks(networks = addToPortfolioData.availableNetworks) + } + + val isPortfolioEmpty = walletsWithCurrencies.flatMap { it.value }.isEmpty() + if (isPortfolioEmpty) { + val hasMultiWallets = walletsWithCurrencies.filterKeys(UserWallet::isMultiCurrency).isNotEmpty() + + return if (hasMultiWallets) { + MyPortfolioUM.AddFirstToken( + addToPortfolioBSConfig = createAddToPortfolioBSConfig( + portfolioData = portfolioData, + portfolioUIData = portfolioUIData, + ), + onAddClick = onAddClick, + ) + } else { + MyPortfolioUM.Unavailable + } + } + + return TokensPortfolioUMConverter( + appCurrency = portfolioData.appCurrency, + isBalanceHidden = portfolioData.isBalanceHidden, + addButtonState = walletsWithCurrencies.getAddButtonState( + availableNetworks = addToPortfolioData.availableNetworks, + ), + bsConfig = createAddToPortfolioBSConfig(portfolioData = portfolioData, portfolioUIData = portfolioUIData), + onAddClick = onAddClick, + quickActionsIntents = tokenActionsHandler, + currentState = currentState, + updateTokens = updateTokens, + ) + .convert(walletsWithCurrencies) + } + + private fun createAddToPortfolioBSConfig( + portfolioData: PortfolioData, + portfolioUIData: PortfolioUIData, + ): TangemBottomSheetConfig { + val selectedWallet = portfolioData.walletsWithCurrencies.keys + .firstOrNull { it.walletId == portfolioUIData.selectedWalletId } + ?: portfolioData.walletsWithCurrencies.keys.firstOrNull { it.isMultiCurrency } + ?: error("walletsWithCurrencies don't contain selected wallet or any multi-currency wallet") + + val availableNetworks = portfolioUIData.addToPortfolioData.availableNetworks.orEmpty() + + val alreadyAddedNetworks = requireNotNull( + value = portfolioData.walletsWithCurrencies.filterAvailableNetworks(availableNetworks)[selectedWallet], + lazyMessage = { "walletsWithCurrencies don't contain ${selectedWallet.walletId}" }, + ) + .map { it.status.currency.network.backendId } + .toSet() + + return addToPortfolioBSContentUMFactory.create( + portfolioData = portfolioData, + portfolioUIData = portfolioUIData, + selectedWallet = selectedWallet, + alreadyAddedNetworks = alreadyAddedNetworks, + ) + } + + private fun Map>.getAddButtonState( + availableNetworks: Set?, + ): AddButtonState { + if (availableNetworks == null) return AddButtonState.Loading + + val networkIds = availableNetworks.map { it.networkId } + + val isAllAvailableNetworksAdded = this + // User can add currencies only in multi-currency wallets + .filterKeys(UserWallet::isMultiCurrency) + .mapValues { entry -> entry.value.map { it.status.currency.network.backendId } } + // Each wallets contains all available networks? + .all { it.value.containsAll(networkIds) } + + return if (isAllAvailableNetworksAdded) AddButtonState.Unavailable else AddButtonState.Available + } + + /** Filter map values by available networks [networks] */ + private fun Map>.filterAvailableNetworks( + networks: Set, + ): Map> { + return mapValues { entry -> entry.value.filterAvailableNetworks(networks) } + } + + /** Filter list of [CryptoCurrencyStatus] by available networks [networks] */ + private fun List.filterAvailableNetworks( + networks: Set, + ): List { + val networkIds = networks.map(TokenMarketInfo.Network::networkId) + + return mapNotNull { + it.takeIf { networkIds.contains(it.status.currency.network.backendId) } + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioBSVisibilityModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioBSVisibilityModel.kt new file mode 100644 index 0000000000..b2d43ea69a --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioBSVisibilityModel.kt @@ -0,0 +1,14 @@ +package com.tangem.features.markets.portfolio.impl.model + +/** + * Model for portfolio bottom sheet visibility + * + * @property addToPortfolioBSVisibility visibility of add to portfolio bottom sheet + * @property walletSelectorBSVisibility visibility of wallet selector bottom sheet + * +[REDACTED_AUTHOR] + */ +internal data class PortfolioBSVisibilityModel( + val addToPortfolioBSVisibility: Boolean = false, + val walletSelectorBSVisibility: Boolean = false, +) \ 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 new file mode 100644 index 0000000000..1125fda4b1 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt @@ -0,0 +1,96 @@ +package com.tangem.features.markets.portfolio.impl.model + +import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.markets.portfolio.impl.loader.PortfolioData +import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.features.markets.portfolio.impl.ui.state.QuickActionUM +import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList + +/** + * Converter from [UserWallet] and [CryptoCurrencyStatus] to [PortfolioTokenUM] + * +[REDACTED_AUTHOR] + */ +internal class PortfolioTokenUMConverter( + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit, + private val tokenActionsHandler: TokenActionsHandler, +) : Converter { + + override fun convert(value: PortfolioData.CryptoCurrencyData): PortfolioTokenUM { + val tokenItemStateConverter = TokenItemStateConverter( + appCurrency = appCurrency, + titleStateProvider = { TokenItemState.TitleState.Content(text = value.userWallet.name) }, + subtitleStateProvider = { + TokenItemState.SubtitleState.TextContent(value = value.status.currency.name) + }, + onItemClick = onTokenItemClick, + ) + + return PortfolioTokenUM( + tokenItemState = tokenItemStateConverter.convert(value = value.status), + walletId = value.userWallet.walletId, + isBalanceHidden = isBalanceHidden, + isQuickActionsShown = false, + quickActions = quickActions(cryptoData = value), + ) + } + + private fun quickActions(cryptoData: PortfolioData.CryptoCurrencyData): PortfolioTokenUM.QuickActions { + return PortfolioTokenUM.QuickActions( + actions = listOfNotNull( + QuickActionUM.Buy.takeIf { + cryptoData.actions.any { + it is TokenActionsState.ActionState.Buy && + it.unavailabilityReason == ScenarioUnavailabilityReason.None + } + }, + QuickActionUM.Exchange.takeIf { + cryptoData.actions.any { + it is TokenActionsState.ActionState.Swap && + it.unavailabilityReason == ScenarioUnavailabilityReason.None + } + }, + QuickActionUM.Receive.takeIf { + cryptoData.actions.any { + it is TokenActionsState.ActionState.Receive && + it.unavailabilityReason == ScenarioUnavailabilityReason.None + } + }, + ).toImmutableList(), + onQuickActionClick = { + when (it) { + QuickActionUM.Buy -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Buy, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.Exchange -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Exchange, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.Receive -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Receive, + cryptoCurrencyData = cryptoData, + ) + } + }, + onQuickActionLongClick = { + if (it == QuickActionUM.Receive) { + tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.CopyAddress, + cryptoCurrencyData = cryptoData, + ) + } + }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt new file mode 100644 index 0000000000..e9c972b068 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt @@ -0,0 +1,20 @@ +package com.tangem.features.markets.portfolio.impl.model + +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Portfolio UI data. Combined data from all UI flows that required to setup portfolio + * + * @property portfolioBSVisibilityModel portfolio bottom sheet visibility model + * @property selectedWalletId selected wallet id + * @property addToPortfolioData add to portfolio data + * @property hasMissedDerivations flag that indicates if user has missed derivations + * +[REDACTED_AUTHOR] + */ +internal data class PortfolioUIData( + val portfolioBSVisibilityModel: PortfolioBSVisibilityModel, + val selectedWalletId: UserWalletId?, + val addToPortfolioData: AddToPortfolioManager.AddToPortfolioData, + val hasMissedDerivations: Boolean, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/SelectNetworkUMConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/SelectNetworkUMConverter.kt new file mode 100644 index 0000000000..05b33043d7 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/SelectNetworkUMConverter.kt @@ -0,0 +1,37 @@ +package com.tangem.features.markets.portfolio.impl.model + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.markets.portfolio.impl.ui.state.SelectNetworkUM +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList + +/** + * Converter from [TokenMarketParams] to [SelectNetworkUM] + * + * @property networksWithToggle map of networks with toggles + * @property alreadyAddedNetworks already added networks + * @property onNetworkSwitchClick callback is called when network switch is clicked + * +[REDACTED_AUTHOR] + */ +internal class SelectNetworkUMConverter( + private val networksWithToggle: Map, + private val alreadyAddedNetworks: Set, + private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit, +) : Converter { + + override fun convert(value: TokenMarketParams): SelectNetworkUM { + return SelectNetworkUM( + tokenId = value.id, + iconUrl = value.imageUrl, + tokenName = value.name, + tokenCurrencySymbol = value.symbol, + networks = BlockchainRowUMConverter(alreadyAddedNetworks) + .convertList(networksWithToggle.toList()) + .toImmutableList(), + onNetworkSwitchClick = { um, isChecked -> onNetworkSwitchClick(um, isChecked) }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt new file mode 100644 index 0000000000..fc2464a7f9 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt @@ -0,0 +1,194 @@ +package com.tangem.features.markets.portfolio.impl.model + +import com.tangem.common.routing.AppRoute +import com.tangem.core.decompose.di.ComponentScoped +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.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.tokenreceive.mapToAddressModels +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.ContentMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.redux.ReduxStateHolder +import com.tangem.domain.tokens.legacy.TradeCryptoAction +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.markets.impl.R +import com.tangem.features.markets.portfolio.impl.loader.PortfolioData +import com.tangem.features.markets.portfolio.impl.ui.WarningDialog +import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.utils.Provider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.toImmutableList + +@Suppress("LongParameterList") +@ComponentScoped +internal class TokenActionsHandler @AssistedInject constructor( + private val router: Router, + private val clipboardManager: ClipboardManager, + private val uiMessageSender: UiMessageSender, + private val reduxStateHolder: ReduxStateHolder, + @Assisted private val currentAppCurrency: Provider, + @Assisted private val updateTokenReceiveBSConfig: ((TangemBottomSheetConfig) -> TangemBottomSheetConfig) -> Unit, + @Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit, + private val isDemoCardUseCase: IsDemoCardUseCase, + private val messageSender: UiMessageSender, +) { + + private val disabledActionsInDemoMode = setOf( + TokenActionsBSContentUM.Action.Buy, + TokenActionsBSContentUM.Action.Sell, + ) + + fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + onHandleQuickAction( + HandledQuickAction( + action = action, + cryptoCurrencyData = cryptoCurrencyData, + ), + ) + if (handleDemoMode(action, cryptoCurrencyData.userWallet)) return + + when (action) { + TokenActionsBSContentUM.Action.Buy -> onBuyClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Exchange -> onExchangeClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Receive -> onReceiveClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.CopyAddress -> onCopyAddress(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Sell -> onSellClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Send -> onSendClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Stake -> onStakeClick(cryptoCurrencyData) + } + } + + private fun handleDemoMode(action: TokenActionsBSContentUM.Action, userWallet: UserWallet): Boolean { + val demoCard = isDemoCardUseCase.invoke(userWallet.cardId) + val needShowDemoWarning = demoCard && disabledActionsInDemoMode.contains(action) + + if (needShowDemoWarning) { + showDemoModeWarning() + } + + return needShowDemoWarning + } + + private fun showDemoModeWarning() { + val message = ContentMessage { onDismiss -> + WarningDialog( + message = resourceReference(R.string.alert_demo_feature_disabled), + onDismiss = onDismiss, + ) + } + + messageSender.send(message) + } + + private fun onReceiveClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val cryptoCurrencyStatus = cryptoCurrencyData.status + val currency = cryptoCurrencyStatus.currency + val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return + + updateTokenReceiveBSConfig { + TangemBottomSheetConfig( + isShow = true, + onDismissRequest = { + updateTokenReceiveBSConfig { + it.copy(isShow = false) + } + }, + content = TokenReceiveBottomSheetConfig( + name = currency.name, + symbol = currency.symbol, + network = currency.network.name, + addresses = networkAddress.availableAddresses + .mapToAddressModels(currency) + .toImmutableList(), + onCopyClick = {}, + onShareClick = {}, + ), + ) + } + } + + private fun onCopyAddress(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val cryptoCurrencyStatus = cryptoCurrencyData.status + val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return + val addresses = networkAddress.availableAddresses + .mapToAddressModels(cryptoCurrencyStatus.currency) + .toImmutableList() + val defaultAddress = addresses.firstOrNull()?.value ?: return + + clipboardManager.setText(text = defaultAddress) + uiMessageSender.send(SnackbarMessage(resourceReference(R.string.wallet_notification_address_copied))) + } + + private fun onBuyClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + reduxStateHolder.dispatch( + TradeCryptoAction.Buy( + userWallet = cryptoCurrencyData.userWallet, + cryptoCurrencyStatus = cryptoCurrencyData.status, + appCurrencyCode = currentAppCurrency().code, + ), + ) + } + + private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + reduxStateHolder.dispatch( + TradeCryptoAction.Sell( + cryptoCurrencyStatus = cryptoCurrencyData.status, + appCurrencyCode = currentAppCurrency().code, + ), + ) + } + + private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + router.push( + AppRoute.Swap( + currency = cryptoCurrencyData.status.currency, + userWalletId = cryptoCurrencyData.userWallet.walletId, + ), + ) + } + + private fun onSendClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + router.push( + AppRoute.Send( + userWalletId = cryptoCurrencyData.userWallet.walletId, + currency = cryptoCurrencyData.status.currency, + ), + ) + } + + private fun onStakeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val yield = cryptoCurrencyData.actions.firstOrNull { it is TokenActionsState.ActionState.Stake } + ?.let { it as TokenActionsState.ActionState.Stake } + ?.yield ?: return + + router.push( + AppRoute.Staking( + userWalletId = cryptoCurrencyData.userWallet.walletId, + cryptoCurrencyId = cryptoCurrencyData.status.currency.id, + yield = yield, + ), + ) + } + + @AssistedFactory + interface Factory { + fun create( + currentAppCurrency: Provider, + updateTokenReceiveBSConfig: ((TangemBottomSheetConfig) -> TangemBottomSheetConfig) -> Unit, + onHandleQuickAction: (HandledQuickAction) -> Unit, + ): TokenActionsHandler + } + + data class HandledQuickAction( + val action: TokenActionsBSContentUM.Action, + val cryptoCurrencyData: PortfolioData.CryptoCurrencyData, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokensPortfolioUMConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokensPortfolioUMConverter.kt new file mode 100644 index 0000000000..5078f51123 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokensPortfolioUMConverter.kt @@ -0,0 +1,116 @@ +package com.tangem.features.markets.portfolio.impl.model + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.markets.portfolio.impl.loader.PortfolioData +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState +import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * Converter from [Map] of [UserWallet] and [CryptoCurrencyStatus] to [MyPortfolioUM.Tokens] + * +[REDACTED_AUTHOR] + */ +@Suppress("LongParameterList") +internal class TokensPortfolioUMConverter( + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val addButtonState: AddButtonState, + private val bsConfig: TangemBottomSheetConfig, + private val onAddClick: () -> Unit, + private val quickActionsIntents: TokenActionsHandler, + private val currentState: Provider, + private val updateTokens: ((ImmutableList) -> ImmutableList) -> Unit, +) : Converter>, MyPortfolioUM.Tokens> { + + override fun convert(value: Map>): MyPortfolioUM.Tokens { + val currentTokensState = currentState() as? MyPortfolioUM.Tokens + + return MyPortfolioUM.Tokens( + tokens = value + .flatMap { entry -> entry.value } + .map { cryptoData -> + PortfolioTokenUMConverter( + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + onTokenItemClick = { toggleQuickActions(cryptoData) }, + tokenActionsHandler = quickActionsIntents, + ).convert(value = cryptoData) to cryptoData + } + .setQuickActionsVisibility(currentState = currentTokensState) + .toImmutableList(), + buttonState = addButtonState, + addToPortfolioBSConfig = bsConfig, + onAddClick = onAddClick, + tokenReceiveBSConfig = (currentState() as? MyPortfolioUM.Tokens) + ?.tokenReceiveBSConfig + ?: TangemBottomSheetConfig.Empty, + ) + } + + private fun List>.setQuickActionsVisibility( + currentState: MyPortfolioUM.Tokens?, + ): List { + return when { + // if there is only one token and it has empty balance, show quick actions for it + currentState == null && this.size == 1 && isEmptyBalance(this.first().second) -> { + this.map { (token, _) -> + token.copy(isQuickActionsShown = true) + } + } + // if there is no previous state, hide quick actions for all tokens + currentState == null -> { + this.map { (token, _) -> + token.copy(isQuickActionsShown = false) + } + } + else -> { + val previousList = currentState.tokens + + // otherwise, keep previous state + this.map { (token, _) -> + token.copy( + isQuickActionsShown = previousList + .firstOrNull { it.matchWith(token) } + ?.isQuickActionsShown ?: false, + ) + } + } + } + } + + private fun isEmptyBalance(cryptoData: PortfolioData.CryptoCurrencyData): Boolean { + return cryptoData.status.value.amount?.isZero() ?: false + } + + private fun toggleQuickActions(cryptoData: PortfolioData.CryptoCurrencyData) { + updateTokens { tokenList -> + tokenList.map { + it.copy( + isQuickActionsShown = if (it.matchWith(cryptoData)) { + !it.isQuickActionsShown + } else { + false + }, + ) + }.toImmutableList() + } + } + + private fun PortfolioTokenUM.matchWith(token: PortfolioTokenUM): Boolean { + return this.walletId == token.walletId && this.tokenItemState.id == token.tokenItemState.id + } + + private fun PortfolioTokenUM.matchWith(cryptoData: PortfolioData.CryptoCurrencyData): Boolean { + return this.walletId == cryptoData.userWallet.walletId && + this.tokenItemState.id == cryptoData.status.currency.id.value + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt new file mode 100644 index 0000000000..b73ba99609 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt @@ -0,0 +1,378 @@ +package com.tangem.features.markets.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.common.ui.userwallet.UserWalletItem +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.buttons.common.TangemButton +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.buttons.common.TangemButtonSize +import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults +import com.tangem.core.ui.components.currency.icon.CoinIcon +import com.tangem.core.ui.components.rows.ArrowRow +import com.tangem.core.ui.components.rows.BlockchainRow +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.markets.impl.R +import com.tangem.features.markets.portfolio.impl.ui.preview.PreviewAddToPortfolioBSContentProvider +import com.tangem.features.markets.portfolio.impl.ui.state.AddToPortfolioBSContentUM +import com.tangem.features.markets.portfolio.impl.ui.state.SelectNetworkUM +import kotlinx.coroutines.delay + +@Composable +internal fun AddToPortfolioBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + addBottomInsets = false, + titleText = resourceReference(R.string.markets_add_to_portfolio_button), + ) { + Content( + modifier = Modifier.fillMaxWidth(), + state = it, + ) + + WalletSelectorBottomSheet(it.walletSelectorConfig) + } +} + +@Composable +private fun Content(state: AddToPortfolioBSContentUM, modifier: Modifier = Modifier) { + var continueButtonAreaHeight by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + val scrollState = rememberScrollState() + + Box(modifier = modifier) { + Column( + modifier = Modifier + .verticalScroll(state = scrollState) + .padding(horizontal = TangemTheme.dimens.spacing16), + ) { + if (state.isWalletBlockVisible) { + UserWalletItem( + state = state.selectedWallet, + blockColors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + disabledContainerColor = TangemTheme.colors.background.action, + ), + ) + SpacerH12() + } + + NetworkSelection( + modifier = Modifier.fillMaxWidth(), + state = state.selectNetworkUM, + ) + + SpacerH12() + + AnimatedVisibility( + visible = state.isScanCardNotificationVisible, + modifier = Modifier.fillMaxWidth(), + ) { + Column { + ScanWalletWarning(modifier = Modifier.fillMaxWidth()) + SpacerH12() + } + + // Scroll to the bottom when the notification appears and the scroll is at the bottom + LaunchedEffect(Unit) { + if (scrollState.canScrollForward.not()) { + delay(timeMillis = 500) + scrollState.animateScrollTo(scrollState.maxValue) + } + } + } + + SpacerH(with(density) { continueButtonAreaHeight.toDp() }) + } + + AnimatedVisibility( + visible = scrollState.canScrollForward, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + BottomFade(Modifier.align(Alignment.BottomCenter)) + } + + ContinueButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .onGloballyPositioned { + continueButtonAreaHeight = it.size.height + }, + enabled = state.continueButtonEnabled, + isTangemIconVisible = state.isScanCardNotificationVisible, + onClick = state.onContinueButtonClick, + ) + } +} + +@Composable +private fun ContinueButton( + enabled: Boolean, + isTangemIconVisible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TangemButton( + enabled = enabled, + modifier = modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ) + .navigationBarsPadding() + .fillMaxWidth(), + text = stringResource(R.string.common_continue), + icon = if (enabled && isTangemIconVisible) { + TangemButtonIconPosition.End(R.drawable.ic_tangem_24) + } else { + TangemButtonIconPosition.None + }, + showProgress = false, + size = TangemButtonSize.Default, + colors = TangemButtonsDefaults.primaryButtonColors, + onClick = onClick, + animateContentChange = true, + ) +} + +@Suppress("LongMethod") +@Composable +private fun NetworkSelection(state: SelectNetworkUM, modifier: Modifier = Modifier) { + val hapticManager = LocalHapticManager.current + + InformationBlock( + modifier = modifier, + title = { + Text( + text = stringResource(R.string.markets_select_network), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + }, + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens.spacing14), + verticalAlignment = Alignment.CenterVertically, + ) { + CoinIcon( + modifier = Modifier.size(TangemTheme.dimens.size36), + url = state.iconUrl, + alpha = 1f, + colorFilter = null, + fallbackResId = R.drawable.ic_custom_token_44, + ) + SpacerW12() + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .alignByBaseline(), + text = state.tokenName, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + SpacerW6() + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .alignByBaseline(), + text = state.tokenCurrencySymbol, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.tertiary, + ) + } + + state.networks.fastForEachIndexed { index, network -> + ArrowRow( + isLastItem = index == state.networks.lastIndex, + content = { + BlockchainRow( + modifier = Modifier.padding( + end = TangemTheme.dimens.spacing4, + ), + model = network, + action = { + TangemSwitch( + checked = network.isSelected, + checkedColor = if (network.isEnabled) { + TangemTheme.colors.control.checked + } else { + TangemTheme.colors.icon.inactive + }, + onCheckedChange = { checked -> + if (checked) { + hapticManager.perform(TangemHapticEffect.View.ToggleOn) + } else { + hapticManager.perform(TangemHapticEffect.View.ToggleOff) + } + + state.onNetworkSwitchClick(network, checked) + }, + enabled = network.isEnabled, + ) + }, + ) + }, + ) + } + } + } +} + +@Composable +private fun ScanWalletWarning(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background( + color = TangemTheme.colors.button.disabled, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .padding(TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing10), + ) { + Icon( + modifier = Modifier.requiredSize(TangemTheme.dimens.size20), + imageVector = ImageVector.vectorResource(R.drawable.ic_tangem_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + Text( + text = stringResource(R.string.markets_generate_addresses_notification), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview { + AddToPortfolioBottomSheet( + config = TangemBottomSheetConfig( + isShow = true, + content = content, + onDismissRequest = {}, + ), + ) + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PreviewContent( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview { + Content( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .fillMaxWidth(), + state = content, + ) + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PreviewContentRtl( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview(rtl = true) { + Content( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .fillMaxWidth(), + state = content, + ) + } +} + +// For on device testing +@Composable +@Preview +private fun PreviewContentTestOnDevice( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview( + alwaysShowBottomSheets = false, + ) { + var isShow by remember { mutableStateOf(false) } + + var contentState by remember { + mutableStateOf(content) + } + + LaunchedEffect(Unit) { + contentState = content.copy( + onContinueButtonClick = { + contentState = contentState.copy( + isScanCardNotificationVisible = !contentState.isScanCardNotificationVisible, + ) + }, + continueButtonEnabled = true, + selectedWallet = content.selectedWallet.copy( + onClick = { + contentState = contentState.copy( + continueButtonEnabled = !contentState.continueButtonEnabled, + ) + }, + ), + ) + } + + AddToPortfolioBottomSheet( + config = TangemBottomSheetConfig( + isShow = isShow, + content = contentState, + onDismissRequest = { isShow = false }, + ), + ) + + Button( + onClick = { isShow = !isShow }, + ) { + Text(text = "Toggle") + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt new file mode 100644 index 0000000000..58247416e1 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt @@ -0,0 +1,187 @@ +package com.tangem.features.markets.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SmallButtonShimmer +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheet +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.extensions.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.impl.ui.preview.PreviewMyPortfolioUMProvider +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState + +@Composable +internal fun MyPortfolio(state: MyPortfolioUM, modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier, + contentHorizontalPadding = TangemTheme.dimens.spacing0, + title = { Title() }, + action = { + if (state !is MyPortfolioUM.Tokens) return@InformationBlock + + AddButton(state = state.buttonState, onClick = state.onAddClick) + }, + ) { + val contentModifier = Modifier.padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing12, + ) + + when (state) { + is MyPortfolioUM.Tokens -> TokenList(state = state) + is MyPortfolioUM.AddFirstToken -> AddFirstTokenContent(state = state, modifier = contentModifier) + MyPortfolioUM.Loading -> LoadingPlaceholder(modifier = contentModifier) + MyPortfolioUM.Unavailable -> UnavailableContent(modifier = contentModifier) + } + } + + when (state) { + is MyPortfolioUM.AddFirstToken -> AddToPortfolioBottomSheet(config = state.addToPortfolioBSConfig) + is MyPortfolioUM.Tokens -> AddToPortfolioBottomSheet(config = state.addToPortfolioBSConfig) + else -> Unit + } +} + +@Composable +private fun Title() { + Text( + text = stringResource(R.string.markets_common_my_portfolio), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Composable +private fun AddButton(state: AddButtonState, onClick: () -> Unit) { + when (state) { + AddButtonState.Loading -> { + SmallButtonShimmer( + modifier = Modifier.size(width = TangemTheme.dimens.size63, height = TangemTheme.dimens.size18), + shape = RoundedCornerShape(TangemTheme.dimens.radius3), + ) + } + AddButtonState.Available, + AddButtonState.Unavailable, + -> { + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(R.string.markets_add_token), + icon = TangemButtonIconPosition.Start(R.drawable.ic_plus_24), + onClick = onClick, + enabled = state == AddButtonState.Available, + ), + ) + } + } +} + +@Composable +private fun TokenList(state: MyPortfolioUM.Tokens, modifier: Modifier = Modifier) { + Column(modifier) { + state.tokens.fastForEachIndexed { index, token -> + key(token.tokenItemState.id) { + PortfolioItem( + state = token, + lastInList = index == state.tokens.size - 1, + ) + } + } + } + + TokenReceiveBottomSheet(config = state.tokenReceiveBSConfig) +} + +@Composable +private fun UnavailableContent(modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = stringResource(R.string.markets_add_to_my_portfolio_unavailable_description), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Composable +private fun AddFirstTokenContent(state: MyPortfolioUM.AddFirstToken, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Text( + text = "To start buying, exchanging or receiving this asset, add this token to at least 1 network", // FIXME + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.markets_add_to_portfolio_button), + onClick = state.onAddClick, + ) + } +} + +@Composable +private fun LoadingPlaceholder(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + + TextShimmer( + modifier = Modifier.fillMaxWidth(fraction = 0.7f), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state: MyPortfolioUM) { + TangemThemePreview { + Box( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .padding(TangemTheme.dimens.spacing8), + ) { + MyPortfolio(state) + } + } +} + +@Preview +@Composable +private fun PreviewRtl(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state: MyPortfolioUM) { + TangemThemePreview(rtl = true) { + Box( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .padding(TangemTheme.dimens.spacing8), + ) { + MyPortfolio(state) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt new file mode 100644 index 0000000000..3ed29f0521 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt @@ -0,0 +1,143 @@ +package com.tangem.features.markets.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.markets.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider +import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.utils.StringsSigns.DASH_SIGN +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState as TokenFiatAmountState + +@Composable +internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifier: Modifier = Modifier) { + Column(modifier) { + val hapticManager = LocalHapticManager.current + val tokenItemState = remember(state.tokenItemState) { + when (state.tokenItemState) { + is TokenItemState.Content -> state.tokenItemState.copy( + onItemClick = { + val onClick = state.tokenItemState.onItemClick + if (onClick != null) { + hapticManager.perform(TangemHapticEffect.View.ContextClick) + onClick.invoke() + } + }, + ) + else -> state.tokenItemState + } + } + TokenItem( + state = tokenItemState, + isBalanceHidden = state.isBalanceHidden, + modifier = Modifier.background(color = TangemTheme.colors.background.action), + itemPaddingValues = PaddingValues( + start = TangemTheme.dimens.spacing10, + end = TangemTheme.dimens.spacing12, + ), + ) + + PortfolioQuickActions( + modifier = Modifier + .background(color = TangemTheme.colors.background.action) + .padding( + bottom = if (lastInList) { + TangemTheme.dimens.spacing12 + } else { + TangemTheme.dimens.spacing24 + }, + ), + actions = state.quickActions.actions, + isVisible = state.isQuickActionsShown, + onActionClick = state.quickActions.onQuickActionClick, + onActionLongClick = state.quickActions.onQuickActionLongClick, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(PortfolioTokenUMProvider::class) tokenUM: PortfolioTokenUM) { + TangemThemePreview { + var quickActionsShown by remember { mutableStateOf(value = false) } + + val onItemClick = { + quickActionsShown = quickActionsShown.not() + } + + PortfolioItem( + state = tokenUM.copy( + tokenItemState = when (tokenUM.tokenItemState) { + is TokenItemState.Content -> tokenUM.tokenItemState.copy(onItemClick = onItemClick) + is TokenItemState.Unreachable -> tokenUM.tokenItemState.copy(onItemClick = onItemClick) + else -> tokenUM.tokenItemState + }, + isQuickActionsShown = quickActionsShown, + ), + lastInList = true, + ) + } +} + +private class PortfolioTokenUMProvider : CollectionPreviewParameterProvider( + collection = listOf( + tokenUM.copy( + tokenItemState = (tokenUM.tokenItemState as TokenItemState.Content).copy( + fiatAmountState = contentFiatAmount.copy(hasStaked = true), + ), + ), + tokenUM.copy( + tokenItemState = tokenUM.tokenItemState.copy( + fiatAmountState = contentFiatAmount.copy(text = DASH_SIGN), + cryptoAmountState = tokenUM.tokenItemState.cryptoAmountState.copy(text = DASH_SIGN), + ), + ), + tokenUM.copy(isBalanceHidden = true), + tokenUM.copy( + tokenItemState = TokenItemState.Unreachable( + id = tokenUM.tokenItemState.id, + iconState = tokenUM.tokenItemState.iconState, + titleState = tokenUM.tokenItemState.titleState, + subtitleState = tokenUM.tokenItemState.subtitleState, + onItemClick = {}, + onItemLongClick = {}, + ), + ), + tokenUM.copy( + tokenItemState = TokenItemState.NoAddress( + id = tokenUM.tokenItemState.id, + iconState = tokenUM.tokenItemState.iconState, + titleState = tokenUM.tokenItemState.titleState, + subtitleState = tokenUM.tokenItemState.subtitleState, + onItemLongClick = {}, + ), + ), + tokenUM.copy( + tokenItemState = TokenItemState.Loading( + id = tokenUM.tokenItemState.id, + iconState = tokenUM.tokenItemState.iconState, + titleState = tokenUM.tokenItemState.titleState as TokenItemState.TitleState.Content, + subtitleState = tokenUM.tokenItemState.subtitleState, + ), + ), + ), +) { + + companion object { + val tokenUM = PreviewMyPortfolioUMProvider().sampleToken + val contentFiatAmount = tokenUM.tokenItemState.fiatAmountState as TokenFiatAmountState.Content + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioQuickActions.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioQuickActions.kt new file mode 100644 index 0000000000..ba5ff99531 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioQuickActions.kt @@ -0,0 +1,224 @@ +package com.tangem.features.markets.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.animation.* +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.SpacerH4 +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.markets.portfolio.impl.ui.state.QuickActionUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun PortfolioQuickActions( + actions: ImmutableList, + isVisible: Boolean, + onActionClick: (QuickActionUM) -> Unit, + onActionLongClick: (QuickActionUM) -> Unit, + modifier: Modifier = Modifier, +) { + if (actions.isEmpty()) return + + AnimatedVisibility( + visible = isVisible, + enter = expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top), + ) { + Column(modifier = modifier) { + actions.fastForEach { action -> + LineSeparator() + QuickActionItem( + state = action, + onClick = { onActionClick(action) }, + onLongClick = { onActionLongClick(action) }.takeIf { action.longClickAvailable }, + ) + } + } + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun AnimatedVisibilityScope.LineSeparator(modifier: Modifier = Modifier) { + val lineColor = TangemTheme.colors.stroke.primary + val strokeWidth = TangemTheme.dimens.size1 + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val startPadding = TangemTheme.dimens.spacing30 + + val height = TangemTheme.dimens.size16 + + Canvas( + modifier = modifier + .animateEnterExit( + enter = expandVertically( + animationSpec = spring( + stiffness = Spring.StiffnessLow, + ), + expandFrom = Alignment.Top, + ) + fadeIn(), + exit = shrinkVertically( + spring( + stiffness = Spring.StiffnessLow, + ), + shrinkTowards = Alignment.Top, + ) + fadeOut(), + ) + .fillMaxWidth() + .height(height), + ) { + val x = if (isLtr) startPadding.toPx() else size.width - startPadding.toPx() + + drawLine( + color = lineColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = strokeWidth.toPx(), + ) + } +} + +@OptIn(ExperimentalAnimationApi::class, ExperimentalFoundationApi::class) +@Composable +private fun AnimatedVisibilityScope.QuickActionItem( + state: QuickActionUM, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val hapticManager = LocalHapticManager.current + val onLongClickInternal: (() -> Unit)? = if (onLongClick != null) { + { + hapticManager.perform(TangemHapticEffect.View.LongPress) + onLongClick() + } + } else { + null + } + + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onLongClick = onLongClickInternal, + onClick = { + hapticManager.perform(TangemHapticEffect.View.SegmentTick) + onClick() + }, + ) + .padding(horizontal = TangemTheme.dimens.spacing14, vertical = TangemTheme.dimens.spacing4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing18), + ) { + Box( + Modifier + .animateEnterExit( + enter = scaleIn(), + exit = scaleOut(), + ) + .background( + color = TangemTheme.colors.button.secondary, + shape = CircleShape, + ) + .size(TangemTheme.dimens.size32), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier + .requiredSize(TangemTheme.dimens.size16), + imageVector = ImageVector.vectorResource(id = state.icon), + contentDescription = null, + tint = TangemTheme.colors.button.primary, + ) + } + Column( + modifier = Modifier + .animateEnterExit( + enter = fadeIn(), + exit = fadeOut(), + ), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2), + ) { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = state.description.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + var isVisible by remember { mutableStateOf(true) } + + Column( + modifier = Modifier + .fillMaxWidth() + .height(680.dp), + ) { + Button( + onClick = { isVisible = !isVisible }, + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + ) { + Text(text = "Toggle") + } + SpacerH4() + Box( + modifier = Modifier.background(color = TangemTheme.colors.background.action), + ) { + PortfolioQuickActions( + actions = QuickActionUM.entries.toImmutableList(), + isVisible = isVisible, + onActionClick = {}, + onActionLongClick = {}, + ) + } + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewRtl() { + TangemThemePreview(rtl = true) { + Box(modifier = Modifier.background(color = TangemTheme.colors.background.action)) { + PortfolioQuickActions( + actions = QuickActionUM.entries.toImmutableList(), + isVisible = true, + onActionClick = {}, + onActionLongClick = {}, + ) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/TokenActionsBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/TokenActionsBottomSheet.kt new file mode 100644 index 0000000000..7dc8517cd8 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/TokenActionsBottomSheet.kt @@ -0,0 +1,89 @@ +package com.tangem.features.markets.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.SimpleSettingsRow +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle +import com.tangem.core.ui.components.rows.CornersToRound +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun TokenActionsBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + title = { content -> + TangemBottomSheetTitle(content.title) + }, + containerColor = TangemTheme.colors.background.tertiary, + content = { Content(it) }, + ) +} + +@Composable +private fun Content(content: TokenActionsBSContentUM) { + Column( + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) { + content.actions.forEachIndexed { index, action -> + val cornersToRound = when (index) { + 0 -> CornersToRound.TOP_2 + content.actions.lastIndex -> CornersToRound.BOTTOM_2 + else -> CornersToRound.ZERO + } + + Box( + modifier = Modifier + .clip(cornersToRound.getShape()) + .background(TangemTheme.colors.background.action), + ) { + SimpleSettingsRow( + title = action.text.resolveReference(), + icon = action.iconRes, + redesign = true, + onItemsClick = { content.onActionClick(action) }, + ) + } + } + } +} + +@Preview(widthDp = 360, heightDp = 640) +@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview( + alwaysShowBottomSheets = true, + ) { + Box(Modifier.background(TangemTheme.colors.background.secondary)) { + TokenActionsBottomSheet( + TangemBottomSheetConfig( + isShow = true, + onDismissRequest = {}, + content = TokenActionsBSContentUM( + title = "Wallet 1", + actions = TokenActionsBSContentUM.Action.entries.toImmutableList(), + onActionClick = {}, + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/WalletSelectorBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/WalletSelectorBottomSheet.kt new file mode 100644 index 0000000000..22ba89a8f7 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/WalletSelectorBottomSheet.kt @@ -0,0 +1,140 @@ +package com.tangem.features.markets.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.common.ui.userwallet.UserWalletItem +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +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.impl.ui.preview.PreviewAddToPortfolioBSContentProvider +import com.tangem.features.markets.portfolio.impl.ui.state.WalletSelectorBSContentUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun WalletSelectorBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + addBottomInsets = false, + title = { content -> + TangemTopAppBar( + title = resourceReference(R.string.manage_tokens_wallet_selector_title), + titleAlignment = Alignment.CenterHorizontally, + startButton = TopAppBarButtonUM.Back(content.onBack), + height = TangemTopAppBarHeight.BOTTOM_SHEET, + ) + }, + ) { content -> + Content( + modifier = Modifier + .fillMaxSize() + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing8, + ), + state = content, + ) + } +} + +@Composable +private fun Content(state: WalletSelectorBSContentUM, modifier: Modifier = Modifier) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + + Column( + modifier = modifier + .verticalScroll(rememberScrollState()), + ) { + BlockCard( + modifier = Modifier.fillMaxSize(), + colors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + disabledContainerColor = TangemTheme.colors.background.action, + ), + ) { + state.userWallets.forEach { state -> + key(state.id) { + UserWalletItem( + modifier = Modifier.fillMaxWidth(), + blockColors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + disabledContainerColor = TangemTheme.colors.background.action, + ), + state = state, + ) + } + } + } + SpacerH(bottomBarHeight) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + WalletSelectorBottomSheet( + config = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = {}, + content = WalletSelectorBSContentUM( + userWallets = persistentListOf( + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.Checkmark, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + ), + onBack = {}, + ), + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewContent() { + TangemThemePreview { + Content( + state = WalletSelectorBSContentUM( + userWallets = persistentListOf( + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.Checkmark, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + ), + onBack = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/WarningDialog.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/WarningDialog.kt new file mode 100644 index 0000000000..70e52d864c --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/WarningDialog.kt @@ -0,0 +1,27 @@ +package com.tangem.features.markets.portfolio.impl.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.tangem.core.ui.components.BasicDialog +import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.markets.impl.R + +@Composable +internal fun WarningDialog( + message: TextReference, + onDismiss: () -> Unit, + title: TextReference = resourceReference(R.string.common_warning), +) { + BasicDialog( + title = title.resolveReference(), + message = message.resolveReference(), + confirmButton = DialogButtonUM( + title = stringResource(R.string.common_ok), + onClick = onDismiss, + ), + onDismissDialog = onDismiss, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt new file mode 100644 index 0000000000..3b0e811163 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt @@ -0,0 +1,86 @@ +package com.tangem.features.markets.portfolio.impl.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.markets.impl.R +import com.tangem.features.markets.portfolio.impl.ui.state.AddToPortfolioBSContentUM +import com.tangem.features.markets.portfolio.impl.ui.state.SelectNetworkUM +import kotlinx.collections.immutable.persistentListOf + +internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider { + + private val blockchainRow = BlockchainRowUM( + id = "1", + name = "Etherium 3", + type = "TEST", + iconResId = R.drawable.ic_eth_16, + isMainNetwork = false, + isSelected = false, + ) + + val userWallet = UserWalletItemUM( + id = UserWalletId("1"), + name = stringReference("Wallet 1"), + information = stringReference("3 cards, 10,123$"), + imageUrl = "", + isEnabled = true, + endIcon = UserWalletItemUM.EndIcon.Arrow, + onClick = {}, + ) + + override val values: Sequence + get() = sequenceOf( + AddToPortfolioBSContentUM( + selectedWallet = userWallet, + selectNetworkUM = SelectNetworkUM( + tokenId = "etherium", + tokenName = "Etherium", + tokenCurrencySymbol = "ETH", + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + isSelected = true, + ), + blockchainRow, + blockchainRow, + ), + onNetworkSwitchClick = { _, _ -> }, + iconUrl = null, + ), + isScanCardNotificationVisible = true, + isWalletBlockVisible = true, + continueButtonEnabled = true, + onContinueButtonClick = {}, + walletSelectorConfig = TangemBottomSheetConfig.Empty, + ), + AddToPortfolioBSContentUM( + selectedWallet = userWallet, + selectNetworkUM = SelectNetworkUM( + tokenId = "etherium", + tokenName = "Etherium", + tokenCurrencySymbol = "ETH", + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + isSelected = true, + ), + *Array(25) { blockchainRow }, + ), + + onNetworkSwitchClick = { _, _ -> }, + iconUrl = null, + ), + isScanCardNotificationVisible = true, + isWalletBlockVisible = false, + continueButtonEnabled = false, + onContinueButtonClick = {}, + walletSelectorConfig = TangemBottomSheetConfig.Empty, + ), + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt new file mode 100644 index 0000000000..bdff4f722c --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt @@ -0,0 +1,67 @@ +package com.tangem.features.markets.portfolio.impl.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.features.markets.portfolio.impl.ui.state.QuickActionUM +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider { + + override val values: Sequence + get() = sequenceOf( + MyPortfolioUM.Tokens( + tokens = persistentListOf(sampleToken, sampleToken), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Available, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + tokenReceiveBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.Tokens( + tokens = persistentListOf(sampleToken, sampleToken.copy(isQuickActionsShown = true)), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Unavailable, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + tokenReceiveBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.Tokens( + tokens = persistentListOf(sampleToken.copy(isQuickActionsShown = true), sampleToken), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Loading, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + tokenReceiveBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.AddFirstToken( + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.Loading, + MyPortfolioUM.Unavailable, + ) + + val sampleToken = PortfolioTokenUM( + tokenItemState = TokenItemState.Content( + id = "", + iconState = CurrencyIconState.Locked, + titleState = TokenItemState.TitleState.Content(text = "My wallet"), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = "486,65 \$"), + cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "733,71097 MATIC"), + subtitleState = TokenItemState.SubtitleState.TextContent(value = "XRP Ledger token"), + onItemClick = {}, + onItemLongClick = {}, + ), + isQuickActionsShown = false, + quickActions = PortfolioTokenUM.QuickActions( + actions = QuickActionUM.entries.toImmutableList(), + onQuickActionClick = {}, + onQuickActionLongClick = {}, + ), + isBalanceHidden = false, + walletId = UserWalletId("walletId"), + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/AddToPortfolioBSContentUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/AddToPortfolioBSContentUM.kt new file mode 100644 index 0000000000..2978ab48ca --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/AddToPortfolioBSContentUM.kt @@ -0,0 +1,15 @@ +package com.tangem.features.markets.portfolio.impl.ui.state + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +internal data class AddToPortfolioBSContentUM( + val selectedWallet: UserWalletItemUM, + val selectNetworkUM: SelectNetworkUM, + val isWalletBlockVisible: Boolean, + val isScanCardNotificationVisible: Boolean, + val continueButtonEnabled: Boolean, + val onContinueButtonClick: () -> Unit, + val walletSelectorConfig: TangemBottomSheetConfig, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/MyPortfolioUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/MyPortfolioUM.kt new file mode 100644 index 0000000000..47404ea0f3 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/MyPortfolioUM.kt @@ -0,0 +1,33 @@ +package com.tangem.features.markets.portfolio.impl.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class MyPortfolioUM { + + data class Tokens( + val tokens: ImmutableList, + val buttonState: AddButtonState, + val addToPortfolioBSConfig: TangemBottomSheetConfig, + val tokenReceiveBSConfig: TangemBottomSheetConfig, + val onAddClick: () -> Unit, + ) : MyPortfolioUM() { + + enum class AddButtonState { + Loading, + Available, + Unavailable, + } + } + + data class AddFirstToken( + val addToPortfolioBSConfig: TangemBottomSheetConfig, + val onAddClick: () -> Unit, + ) : MyPortfolioUM() + + data object Loading : MyPortfolioUM() + + data object Unavailable : MyPortfolioUM() +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/PortfolioTokenUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/PortfolioTokenUM.kt new file mode 100644 index 0000000000..4f973ad210 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/PortfolioTokenUM.kt @@ -0,0 +1,20 @@ +package com.tangem.features.markets.portfolio.impl.ui.state + +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.collections.immutable.ImmutableList + +internal data class PortfolioTokenUM( + val tokenItemState: TokenItemState, + val walletId: UserWalletId, + val isBalanceHidden: Boolean, + val isQuickActionsShown: Boolean, + val quickActions: QuickActions, +) { + + data class QuickActions( + val actions: ImmutableList, + val onQuickActionClick: (QuickActionUM) -> Unit, + val onQuickActionLongClick: (QuickActionUM) -> Unit, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/QuickActionUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/QuickActionUM.kt new file mode 100644 index 0000000000..dba8458d97 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/QuickActionUM.kt @@ -0,0 +1,32 @@ +package com.tangem.features.markets.portfolio.impl.ui.state + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.markets.impl.R + +@Immutable +internal enum class QuickActionUM( + val title: TextReference, + val description: TextReference, + @DrawableRes val icon: Int, + val longClickAvailable: Boolean = false, +) { + Buy( + title = resourceReference(R.string.common_buy), + description = resourceReference(R.string.buy_token_description), + icon = R.drawable.ic_plus_24, + ), + Exchange( + title = resourceReference(R.string.common_exchange), + description = resourceReference(R.string.exсhange_token_description), + icon = R.drawable.ic_exchange_vertical_24, + ), + Receive( + title = resourceReference(R.string.common_receive), + description = resourceReference(R.string.receive_token_description), + icon = R.drawable.ic_arrow_down_24, + longClickAvailable = true, + ), +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/SelectNetworkUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/SelectNetworkUM.kt new file mode 100644 index 0000000000..90830679ca --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/SelectNetworkUM.kt @@ -0,0 +1,13 @@ +package com.tangem.features.markets.portfolio.impl.ui.state + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import kotlinx.collections.immutable.ImmutableList + +internal data class SelectNetworkUM( + val tokenId: String, + val iconUrl: String?, + val tokenName: String, + val tokenCurrencySymbol: String, + val networks: ImmutableList, + val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/TokenActionsBSContentUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/TokenActionsBSContentUM.kt new file mode 100644 index 0000000000..83450b1ebc --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/TokenActionsBSContentUM.kt @@ -0,0 +1,54 @@ +package com.tangem.features.markets.portfolio.impl.ui.state + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.markets.impl.R +import kotlinx.collections.immutable.ImmutableList + +internal data class TokenActionsBSContentUM( + val title: String, + val actions: ImmutableList, + val onActionClick: (Action) -> Unit, +) : TangemBottomSheetConfigContent { + + @Immutable + enum class Action( + val text: TextReference, + @DrawableRes val iconRes: Int, + ) { + CopyAddress( + text = resourceReference(R.string.common_copy_address), + iconRes = R.drawable.ic_copy_24, + ), + Send( + text = resourceReference(R.string.common_send), + iconRes = R.drawable.ic_arrow_up_24, + ), + Receive( + text = resourceReference(R.string.common_receive), + iconRes = R.drawable.ic_arrow_down_24, + ), + Buy( + text = resourceReference(R.string.common_buy), + iconRes = R.drawable.ic_plus_24, + ), + Sell( + text = resourceReference(R.string.common_sell), + iconRes = R.drawable.ic_currency_24, + ), + Exchange( + text = resourceReference(R.string.common_exchange), + iconRes = R.drawable.ic_exchange_horizontal_24, + ), + Stake( + text = resourceReference(R.string.common_stake), + iconRes = R.drawable.ic_staking_24, + ), + ; + + val order: Int = ordinal + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/WalletSelectorBSContentUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/WalletSelectorBSContentUM.kt new file mode 100644 index 0000000000..fddc12c25e --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/WalletSelectorBSContentUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.markets.portfolio.impl.ui.state + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import kotlinx.collections.immutable.ImmutableList + +internal data class WalletSelectorBSContentUM( + val userWallets: ImmutableList, + val onBack: () -> Unit, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/DefaultTokenMarketBlockComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/DefaultTokenMarketBlockComponent.kt new file mode 100644 index 0000000000..e4acc5b43c --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/DefaultTokenMarketBlockComponent.kt @@ -0,0 +1,40 @@ +package com.tangem.features.markets.token.block.impl + +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.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import com.tangem.features.markets.token.block.TokenMarketBlockComponent.Params +import com.tangem.features.markets.token.block.impl.model.TokenMarketBlockModel +import com.tangem.features.markets.token.block.impl.ui.TokenMarketBlock +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Stable +internal class DefaultTokenMarketBlockComponent @AssistedInject constructor( + @Assisted componentContext: AppComponentContext, + @Assisted params: Params, +) : TokenMarketBlockComponent, AppComponentContext by componentContext { + + private val model: TokenMarketBlockModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + + TokenMarketBlock( + modifier = modifier, + state = state, + ) + } + + @AssistedFactory + interface Factory : TokenMarketBlockComponent.Factory { + override fun create(appComponentContext: AppComponentContext, params: Params): DefaultTokenMarketBlockComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/di/ComponentModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/di/ComponentModule.kt new file mode 100644 index 0000000000..0da82394c8 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/di/ComponentModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.markets.token.block.impl.di + +import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import com.tangem.features.markets.token.block.impl.DefaultTokenMarketBlockComponent +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 ComponentModule { + + @Binds + @Singleton + fun bindMarketsPortfolioComponent( + factory: DefaultTokenMarketBlockComponent.Factory, + ): TokenMarketBlockComponent.Factory +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/di/ModelModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/di/ModelModule.kt new file mode 100644 index 0000000000..fdb1ba90ba --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/di/ModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.markets.token.block.impl.di + +import com.tangem.core.decompose.di.DecomposeComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.markets.token.block.impl.model.TokenMarketBlockModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(DecomposeComponent::class) +internal interface ModelModule { + + @Binds + @IntoMap + @ClassKey(TokenMarketBlockModel::class) + fun provideTokenMarketBlockModel(model: TokenMarketBlockModel): Model +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/QuotesState.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/QuotesState.kt new file mode 100644 index 0000000000..218669f3d9 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/QuotesState.kt @@ -0,0 +1,8 @@ +package com.tangem.features.markets.token.block.impl.model + +import java.math.BigDecimal + +internal class QuotesState( + val currentPrice: BigDecimal, + val h24Percent: BigDecimal, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt new file mode 100644 index 0000000000..d0ca34298a --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt @@ -0,0 +1,162 @@ +package com.tangem.features.markets.token.block.impl.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.common.routing.AppRoute +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter +import com.tangem.common.ui.charts.state.sorted +import com.tangem.core.decompose.di.ComponentScoped +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.marketprice.PriceChangeType +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.GetCurrencyQuotesUseCase +import com.tangem.domain.markets.GetTokenPriceChartUseCase +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import com.tangem.features.markets.token.block.impl.ui.state.TokenMarketBlockUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ComponentScoped +internal class TokenMarketBlockModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val getTokenQuotesUseCase: GetCurrencyQuotesUseCase, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, +) : Model() { + + private val params = paramsContainer.require() + + private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = false) + + private val currentAppCurrency = getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + }.stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + + private var quotesState: QuotesState? = null + private val quotesUpdateJobHolder = JobHolder() + + val state = MutableStateFlow( + TokenMarketBlockUM( + currencySymbol = params.cryptoCurrency.symbol, + currentPrice = null, + h24Percent = null, + priceChangeType = PriceChangeType.NEUTRAL, + chartData = null, + onClick = ::navigateToMarketDetails, + ), + ) + + init { + startFetching() + } + + private fun startFetching() { + modelScope.launch { + getTokenQuotesUseCase( + currencyID = params.cryptoCurrency.id, + interval = PriceChangeInterval.H24, + refresh = true, + ).collect { + it.onSome { res -> + quotesState = QuotesState( + currentPrice = res.fiatRate, + h24Percent = res.priceChange, + ) + + state.value = state.value.copy( + currentPrice = BigDecimalFormatter.formatFiatPriceUncapped( + fiatAmount = res.fiatRate, + // TODO get currency from quotes use case [REDACTED_TASK_KEY] + fiatCurrencyCode = currentAppCurrency.value.code, + // TODO get currency from quotes use case [REDACTED_TASK_KEY] + fiatCurrencySymbol = currentAppCurrency.value.symbol, + ), + h24Percent = BigDecimalFormatter.formatPercent( + percent = res.priceChange, + useAbsoluteValue = true, + ), + priceChangeType = PriceChangeType.fromBigDecimal(res.priceChange), + ) + } + } + }.saveIn(quotesUpdateJobHolder) + + val tokenId = params.cryptoCurrency.id.rawCurrencyId ?: return + + modelScope.launch(dispatchers.main) { + val result = getTokenPriceChartUseCase( + tokenId = tokenId, + tokenSymbol = params.cryptoCurrency.symbol, + interval = PriceChangeInterval.H24, + appCurrency = currentAppCurrency.value, // TODO get currency from quotes use case [REDACTED_TASK_KEY] + preview = true, + ) + + result.onRight { res -> + // wait until quotes are loaded + state.first { it.currentPrice != null } + + state.update { stateToUpdate -> + stateToUpdate.copy( + chartData = priceAndTimePointValuesConverter.convert( + MarketChartData.Data( + y = res.priceY.toImmutableList(), + x = res.timeStamps.map { it.toBigDecimal() }.toImmutableList(), + ).sorted(), + ), + ) + } + } + } + } + + private fun navigateToMarketDetails() { + val quotes = quotesState ?: return + val tokenId = params.cryptoCurrency.id.rawCurrencyId ?: return + + val tokenParam = TokenMarketParams( + id = tokenId, + name = params.cryptoCurrency.name, + imageUrl = params.cryptoCurrency.iconUrl, + symbol = params.cryptoCurrency.symbol, + tokenQuotes = TokenMarketParams.Quotes( + currentPrice = quotes.currentPrice, + h24Percent = quotes.h24Percent, + weekPercent = null, + monthPercent = null, + ), + ) + + router.push( + AppRoute.MarketsTokenDetails( + token = tokenParam, + appCurrency = currentAppCurrency.value, + showPortfolio = false, + analyticsParams = AppRoute.MarketsTokenDetails.AnalyticsParams( + blockchain = params.cryptoCurrency.network.name, + source = "Token", + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt new file mode 100644 index 0000000000..8191f0addf --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt @@ -0,0 +1,216 @@ +package com.tangem.features.markets.token.block.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.common.ui.charts.MarketChartMini +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.tokens.TokenPriceText +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerW8 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.components.marketprice.PriceChangeInPercent +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.markets.details.impl.model.formatter.toChartType +import com.tangem.features.markets.impl.R +import com.tangem.features.markets.token.block.impl.ui.state.TokenMarketBlockUM +import kotlinx.collections.immutable.toImmutableList +import kotlin.random.Random + +@Composable +internal fun TokenMarketBlock(state: TokenMarketBlockUM, modifier: Modifier = Modifier) { + BlockCard( + modifier = modifier, + enabled = state.currentPrice != null, + onClick = state.onClick, + content = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(TangemTheme.dimens.spacing12), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + LeftSide( + modifier = Modifier.weight(1f), + symbol = state.currencySymbol, + priceText = state.currentPrice, + percentText = state.h24Percent, + type = state.priceChangeType, + ) + SpacerW8() + RightSide( + modifier = Modifier, + priceChangeType = state.priceChangeType, + chartRawData = state.chartData, + ) + } + }, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LeftSide( + symbol: String, + priceText: String?, + percentText: String?, + type: PriceChangeType, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + ) { + Text( + text = stringResource(id = R.string.wallet_marketplace_block_title, symbol), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.subtitle2, + ) + + if (priceText != null && percentText != null) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + TokenPriceText( + modifier = Modifier.alignByBaseline(), + price = priceText, + priceChangeType = type, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + PriceChangeInPercent( + modifier = Modifier.alignByBaseline(), + valueInPercent = percentText, + type = type, + ) + Text( + modifier = Modifier.alignByBaseline(), + text = stringResource(id = R.string.wallet_marketprice_block_update_time), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } else { + TextShimmer( + modifier = Modifier.fillMaxWidth(fraction = 0.6f), + style = TangemTheme.typography.body2, + ) + } + } +} + +@Composable +private fun RightSide( + priceChangeType: PriceChangeType?, + chartRawData: MarketChartRawData?, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(vertical = TangemTheme.dimens.spacing10), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + verticalAlignment = Alignment.CenterVertically, + ) { + if (chartRawData != null && priceChangeType != null) { + MarketChartMini( + rawData = chartRawData, + type = priceChangeType.toChartType(), + modifier = Modifier + .requiredSize( + width = TangemTheme.dimens.size56, + height = TangemTheme.dimens.size24, + ), + ) + } else { + RectangleShimmer( + modifier = Modifier + .padding(vertical = TangemTheme.dimens.spacing2) + .requiredSize( + width = TangemTheme.dimens.size56, + height = TangemTheme.dimens.size20, + ), + ) + } + + if (priceChangeType != null) { + Icon( + modifier = Modifier.requiredSize(TangemTheme.dimens.size20), + imageVector = ImageVector.vectorResource(id = R.drawable.ic_chevron_right_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } else { + RectangleShimmer( + modifier = Modifier + .requiredSize( + width = TangemTheme.dimens.size20, + height = TangemTheme.dimens.size20, + ), + ) + } + } +} + +@Preview(widthDp = 360) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 360) +@Composable +private fun Preview() { + val data = MarketChartRawData( + x = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), + y = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), + ) + + val state = TokenMarketBlockUM( + currencySymbol = "XRP", + currentPrice = "0,5$", + h24Percent = "0,5%", + priceChangeType = PriceChangeType.UP, + chartData = data, + onClick = {}, + ) + + TangemThemePreview { + Column( + modifier = Modifier.background(TangemTheme.colors.background.tertiary), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + TokenMarketBlock( + modifier = Modifier.fillMaxWidth(), + state = state, + ) + TokenMarketBlock( + modifier = Modifier.fillMaxWidth(), + state = state.copy( + currentPrice = "0,0000000000012356786789$", + ), + ) + TokenMarketBlock( + modifier = Modifier.fillMaxWidth(), + state = state.copy( + currentPrice = null, + chartData = null, + ), + ) + TokenMarketBlock( + modifier = Modifier.fillMaxWidth(), + state = state.copy( + chartData = null, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/state/TokenMarketBlockUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/state/TokenMarketBlockUM.kt new file mode 100644 index 0000000000..475c39e3f9 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/state/TokenMarketBlockUM.kt @@ -0,0 +1,13 @@ +package com.tangem.features.markets.token.block.impl.ui.state + +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.core.ui.components.marketprice.PriceChangeType + +internal data class TokenMarketBlockUM( + val currencySymbol: String, + val currentPrice: String?, + val h24Percent: String?, + val priceChangeType: PriceChangeType, + val chartData: MarketChartRawData?, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt index 6843a4d610..64b0f12b74 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.unit.Dp import com.tangem.core.decompose.context.AppComponentContext import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket -import com.tangem.features.markets.component.BottomSheetState +import com.tangem.features.markets.entry.BottomSheetState @Stable interface MarketsTokenListComponent { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt index 64c1f0f751..a04a5f8b63 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt @@ -9,7 +9,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket -import com.tangem.features.markets.component.BottomSheetState +import com.tangem.features.markets.entry.BottomSheetState import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent import com.tangem.features.markets.tokenlist.impl.model.MarketsListModel import com.tangem.features.markets.tokenlist.impl.ui.MarketsList diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt new file mode 100644 index 0000000000..99f0faa3c3 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.markets.tokenlist.impl.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM + +internal sealed class MarketsListAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent(category = "Markets", event = event, params = params) { + + data object BottomSheetOpened : MarketsListAnalyticsEvent(event = "Markets Screen Opened") + + data class SortBy( + val sortByTypeUM: SortByTypeUM, + val interval: MarketsListUM.TrendInterval, + ) : MarketsListAnalyticsEvent( + event = "Sort By", + params = mapOf( + "Type" to when (sortByTypeUM) { + SortByTypeUM.Rating -> "Rating" + SortByTypeUM.Trending -> "Trending" + SortByTypeUM.ExperiencedBuyers -> "Buyers" + SortByTypeUM.TopGainers -> "Gainers" + SortByTypeUM.TopLosers -> "Losers" + }, + "Period" to when (interval) { + MarketsListUM.TrendInterval.H24 -> "24h" + MarketsListUM.TrendInterval.D7 -> "7d" + MarketsListUM.TrendInterval.M1 -> "1m" + }, + ), + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt index b2d0ca1844..f3dd2d3c1a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt @@ -2,15 +2,17 @@ package com.tangem.features.markets.tokenlist.impl.model import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ComponentScoped import com.tangem.core.decompose.model.Model import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase import com.tangem.domain.markets.TokenMarket -import com.tangem.features.markets.component.BottomSheetState -import com.tangem.features.markets.tokenlist.impl.model.statemanager.MarketsListUMStateManager +import com.tangem.features.markets.entry.BottomSheetState +import com.tangem.features.markets.tokenlist.impl.analytics.MarketsListAnalyticsEvent import com.tangem.features.markets.tokenlist.impl.model.statemanager.MarketsListBatchFlowManager +import com.tangem.features.markets.tokenlist.impl.model.statemanager.MarketsListUMStateManager import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM @@ -32,6 +34,7 @@ internal class MarketsListModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private var updateQuotesJob = JobHolder() @@ -221,10 +224,30 @@ internal class MarketsListModel @Inject constructor( } } + // analytics + initAnalytics() + // initial loading mainMarketsListManager.reload() } + private fun initAnalytics() { + containerBottomSheetState + .onEach { + if (it == BottomSheetState.EXPANDED) { + analyticsEventHandler.send(MarketsListAnalyticsEvent.BottomSheetOpened) + } + }.launchIn(modelScope) + + state + .filter { it.isInSearchMode.not() } + .map { MarketsListAnalyticsEvent.SortBy(it.selectedSortBy, it.selectedInterval) } + .distinctUntilChanged() + .onEach { + analyticsEventHandler.send(it) + }.launchIn(modelScope) + } + private fun onTokenUIClicked(token: MarketsListItemUM) { modelScope.launch { activeListManager.getTokenById(token.id)?.let { found -> diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt index c43e29f406..b59acbc16d 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt @@ -1,8 +1,9 @@ package com.tangem.features.markets.tokenlist.impl.model.converters -import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter import com.tangem.common.ui.charts.state.MarketChartData import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter +import com.tangem.common.ui.charts.state.sorted import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency @@ -33,7 +34,7 @@ internal class MarketsTokenItemConverter( trendPercentText = value.getTrendPercent(), trendType = value.getTrendType(), chardData = value.getChartData(), - isUnder100kMarketCap = value.isUnder100kMarketCap(), + isUnder100kMarketCap = value.isUnderMarketCapLimit, ) } @@ -115,21 +116,23 @@ internal class MarketsTokenItemConverter( MarketChartData.Data( y = ct.priceY.toImmutableList(), x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(), - ), + ).sorted(), ) } } + @Suppress("MagicNumber") private fun TokenMarket.getTrendType(): PriceChangeType { val percent = when (currentTrendInterval) { TrendInterval.H24 -> tokenQuotesShort.h24ChangePercent TrendInterval.D7 -> tokenQuotesShort.weekChangePercent TrendInterval.M1 -> tokenQuotesShort.monthChangePercent - }.setScale(2, RoundingMode.UP) - - return when (percent.compareTo(BigDecimal.ZERO)) { - 1 -> PriceChangeType.UP - -1 -> PriceChangeType.DOWN + } + val scaled = percent.setScale(4, RoundingMode.HALF_UP) + return when { + scaled == null -> PriceChangeType.NEUTRAL + scaled > BigDecimal.ZERO -> PriceChangeType.UP + scaled < BigDecimal.ZERO -> PriceChangeType.DOWN else -> PriceChangeType.NEUTRAL } } @@ -146,12 +149,4 @@ internal class MarketsTokenItemConverter( useAbsoluteValue = true, ) } - - private fun TokenMarket.isUnder100kMarketCap(): Boolean { - return marketCap?.let { it < decimal100k } ?: true - } - - private companion object { - val decimal100k: BigDecimal = BigDecimal.valueOf(100_000) - } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt index 50170ce417..f22b26c622 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt @@ -192,7 +192,7 @@ internal class MarketsListUMStateManager( private fun state(): MarketsListUM = MarketsListUM( list = ListUM.Loading, searchBar = SearchBarUM( - placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), + placeholderText = resourceReference(R.string.common_search), query = "", onQueryChange = { searchQuery = it }, isActive = false, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt index 6dcb360962..9c52cb6bdb 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -20,6 +21,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.Keyboard import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig @@ -34,7 +36,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.markets.component.BottomSheetState +import com.tangem.features.markets.entry.BottomSheetState import com.tangem.features.markets.impl.R import com.tangem.features.markets.tokenlist.impl.ui.components.MarketsListLazyColumn import com.tangem.features.markets.tokenlist.impl.ui.components.MarketsListSortByBottomSheet @@ -65,10 +67,13 @@ internal fun MarketsList( ) } +@Suppress("LongMethod") @Composable private fun Content(state: MarketsListUM, onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier = Modifier) { val density = LocalDensity.current val background = LocalMainBottomSheetColor.current.value + val strokeColor = TangemTheme.colors.stroke.primary + val scrolledState = remember { mutableStateOf(false) } Column( modifier = modifier @@ -82,7 +87,7 @@ private fun Content(state: MarketsListUM, onHeaderSizeChange: (Dp) -> Unit, modi .padding( start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing16, - bottom = TangemTheme.dimens.spacing4, + bottom = 8.dp, ) .onGloballyPositioned { if (it.size.height > 0) { @@ -90,26 +95,49 @@ private fun Content(state: MarketsListUM, onHeaderSizeChange: (Dp) -> Unit, modi onHeaderSizeChange(it.size.height.toDp()) } } - }, + } + .padding(bottom = 4.dp), state = state.searchBar, ) - Spacer(Modifier.height(TangemTheme.dimens.spacing20)) Column(Modifier.padding(horizontal = TangemTheme.dimens.size16)) { - Title(isInSearchMode = state.isInSearchMode) - AnimatedVisibility(state.isInSearchMode.not()) { + AnimatedVisibility( + visible = scrolledState.value.not(), + ) { Column { + SpacerH8() + Title(isInSearchMode = state.isInSearchMode) SpacerH12() - Options( - sortByTypeUM = state.selectedSortBy, - trendInterval = state.selectedInterval, - onIntervalClick = state.onIntervalClick, - onSortByClick = state.onSortByButtonClick, - ) } } + AnimatedVisibility(state.isInSearchMode.not()) { + Options( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + sortByTypeUM = state.selectedSortBy, + trendInterval = state.selectedInterval, + onIntervalClick = state.onIntervalClick, + onSortByClick = state.onSortByButtonClick, + ) + } } - SpacerH12() + val strokeWidth = TangemTheme.dimens.size0_5 + Box( + Modifier + .fillMaxWidth() + .height(strokeWidth) + .drawBehind { + // draw horizontal line + if (scrolledState.value) { + drawLine( + color = strokeColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = strokeWidth.toPx(), + ) + } + }, + ) ItemsList( + scrolledState = scrolledState, isInSearchMode = state.isInSearchMode, state = state.list, ) @@ -184,10 +212,35 @@ private fun Options( } @Composable -private fun ItemsList(isInSearchMode: Boolean, state: ListUM, modifier: Modifier = Modifier) { +private fun ItemsList( + scrolledState: MutableState, + isInSearchMode: Boolean, + state: ListUM, + modifier: Modifier = Modifier, +) { val searchLazyListState = rememberLazyListState() val mainLazyListState = rememberLazyListState() + val mainScrolled by remember { + derivedStateOf { + mainLazyListState.firstVisibleItemScrollOffset > 0 + } + } + + val searchScrolledState by remember { + derivedStateOf { + searchLazyListState.firstVisibleItemScrollOffset > 0 + } + } + + LaunchedEffect(mainScrolled, isInSearchMode, searchScrolledState) { + scrolledState.value = if (isInSearchMode) { + searchScrolledState + } else { + mainScrolled + } + } + MarketsListLazyColumn( modifier = modifier, state = state, @@ -256,7 +309,7 @@ private fun Preview() { onItemClick = {}, ), searchBar = SearchBarUM( - placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), + placeholderText = resourceReference(R.string.common_search), query = "", onQueryChange = {}, isActive = false, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt index 7658893fb7..c153024da0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt @@ -1,185 +1,46 @@ package com.tangem.features.markets.tokenlist.impl.ui.components import android.content.res.Configuration -import androidx.compose.animation.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.snap -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.AnchoredDraggableState -import androidx.compose.foundation.gestures.DraggableAnchors -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.anchoredDraggable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.* -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalDensity -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.unit.IntOffset import com.tangem.common.ui.charts.MarketChartMini import com.tangem.common.ui.charts.state.MarketChartLook import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.tokens.TokenPriceText import com.tangem.core.ui.components.* import com.tangem.core.ui.components.currency.icon.CoinIcon import com.tangem.core.ui.components.marketprice.PriceChangeInPercent import com.tangem.core.ui.components.marketprice.PriceChangeType -import com.tangem.core.ui.haptic.TangemHapticEffect -import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.windowsize.WindowSizeType import com.tangem.features.markets.impl.R -import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM import com.tangem.features.markets.tokenlist.impl.ui.preview.MarketChartListItemPreviewDataProvider +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM import com.tangem.utils.StringsSigns.MINUS -import kotlinx.coroutines.launch -import kotlin.math.roundToInt import kotlin.random.Random -internal enum class DragValue { Start, End } - -const val SWIPE_THRESHOLD_PERCENT = 0.8f -const val SWIPE_VELOCITY_THRESHOLD = 20f - -@Suppress("LongMethod") -@OptIn(ExperimentalFoundationApi::class) @Composable -fun MarketsListItem( - model: MarketsListItemUM, - modifier: Modifier = Modifier, - onClick: () -> Unit = {}, - onSwipeToAction: () -> Unit = {}, -) { - val actionWidth = TangemTheme.dimens.size68 - val actionWidthPx = with(LocalDensity.current) { actionWidth.toPx() } - - val hapticManager = LocalHapticManager.current - - val anchors = DraggableAnchors { - DragValue.Start at 0f - DragValue.End at -actionWidthPx - } - val state = remember { - AnchoredDraggableState( - initialValue = DragValue.Start, - anchors = anchors, - positionalThreshold = { it * (1 - SWIPE_THRESHOLD_PERCENT) }, - velocityThreshold = { SWIPE_VELOCITY_THRESHOLD }, - animationSpec = tween(easing = FastOutSlowInEasing), - confirmValueChange = { it == DragValue.Start }, - ) - } - val dragInteractionSource = remember { MutableInteractionSource() } - val clickInteractionSource = remember { MutableInteractionSource() } - val isInDraggedState by dragInteractionSource.collectIsDraggedAsState() - - LaunchedEffect(Unit) { - var actionPerformed = false - var releasePerformed = true - launch { - snapshotFlow { state.offset } - .collect { - val border = -actionWidthPx * SWIPE_THRESHOLD_PERCENT - if (it < border && actionPerformed.not()) { - hapticManager.perform(TangemHapticEffect.View.GestureThresholdActivate) - actionPerformed = true - releasePerformed = false - } - - if (it > border) { - if (releasePerformed.not()) { - hapticManager.perform(TangemHapticEffect.View.GestureThresholdDeactivate) - releasePerformed = true - } - actionPerformed = false - } - } - } - launch { - snapshotFlow { isInDraggedState } - .collect { - if (it.not() && actionPerformed) { - releasePerformed = true - onSwipeToAction() - } - } - } - } - - Box( - modifier = Modifier - .height(intrinsicSize = IntrinsicSize.Min) - .fillMaxWidth(), - ) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .fillMaxHeight() - .offset { - IntOffset( - x = actionWidthPx.roundToInt() + - state - .requireOffset() - .toInt(), - y = 0, - ) - } - .width(actionWidth) - .background(TangemTheme.colors.control.checked), - contentAlignment = Alignment.Center, - ) { - Image( - modifier = Modifier.size(TangemTheme.dimens.size28), - imageVector = ImageVector.vectorResource(id = R.drawable.ic_plus_mini_28), - colorFilter = ColorFilter.tint(TangemTheme.colors.icon.primary2), - contentDescription = null, - ) - } - - Box( - modifier = modifier - .align(Alignment.CenterStart) - .clip(RectangleShape) - .offset { - IntOffset( - x = state - .requireOffset() - .toInt(), - y = 0, - ) - } - .anchoredDraggable( - state = state, - orientation = Orientation.Horizontal, - interactionSource = dragInteractionSource, - ) - .clickable( - enabled = true, - interactionSource = clickInteractionSource, - indication = rememberRipple(), - onClick = onClick, - ), - ) { - MarketsListItemContent(model = model) - } - } +internal fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { + MarketsListItemContent( + modifier = modifier + .fillMaxWidth() + .clip(RectangleShape) + .clickable(onClick = onClick), + model = model, + ) } @Composable @@ -325,38 +186,6 @@ private fun RowScope.TokenMarketCapText(text: String) { ) } -@Composable -private fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) { - val growColor = TangemTheme.colors.text.accent - val fallColor = TangemTheme.colors.text.warning - val generalColor = TangemTheme.colors.text.primary1 - - val color = remember { Animatable(generalColor) } - - LaunchedEffect(price) { - if (priceChangeType != null) { - val nextColor = when (priceChangeType) { - PriceChangeType.UP, - -> growColor - PriceChangeType.DOWN -> fallColor - PriceChangeType.NEUTRAL -> return@LaunchedEffect - } - - color.animateTo(nextColor, snap()) - color.animateTo(generalColor, tween(durationMillis = 500)) - } - } - - Text( - modifier = modifier, - text = price, - color = color.value, - maxLines = 1, - style = TangemTheme.typography.body2, - overflow = TextOverflow.Visible, - ) -} - @Composable private fun Chart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawData?) { val chartWidth = TangemTheme.dimens.size56 diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt index 0da161d62a..5d70d6e85e 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt @@ -13,10 +13,10 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.utils.disableNestedScroll import com.tangem.features.markets.impl.R import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM import kotlinx.coroutines.launch @@ -51,7 +51,7 @@ internal fun MarketsListLazyColumn( if (state is ListUM.Loading) { LazyColumn( - modifier = Modifier.disableNestedScroll(), + modifier = modifier, state = rememberLazyListState(), contentPadding = PaddingValues(bottom = bottomBarHeight), userScrollEnabled = false, @@ -62,7 +62,7 @@ internal fun MarketsListLazyColumn( } } else { LazyColumn( - modifier = modifier.disableNestedScroll(), + modifier = modifier, state = lazyListState, contentPadding = PaddingValues(bottom = bottomBarHeight), userScrollEnabled = true, @@ -196,26 +196,4 @@ private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) { state.visibleIdsChanged(visibleItems) } } -} - -@Composable -fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Boolean, buffer: Int = 2) { - val loadMore by remember { - derivedStateOf { - val layoutInfo = listState.layoutInfo - val totalItemsNumber = layoutInfo.totalItemsCount - val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 - - lastVisibleItemIndex > totalItemsNumber - buffer - } - } - - val totalItemsCount by remember { derivedStateOf { listState.layoutInfo.totalItemsCount } } - var emitted by remember(totalItemsCount) { mutableStateOf(false) } - - LaunchedEffect(loadMore) { - if (loadMore && !emitted) { - emitted = onLoadMore() - } - } } \ No newline at end of file diff --git a/features/referral/presentation/src/main/java/com/tangem/feature/referral/ui/ReferralBottomSheet.kt b/features/referral/presentation/src/main/java/com/tangem/feature/referral/ui/ReferralBottomSheet.kt index 678775213a..0a3d43118a 100644 --- a/features/referral/presentation/src/main/java/com/tangem/feature/referral/ui/ReferralBottomSheet.kt +++ b/features/referral/presentation/src/main/java/com/tangem/feature/referral/ui/ReferralBottomSheet.kt @@ -1,9 +1,6 @@ package com.tangem.feature.referral.ui -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet @@ -11,7 +8,6 @@ import androidx.compose.material3.SheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity -import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.feature.referral.models.ReferralStateHolder @@ -24,19 +20,18 @@ internal fun ReferralBottomSheet( onDismissRequest: () -> Unit, config: ReferralStateHolder.ReferralInfoState, ) { - val statusBarHeight = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() } val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } if (isVisible) { ModalBottomSheet( - modifier = Modifier.height(LocalWindowSize.current.height - statusBarHeight), + modifier = Modifier.statusBarsPadding(), onDismissRequest = onDismissRequest, sheetState = sheetState, shape = RoundedCornerShape( topStart = TangemTheme.dimens.radius16, topEnd = TangemTheme.dimens.radius16, ), - windowInsets = WindowInsetsZero, + contentWindowInsets = { WindowInsetsZero }, containerColor = TangemTheme.colors.background.primary, ) { AgreementBottomSheetContent( diff --git a/features/referral/presentation/src/main/java/com/tangem/feature/referral/ui/ReferralScreen.kt b/features/referral/presentation/src/main/java/com/tangem/feature/referral/ui/ReferralScreen.kt index 7d6614a7aa..9fe33b7bd6 100644 --- a/features/referral/presentation/src/main/java/com/tangem/feature/referral/ui/ReferralScreen.kt +++ b/features/referral/presentation/src/main/java/com/tangem/feature/referral/ui/ReferralScreen.kt @@ -195,7 +195,7 @@ private fun ReferralInfo( } is ReferralInfoState.Loading -> { - LoadingCondition(iconResId = R.drawable.ic_tether_28) + LoadingCondition(iconResId = R.drawable.ic_tether_24) SpacerH32() LoadingCondition(iconResId = R.drawable.ic_discount_28) } @@ -211,7 +211,7 @@ private fun Conditions(state: ReferralInfoContentState) { @Composable private fun ConditionForYou(state: ReferralInfoContentState) { - Condition(iconResId = R.drawable.ic_tether_28) { + Condition(iconResId = R.drawable.ic_tether_24) { when (state) { is ReferralInfoState.ParticipantContent -> InfoForYou( award = state.award, diff --git a/features/send/impl/build.gradle.kts b/features/send/impl/build.gradle.kts index 9a3de6d8cb..14e47173a5 100644 --- a/features/send/impl/build.gradle.kts +++ b/features/send/impl/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation(projects.domain.txhistory) implementation(projects.domain.txhistory.models) implementation(projects.domain.transaction) + implementation(projects.domain.transaction.models) implementation(projects.domain.card) implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/SendAnalyticEvents.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/SendAnalyticEvents.kt index c4ce202599..03fb73beab 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/SendAnalyticEvents.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/SendAnalyticEvents.kt @@ -5,7 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE -import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN +import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE import com.tangem.core.analytics.models.AnalyticsParam.Key.VALIDATION import com.tangem.core.analytics.models.AnalyticsParam.OnOffState @@ -105,7 +105,7 @@ internal sealed class SendAnalyticEvents( ) : SendAnalyticEvents( event = "Transaction Sent Screen Opened", params = mapOf( - TOKEN to token, + TOKEN_PARAM to token, FEE_TYPE to feeType.value, ), ) @@ -119,13 +119,13 @@ internal sealed class SendAnalyticEvents( /** If not enough fee notification is present */ data class NoticeNotEnoughFee(val token: String, val blockchain: String) : SendAnalyticEvents( event = "Notice - Not Enough Fee", - params = mapOf(TOKEN to token, BLOCKCHAIN to blockchain), + params = mapOf(TOKEN_PARAM to token, BLOCKCHAIN to blockchain), ) /** If transaction delays notification is present */ data class NoticeTransactionDelays(val token: String) : SendAnalyticEvents( event = "Notice - Transaction Delays Are Possible", - params = mapOf(TOKEN to token), + params = mapOf(TOKEN_PARAM to token), ) data object NoticeFeeCoverage : SendAnalyticEvents( @@ -135,7 +135,7 @@ internal sealed class SendAnalyticEvents( /** If error occurs during send transactions */ data class TransactionError(val token: String) : SendAnalyticEvents( event = "Error - Transaction Rejected", - params = mapOf(TOKEN to token), + params = mapOf(TOKEN_PARAM to token), ) // endregion } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt index 8a455b1b25..187e4090fd 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt @@ -101,7 +101,7 @@ internal sealed class SendNotification(val config: NotificationConfig) { title = resourceReference(R.string.send_notification_existential_deposit_title), subtitle = resourceReference(R.string.send_notification_existential_deposit_text, wrappedList(deposit)), buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig( - text = resourceReference(R.string.send_notification_leave_button, wrappedList(deposit)), + text = resourceReference(R.string.common_ok), onClick = onConfirmClick, ), ) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendTransactionAlertConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendTransactionAlertConverter.kt index d37954025b..7d184f5227 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendTransactionAlertConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendTransactionAlertConverter.kt @@ -1,5 +1,7 @@ package com.tangem.features.send.impl.presentation.state +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents import com.tangem.utils.converter.Converter @@ -15,7 +17,7 @@ internal class SendTransactionAlertConverter( is SendTransactionError.TangemSdkError -> SendAlertState.TransactionError( code = value.code.toString(), cause = null, - causeTextReference = value.messageReference, + causeTextReference = resourceReference(value.messageRes, wrappedList(value.args)), onConfirmClick = { clickIntents.onFailedTxEmailClick(value.code.toString()) }, ) is SendTransactionError.BlockchainSdkError -> SendAlertState.TransactionError( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeConverter.kt index 4c557fecbb..449c2f6f43 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeConverter.kt @@ -72,7 +72,10 @@ internal class FeeConverter( normalFee } else { when (normalFee) { - is Fee.Ethereum -> ethereumCustomFeeConverter.convertBack(normalFee = normalFee, value = customValues) + is Fee.Ethereum.Legacy -> ethereumCustomFeeConverter.convertBack( + normalFee = normalFee, + value = customValues, + ) is Fee.Bitcoin -> bitcoinCustomFeeConverter.convertBack(normalFee = normalFee, value = customValues) is Fee.Kaspa -> kaspaCustomFeeConverter.convertBack(normalFee = normalFee, value = customValues) else -> { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeStateFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeStateFactory.kt index 3a30e424b9..3ff511170b 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeStateFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeStateFactory.kt @@ -1,10 +1,12 @@ package com.tangem.features.send.impl.presentation.state.fee -import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.extensions.isZero +import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.AmountType import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase import com.tangem.features.send.impl.presentation.state.SendNotification @@ -15,6 +17,7 @@ import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents import com.tangem.utils.Provider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import com.tangem.blockchain.common.AmountType as SdkAmountType /** * Factory to produce fee state for [SendUiState] @@ -94,7 +97,7 @@ internal class FeeStateFactory( feeState = feeState.copy( feeSelectorState = updatedFeeSelectorState, fee = fee, - isFeeApproximate = isFeeApproximate(fee), + isFeeApproximate = isFeeApproximate(state.amountState), ), ) } @@ -180,11 +183,29 @@ internal class FeeStateFactory( return noErrors && (isNotEmptyCustom || isNotCustom) } - private fun isFeeApproximate(fee: Fee): Boolean { + private fun isFeeApproximate(state: AmountState): Boolean { val cryptoCurrencyStatus = feeCryptoCurrencyStatusProvider() ?: return false + val amount = (state as? AmountState.Data)?.amountTextField?.cryptoAmount ?: return false return isFeeApproximateUseCase( networkId = cryptoCurrencyStatus.currency.network.id, - amountType = fee.amount.type, + amountType = amount.type.toSdkAmountType(), ) } + + private fun AmountType.toSdkAmountType(): SdkAmountType { + return when (this) { + AmountType.CoinType -> SdkAmountType.Coin + is AmountType.FiatType -> error("unsupported type FiatType") + AmountType.ReserveType -> SdkAmountType.Reserve + is AmountType.TokenType -> SdkAmountType.Token( + Token( + name = this.token.name, + symbol = this.token.symbol, + contractAddress = this.token.contractAddress, + decimals = this.token.decimals, + id = this.token.id.rawCurrencyId, + ), + ) + } + } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeCustomFieldConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeCustomFieldConverter.kt index b621339ffd..25849d36f7 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeCustomFieldConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeCustomFieldConverter.kt @@ -49,7 +49,7 @@ internal class SendFeeCustomFieldConverter( override fun convert(value: Fee): ImmutableList { return when (value) { - is Fee.Ethereum -> ethereumCustomFeeConverter.convert(value) + is Fee.Ethereum.Legacy -> ethereumCustomFeeConverter.convert(value) is Fee.Bitcoin -> bitcoinCustomFeeConverter.convert(value) is Fee.Kaspa -> kaspaCustomFeeConverter.convert(value) else -> persistentListOf() diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/custom/EthereumCustomFeeConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/custom/EthereumCustomFeeConverter.kt index fdee783db4..b44dc65a5b 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/custom/EthereumCustomFeeConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/custom/EthereumCustomFeeConverter.kt @@ -27,9 +27,9 @@ internal class EthereumCustomFeeConverter( private val stateRouterProvider: Provider, private val appCurrencyProvider: Provider, private val feeCryptoCurrencyStatusProvider: Provider, -) : CustomFeeConverter { +) : CustomFeeConverter { - override fun convert(value: Fee.Ethereum): ImmutableList { + override fun convert(value: Fee.Ethereum.Legacy): ImmutableList { val feeValue = value.amount.value val feeCurrency = feeCryptoCurrencyStatusProvider()?.value return persistentListOf( @@ -91,7 +91,10 @@ internal class EthereumCustomFeeConverter( ) } - override fun convertBack(normalFee: Fee.Ethereum, value: ImmutableList): Fee.Ethereum { + override fun convertBack( + normalFee: Fee.Ethereum.Legacy, + value: ImmutableList, + ): Fee.Ethereum.Legacy { val feeAmount = value[FEE_AMOUNT].value.parseToBigDecimal(value[FEE_AMOUNT].decimals) val gasPrice = value[GAS_PRICE].value.parseToBigDecimal(GAS_DECIMALS).toBigInteger() val gasLimit = value[GAS_LIMIT].value.parseToBigDecimal(GAS_DECIMALS).toBigInteger() diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendEventEffect.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendEventEffect.kt index 53378ca7f7..dee53dccc8 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendEventEffect.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendEventEffect.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import com.tangem.core.ui.components.BasicDialog -import com.tangem.core.ui.components.DialogButton +import com.tangem.core.ui.components.DialogButtonUM import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.resolveReference @@ -45,24 +45,24 @@ internal fun SendEventEffect(event: StateEvent, snackbarHostState: Sn @Composable internal fun SendAlert(state: SendAlertState, onDismiss: () -> Unit) { - val confirmButton: DialogButton - val dismissButton: DialogButton? + val confirmButton: DialogButtonUM + val dismissButton: DialogButtonUM? val onActionClick = state.onConfirmClick if (onActionClick != null) { - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = state.confirmButtonText.resolveReference(), onClick = { onActionClick() onDismiss() }, ) - dismissButton = DialogButton( + dismissButton = DialogButtonUM( title = stringResource(id = R.string.common_cancel), onClick = onDismiss, ) } else { - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = state.confirmButtonText.resolveReference(), onClick = onDismiss, ) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt index 992da60b9f..3324d89836 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt @@ -163,13 +163,15 @@ private fun SendScreenContent(uiState: SendUiState, currentState: SendUiCurrentS when (state.type) { SendUiStateType.Amount -> AmountScreenContent( amountState = uiState.amountState, - isBalanceHiding = uiState.isBalanceHidden, + isBalanceHidden = uiState.isBalanceHidden, clickIntents = uiState.clickIntents, + modifier = Modifier.background(TangemTheme.colors.background.tertiary), ) SendUiStateType.EditAmount -> AmountScreenContent( - amountState = uiState.editAmountState!!, - isBalanceHiding = uiState.isBalanceHidden, + amountState = uiState.editAmountState, + isBalanceHidden = uiState.isBalanceHidden, clickIntents = uiState.clickIntents, + modifier = Modifier.background(TangemTheme.colors.background.tertiary), ) SendUiStateType.Recipient -> SendRecipientContent( uiState = uiState.recipientState, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt index 75729e23b1..013b9e7258 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt @@ -33,8 +33,8 @@ import com.tangem.features.send.impl.presentation.state.fields.SendTextField import com.tangem.features.send.impl.presentation.state.previewdata.RecipientStatePreviewData import com.tangem.features.send.impl.presentation.state.previewdata.SendClickIntentsStub import com.tangem.core.ui.components.containers.FooterContainer +import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents -import com.tangem.utils.StringsSigns.STARS import kotlinx.collections.immutable.ImmutableList private const val ADDRESS_FIELD_KEY = "ADDRESS_FIELD_KEY" @@ -205,7 +205,7 @@ private fun LazyListScope.listItem( AnimateRecentAppearance(item.isVisible) { ListItemWithIcon( title = title, - subtitle = if (isBalanceHidden) STARS else item.subtitle.resolveReference(), + subtitle = item.subtitle.orMaskWithStars(isBalanceHidden).resolveReference(), info = item.timestamp?.resolveReference(), subtitleEndOffset = item.subtitleEndOffset, subtitleIconRes = item.subtitleIconRes, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/SendContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/SendContent.kt index 2576d7b37f..d65fdea64d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/SendContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/SendContent.kt @@ -26,8 +26,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.core.ui.utils.toTimeFormat import com.tangem.features.send.impl.R import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.features.send.impl.presentation.state.previewdata.ConfirmStatePreviewData @@ -69,8 +73,14 @@ private fun LazyListScope.blocks(uiState: SendUiState) { modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12), ) { TransactionDoneTitle( - titleRes = R.string.sent_transaction_sent_title, - date = timestamp, + title = resourceReference(R.string.sent_transaction_sent_title), + subtitle = resourceReference( + R.string.send_date_format, + wrappedList( + timestamp.toTimeFormat(DateTimeFormatters.dateFormatter), + timestamp.toTimeFormat(), + ), + ), ) } RecipientBlock( diff --git a/features/staking/impl/build.gradle.kts b/features/staking/impl/build.gradle.kts index 15213f79d6..808810f2d7 100644 --- a/features/staking/impl/build.gradle.kts +++ b/features/staking/impl/build.gradle.kts @@ -61,7 +61,9 @@ dependencies { implementation(projects.domain.legacy) implementation(projects.domain.models) implementation(projects.domain.transaction) + implementation(projects.domain.transaction.models) implementation(projects.domain.txhistory) + implementation(projects.domain.feedback) /** Common */ implementation(projects.common.ui) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/StakingAnalyticsEvents.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/StakingAnalyticsEvents.kt new file mode 100644 index 0000000000..336486156f --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/StakingAnalyticsEvents.kt @@ -0,0 +1,157 @@ +package com.tangem.features.staking.impl.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.domain.staking.model.stakekit.action.StakingActionType + +internal sealed class StakingAnalyticsEvents( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent( + category = "Staking", + event = event, + params = params, +) { + + data class StakingInfoScreenOpened( + val validatorsCount: Int, + val token: String, + ) : StakingAnalyticsEvents( + event = "Staking Info Screen Opened", + params = mapOf( + "Validators Count" to validatorsCount.toString(), + AnalyticsParam.TOKEN_PARAM to token, + ), + ) + + data class WhatIsStaking(val token: String) : StakingAnalyticsEvents( + event = "Link - What Is Staking", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + ), + ) + + data class AmountScreenOpened(val token: String) : StakingAnalyticsEvents( + event = "Amount Screen Opened", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + ), + ) + + data class ConfirmationScreenOpened( + val token: String, + val validator: String, + val action: StakingActionType, + ) : StakingAnalyticsEvents( + event = "Confirmation Screen Opened", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + "Validator" to validator, + "Action" to action.name, + ), + ) + + data class StakeInProgressScreenOpened( + val validator: String, + val token: String, + ) : StakingAnalyticsEvents( + event = "Stake In Progress Screen Opened", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + "Validator" to validator, + ), + ) + + data class RewardScreenOpened(val token: String) : StakingAnalyticsEvents( + event = "Reward Screen Opened", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + ), + ) + + data class AmountSelectCurrency( + val token: String, + val isAppCurrency: Boolean, + ) : StakingAnalyticsEvents( + event = "Selected Currency", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + AnalyticsParam.TYPE to if (isAppCurrency) "App Currency" else "Token", + ), + ) + + data class ButtonMax(val token: String) : StakingAnalyticsEvents( + event = "Button - Max", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + ), + ) + + data class ButtonCancel( + val source: StakeScreenSource, + val token: String, + ) : StakingAnalyticsEvents( + event = "Button - Cancel", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + AnalyticsParam.SOURCE to source.name, + ), + ) + + data class ButtonValidator( + val source: StakeScreenSource, + val token: String, + ) : StakingAnalyticsEvents( + event = "Button - Validator", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + AnalyticsParam.SOURCE to source.name, + ), + ) + + data class ButtonRewards( + val token: String, + ) : StakingAnalyticsEvents( + event = "Button - Rewards", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + ), + ) + + data class ButtonAction( + val action: String, + val token: String, + val validator: String, + ) : StakingAnalyticsEvents( + event = "Button - $action", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + "Validator" to validator, + ), + ) + + data object ButtonShare : StakingAnalyticsEvents(event = "Button - Share") + + data object ButtonExplore : StakingAnalyticsEvents(event = "Button - Explore") + + data class StakingError( + val token: String, + ) : StakingAnalyticsEvents( + event = "Errors", + params = mapOf(AnalyticsParam.TOKEN_PARAM to token), + ) + + data class TransactionError( + val token: String, + ) : StakingAnalyticsEvents( + event = "Error - Transaction Rejected", + params = mapOf(AnalyticsParam.TOKEN_PARAM to token), + ) +} + +enum class StakeScreenSource { + Info, + Amount, + Confirmation, + Validators, +} \ No newline at end of file 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 new file mode 100644 index 0000000000..013bf2e49f --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt @@ -0,0 +1,122 @@ +package com.tangem.features.staking.impl.analytics.utils + +import com.tangem.common.ui.bottomsheet.permission.state.ApproveType +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.staking.model.stakekit.action.StakingActionType +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.features.staking.impl.analytics.StakeScreenSource +import com.tangem.features.staking.impl.analytics.StakingAnalyticsEvents +import com.tangem.features.staking.impl.presentation.state.* + +internal class StakingAnalyticSender( + private val analyticsEventHandler: AnalyticsEventHandler, +) { + + fun initialInfoScreen(value: StakingUiState) { + val initialInfoState = value.initialInfoState as? StakingStates.InitialInfoState.Data + val validatorState = initialInfoState?.yieldBalance as? InnerYieldBalanceState.Data + val validatorCount = validatorState?.balance + ?.filterNot { it.validator?.address.isNullOrBlank() } + ?.distinctBy { it.validator?.address } + ?.size ?: 0 + + analyticsEventHandler.send( + StakingAnalyticsEvents.StakingInfoScreenOpened( + validatorsCount = validatorCount, + token = value.cryptoCurrencySymbol, + ), + ) + } + + fun confirmationScreen(value: StakingUiState) { + val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data + val validatorState = confirmationState?.validatorState as? ValidatorState.Content + val validatorName = validatorState?.chosenValidator?.name ?: return + + if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) return + + analyticsEventHandler.send( + StakingAnalyticsEvents.ConfirmationScreenOpened( + token = value.cryptoCurrencySymbol, + validator = validatorName, + action = getStakingActionType(value), + ), + ) + } + + fun screenCancel(value: StakingUiState) { + analyticsEventHandler.send( + StakingAnalyticsEvents.ButtonCancel( + source = when (value.currentStep) { + StakingStep.InitialInfo -> StakeScreenSource.Info + StakingStep.Amount -> StakeScreenSource.Amount + StakingStep.Confirmation -> StakeScreenSource.Confirmation + StakingStep.Validators, + StakingStep.RewardsValidators, + -> StakeScreenSource.Validators + }, + token = value.cryptoCurrencyName, + ), + ) + } + + fun sendTransactionApprovalAnalytics(tokenCryptoCurrency: CryptoCurrency) { + analyticsEventHandler.send( + Basic.TransactionSent( + sentFrom = AnalyticsParam.TxSentFrom.Approve( + blockchain = tokenCryptoCurrency.network.name, + token = tokenCryptoCurrency.symbol, + feeType = AnalyticsParam.FeeType.Normal, + permissionType = ApproveType.LIMITED.name, + ), + memoType = Basic.TransactionSent.MemoType.Null, + ), + ) + } + + fun sendTransactionStakingAnalytics(value: StakingUiState) { + val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data + val validatorState = confirmationState?.validatorState as? ValidatorState.Content + val validatorName = validatorState?.chosenValidator?.name ?: return + + analyticsEventHandler.send( + StakingAnalyticsEvents.ButtonAction( + action = getStakingActionType(value).name, + token = value.cryptoCurrencyName, + validator = validatorName, + ), + ) + + analyticsEventHandler.send( + Basic.TransactionSent( + sentFrom = AnalyticsParam.TxSentFrom.Staking( + blockchain = value.cryptoCurrencyName, + token = value.cryptoCurrencySymbol, + feeType = AnalyticsParam.FeeType.Normal, + ), + memoType = Basic.TransactionSent.MemoType.Null, + ), + ) + analyticsEventHandler.send( + StakingAnalyticsEvents.StakeInProgressScreenOpened( + validator = validatorName, + token = value.cryptoCurrencySymbol, + ), + ) + } + + private fun getStakingActionType(value: StakingUiState): StakingActionType { + val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data + + return when (value.actionType) { + StakingActionCommonType.ENTER -> StakingActionType.STAKE + StakingActionCommonType.EXIT -> StakingActionType.UNSTAKE + StakingActionCommonType.PENDING_REWARDS, + StakingActionCommonType.PENDING_OTHER, + -> confirmationState?.pendingAction?.type ?: StakingActionType.UNKNOWN + } + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/di/StakingRouterModule.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/di/StakingRouterModule.kt index c64b0dd4f2..1e01cf0b3d 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/di/StakingRouterModule.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/di/StakingRouterModule.kt @@ -1,8 +1,9 @@ package com.tangem.features.staking.impl.di +import com.tangem.common.routing.AppRouter import com.tangem.core.navigation.url.UrlOpener -import com.tangem.features.staking.impl.navigation.DefaultStakingRouter import com.tangem.features.staking.api.navigation.StakingRouter +import com.tangem.features.staking.impl.navigation.DefaultStakingRouter import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,9 +19,10 @@ internal object StakingRouterModule { @Provides @ActivityScoped - fun provideStakingRouter(urlOpener: UrlOpener): StakingRouter { + fun provideStakingRouter(urlOpener: UrlOpener, router: AppRouter): StakingRouter { return DefaultStakingRouter( urlOpener = urlOpener, + router = router, ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/navigation/DefaultStakingRouter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/navigation/DefaultStakingRouter.kt index 464d28694e..6acad0658b 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/navigation/DefaultStakingRouter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/navigation/DefaultStakingRouter.kt @@ -1,15 +1,33 @@ package com.tangem.features.staking.impl.navigation import androidx.fragment.app.Fragment +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.staking.impl.presentation.StakingFragment internal class DefaultStakingRouter( private val urlOpener: UrlOpener, + private val router: AppRouter, ) : InnerStakingRouter { override fun getEntryFragment(): Fragment = StakingFragment.create() override fun openUrl(url: String) { urlOpener.openUrl(url) } + + override fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) { + router.pop { isSuccess -> + if (isSuccess) { + router.push( + AppRoute.CurrencyDetails( + userWalletId = userWalletId, + currency = currency, + ), + ) + } + } + } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/navigation/InnerStakingRouter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/navigation/InnerStakingRouter.kt index d4197489fb..e1fec4bab1 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/navigation/InnerStakingRouter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/navigation/InnerStakingRouter.kt @@ -1,8 +1,12 @@ package com.tangem.features.staking.impl.navigation +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.staking.api.navigation.StakingRouter interface InnerStakingRouter : StakingRouter { fun openUrl(url: String) + + fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/StakingFragment.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/StakingFragment.kt index cb590bcb4c..1740d65084 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/StakingFragment.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/StakingFragment.kt @@ -54,6 +54,7 @@ internal class StakingFragment : ComposeFragment() { StakingStateRouter( appRouter = appRouter, stateController = stateController, + analyticsEventsHandler = analyticsEventsHandler, ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/FeeState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/FeeState.kt index ad93ecd3a3..d74bc8fe32 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/FeeState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/FeeState.kt @@ -6,7 +6,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency import java.math.BigDecimal @Immutable -sealed class FeeState { +internal sealed class FeeState { data class Content( val fee: Fee?, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerConfirmationStakingState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerConfirmationStakingState.kt index babc93a2dd..29ce1f54f5 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerConfirmationStakingState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerConfirmationStakingState.kt @@ -1,6 +1,6 @@ package com.tangem.features.staking.impl.presentation.state -enum class InnerConfirmationStakingState { +internal enum class InnerConfirmationStakingState { ASSENT, IN_PROGRESS, COMPLETED, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt index ef7e4e8fec..c989de7fb1 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt @@ -4,40 +4,34 @@ import androidx.compose.runtime.Immutable import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.staking.model.stakekit.BalanceType import com.tangem.domain.staking.model.stakekit.PendingAction +import com.tangem.domain.staking.model.stakekit.RewardBlockType import com.tangem.domain.staking.model.stakekit.Yield import kotlinx.collections.immutable.ImmutableList import java.math.BigDecimal -sealed class InnerYieldBalanceState { +internal sealed class InnerYieldBalanceState { data class Data( val rewardsCrypto: String, val rewardsFiat: String, - val isRewardsToClaim: Boolean, - val isRewardsClaimable: Boolean, - val balance: ImmutableList, + val rewardBlockType: RewardBlockType, + val balance: ImmutableList, ) : InnerYieldBalanceState() data object Empty : InnerYieldBalanceState() } -// TODO staking get rid of unstable types @Immutable -data class BalanceGroupedState( - val items: ImmutableList, - val footer: TextReference?, +internal data class BalanceState( + val id: String, val title: TextReference, val type: BalanceType, + val subtitle: TextReference?, val isClickable: Boolean, -) - -@Immutable -data class BalanceState( - val validator: Yield.Validator, val cryptoValue: String, val cryptoDecimal: BigDecimal, val cryptoAmount: TextReference, val fiatAmount: TextReference, val rawCurrencyId: String?, - val unbondingPeriod: TextReference, + val validator: Yield.Validator?, val pendingActions: ImmutableList, ) \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingAlertState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingAlertState.kt deleted file mode 100644 index c133fe00ca..0000000000 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingAlertState.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.features.staking.impl.presentation.state - -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.features.staking.impl.R - -@Immutable -internal sealed class StakingAlertState { - - abstract val title: TextReference? - abstract val message: TextReference - open val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok) - open val onConfirmClick: (() -> Unit)? = null - - data class GenericError( - override val title: TextReference? = TODO(), - override val onConfirmClick: () -> Unit, - ) : StakingAlertState() { - override val message: TextReference = resourceReference(R.string.common_unknown_error) - override val confirmButtonText: TextReference = - resourceReference(id = R.string.common_support) - } -} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingEvent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingEvent.kt deleted file mode 100644 index dbc800ea01..0000000000 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingEvent.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.features.staking.impl.presentation.state - -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.extensions.TextReference - -@Immutable -internal sealed class StakingEvent { - - data class ShowSnackBar(val text: TextReference) : StakingEvent() - - data class ShowAlert(val alert: StakingAlertState) : StakingEvent() -} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt index c338f2c047..c989a34d8c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt @@ -1,35 +1,31 @@ package com.tangem.features.staking.impl.presentation.state +import androidx.annotation.StringRes +import com.tangem.common.ui.notifications.NotificationUM 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.extensions.* import com.tangem.features.staking.impl.R -internal sealed class StakingNotification(val config: NotificationConfig) { +internal object StakingNotification { sealed class Error( title: TextReference, subtitle: TextReference, - iconResId: Int = R.drawable.ic_alert_24, buttonState: NotificationConfig.ButtonsState? = null, onCloseClick: (() -> Unit)? = null, - ) : StakingNotification( - config = NotificationConfig( - title = title, - subtitle = subtitle, - iconResId = iconResId, - buttonsState = buttonState, - onCloseClick = onCloseClick, - ), + ) : NotificationUM.Error( + title = title, + subtitle = subtitle, + iconResId = R.drawable.ic_alert_24, + buttonState = buttonState, + onCloseClick = onCloseClick, ) { - data class StakedPositionNotFoundError(val message: String) : Error( + data class StakedPositionNotFoundError(val message: String) : StakingNotification.Error( title = stringReference(message), subtitle = stringReference(message), ) - data class Common(val subtitle: TextReference) : Error( + data class Common(val subtitle: TextReference) : StakingNotification.Error( title = resourceReference(R.string.common_error), subtitle = subtitle, ) @@ -40,39 +36,66 @@ internal sealed class StakingNotification(val config: NotificationConfig) { subtitle: TextReference, buttonsState: NotificationConfig.ButtonsState? = null, onCloseClick: (() -> Unit)? = null, - ) : StakingNotification( - config = NotificationConfig( - title = title, - subtitle = subtitle, - iconResId = R.drawable.ic_alert_circle_24, - buttonsState = buttonsState, - onCloseClick = onCloseClick, - ), + ) : NotificationUM.Warning( + title = title, + subtitle = subtitle, + iconResId = R.drawable.ic_alert_circle_24, + buttonsState = buttonsState, + onCloseClick = onCloseClick, + ) { + data class TransactionInProgress( + val title: TextReference, + val description: TextReference, + ) : StakingNotification.Warning(title = title, subtitle = description) + } + + sealed class Info( + title: TextReference, + subtitle: TextReference, + buttonsState: NotificationConfig.ButtonsState? = null, + onCloseClick: (() -> Unit)? = null, + ) : NotificationUM.Info( + title = title, + subtitle = subtitle, + buttonsState = buttonsState, + onCloseClick = onCloseClick, + ) { data class EarnRewards( - val subtitleResourceId: Int, - val currencyName: String, - ) : Warning( + val subtitleText: TextReference, + ) : StakingNotification.Info( title = resourceReference(R.string.staking_notification_earn_rewards_title), - subtitle = resourceReference( - subtitleResourceId, - wrappedList(currencyName), - ), + subtitle = subtitleText, ) data class Unstake( val cooldownPeriodDays: Int, - ) : Warning( + @StringRes val subtitleRes: Int, + ) : StakingNotification.Info( title = resourceReference(R.string.common_unstake), subtitle = resourceReference( - R.string.staking_notification_unstake_text, - wrappedList(cooldownPeriodDays, cooldownPeriodDays), + subtitleRes, + wrappedList( + pluralReference( + id = R.plurals.common_days, + count = cooldownPeriodDays, + formatArgs = wrappedList(cooldownPeriodDays), + ), + ), ), ) - data class TransactionInProgress( + data class PendingAction( val title: TextReference, - val description: TextReference, - ) : Warning(title = title, subtitle = description) + val text: TextReference, + ) : StakingNotification.Info( + title = title, + subtitle = text, + ) + + data object TronRevote : StakingNotification.Info( + title = resourceReference(R.string.staking_revote), + subtitle = resourceReference(R.string.staking_notifications_revote_tron_text), + ) } } \ No newline at end of file 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 1bf70095ff..0b04c60e11 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 @@ -2,9 +2,12 @@ package com.tangem.features.staking.impl.presentation.state import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.navigationButtons.NavigationButtonsState +import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.features.staking.impl.presentation.state.events.StakingEvent import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub import com.tangem.features.staking.impl.presentation.state.transformers.SetButtonsStateTransformer import com.tangem.features.staking.impl.presentation.state.transformers.SetTitleTransformer @@ -17,7 +20,9 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class StakingStateController @Inject constructor() { +internal class StakingStateController @Inject constructor( + urlOpener: UrlOpener, +) { val value: StakingUiState get() = uiState.value @@ -25,7 +30,7 @@ internal class StakingStateController @Inject constructor() { val uiState: StateFlow get() = mutableUiState.asStateFlow() - private val buttonsTransformer = SetButtonsStateTransformer() + private val buttonsTransformer = SetButtonsStateTransformer(urlOpener) private val titleTransformer = SetTitleTransformer fun update(function: (StakingUiState) -> StakingUiState) { @@ -46,11 +51,24 @@ internal class StakingStateController @Inject constructor() { mutableUiState.update(function = titleTransformer::transform) } + fun updateEvent(event: StakingEvent?) { + mutableUiState.update { + it.copy(event = event?.let { triggeredEvent(event, ::dismissAlert) } ?: consumedEvent()) + } + } + + private fun dismissAlert() { + mutableUiState.update { it.copy(event = consumedEvent()) } + } + private fun getInitialState(): StakingUiState { return StakingUiState( title = TextReference.EMPTY, + subtitle = null, clickIntents = StakingClickIntentsStub, + walletName = "", cryptoCurrencyName = "", + cryptoCurrencySymbol = "", currentStep = StakingStep.InitialInfo, initialInfoState = StakingStates.InitialInfoState.Empty(), amountState = AmountState.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 03cc906908..69cca72194 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 @@ -1,14 +1,21 @@ package com.tangem.features.staking.impl.presentation.state import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.features.staking.impl.analytics.StakingAnalyticsEvents +import com.tangem.features.staking.impl.analytics.utils.StakingAnalyticSender internal class StakingStateRouter( private val appRouter: AppRouter, private val stateController: StakingStateController, + private val analyticsEventsHandler: AnalyticsEventHandler, ) { + private val analyticSender = StakingAnalyticSender(analyticsEventsHandler) + fun onBackClick() { + analyticSender.screenCancel(stateController.value) appRouter.pop() stateController.clear() } @@ -26,9 +33,7 @@ internal class StakingStateRouter( StakingStep.Validators, StakingStep.Amount, -> showConfirmation() - StakingStep.Confirmation -> { - // TODO staking handle - } + StakingStep.Confirmation -> showInitial() } } @@ -50,14 +55,21 @@ internal class StakingStateRouter( } private fun showInitial() { + analyticSender.initialInfoScreen(stateController.value) stateController.update { it.copy(currentStep = StakingStep.InitialInfo) } } - fun showRewardsValidators() { + private fun showRewardsValidators() { + analyticsEventsHandler.send( + StakingAnalyticsEvents.RewardScreenOpened(stateController.value.cryptoCurrencySymbol), + ) stateController.update { it.copy(currentStep = StakingStep.RewardsValidators) } } - fun showAmount() { + private fun showAmount() { + analyticsEventsHandler.send( + StakingAnalyticsEvents.AmountScreenOpened(stateController.value.cryptoCurrencySymbol), + ) stateController.update { it.copy(currentStep = StakingStep.Amount) } } @@ -65,7 +77,8 @@ internal class StakingStateRouter( stateController.update { it.copy(currentStep = StakingStep.Validators) } } - fun showConfirmation() { + private fun showConfirmation() { + analyticSender.confirmationScreen(stateController.value) stateController.update { it.copy(currentStep = StakingStep.Confirmation) } } } \ No newline at end of file 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 e9b4c44faa..9e4db06b4e 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 @@ -3,15 +3,19 @@ package com.tangem.features.staking.impl.presentation.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.navigationButtons.NavigationButtonsState +import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.list.RoundedListWithDividersItemData import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig import com.tangem.domain.staking.model.stakekit.PendingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType -import com.tangem.features.staking.impl.presentation.state.transformers.InfoType +import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType +import com.tangem.features.staking.impl.presentation.state.events.StakingEvent import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents import kotlinx.collections.immutable.ImmutableList +import java.math.BigDecimal /** * Ui states of the staking screen @@ -19,8 +23,11 @@ import kotlinx.collections.immutable.ImmutableList @Immutable internal data class StakingUiState( val title: TextReference, + val subtitle: TextReference?, val clickIntents: StakingClickIntents, + val walletName: String, val cryptoCurrencyName: String, + val cryptoCurrencySymbol: String, val currentStep: StakingStep, val initialInfoState: StakingStates.InitialInfoState, val amountState: AmountState, @@ -52,11 +59,12 @@ internal sealed class StakingStates { sealed class InitialInfoState : StakingStates() { data class Data( override val isPrimaryButtonEnabled: Boolean, + val showBanner: Boolean, val infoItems: ImmutableList, val aprRange: TextReference, val onInfoClick: (InfoType) -> Unit, val yieldBalance: InnerYieldBalanceState, - val isStakeMoreAvailable: Boolean, + val pullToRefreshConfig: PullToRefreshConfig, ) : InitialInfoState() data class Empty( @@ -83,12 +91,13 @@ internal sealed class StakingStates { val innerState: InnerConfirmationStakingState, val feeState: FeeState, val validatorState: ValidatorState, - val pendingActions: ImmutableList, - val notifications: ImmutableList, - val footerText: String, + val pendingAction: PendingAction?, + val pendingActions: ImmutableList?, + val notifications: ImmutableList, + val footerText: TextReference, val transactionDoneState: TransactionDoneState, - val pendingActionInProgress: PendingAction? = null, val isApprovalNeeded: Boolean, + val reduceAmountBy: BigDecimal?, ) : ConfirmationState() data class Empty( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/InfoType.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/InfoType.kt new file mode 100644 index 0000000000..b923734491 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/InfoType.kt @@ -0,0 +1,9 @@ +package com.tangem.features.staking.impl.presentation.state.bottomsheet + +internal enum class InfoType { + ANNUAL_PERCENTAGE_RATE, + UNBONDING_PERIOD, + REWARD_CLAIMING, + WARMUP_PERIOD, + REWARD_SCHEDULE, +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/StakingActionSelectionBottomSheetConfig.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/StakingActionSelectionBottomSheetConfig.kt new file mode 100644 index 0000000000..873ba40422 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/StakingActionSelectionBottomSheetConfig.kt @@ -0,0 +1,11 @@ +package com.tangem.features.staking.impl.presentation.state.bottomsheet + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.staking.model.stakekit.PendingAction + +internal data class StakingActionSelectionBottomSheetConfig( + val title: TextReference, + val actions: List, + val onActionSelect: (PendingAction) -> Unit, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/StakingInfoBottomSheetConfig.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/StakingInfoBottomSheetConfig.kt index f8cc06e495..12b327a0d0 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/StakingInfoBottomSheetConfig.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/StakingInfoBottomSheetConfig.kt @@ -3,7 +3,7 @@ package com.tangem.features.staking.impl.presentation.state.bottomsheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.extensions.TextReference -data class StakingInfoBottomSheetConfig( +internal data class StakingInfoBottomSheetConfig( val title: TextReference, val text: TextReference, ) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt new file mode 100644 index 0000000000..9fd91d3cb8 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt @@ -0,0 +1,141 @@ +package com.tangem.features.staking.impl.presentation.state.converters + +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.staking.model.stakekit.BalanceItem +import com.tangem.domain.staking.model.stakekit.BalanceType +import com.tangem.domain.staking.model.stakekit.BalanceType.Companion.isClickable +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.BalanceState +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toPersistentList +import org.joda.time.DateTime +import java.util.Calendar + +internal class BalanceItemConverter( + private val cryptoCurrencyStatusProvider: Provider, + private val appCurrencyProvider: Provider, + private val yield: Yield, +) : Converter { + override fun convert(value: BalanceItem): BalanceState? { + val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() + val appCurrency = appCurrencyProvider() + val cryptoCurrency = cryptoCurrencyStatus.currency + + val validator = yield.validators.firstOrNull { + value.validatorAddress?.contains(it.address, ignoreCase = true) == true + } + + val cryptoAmount = value.amount + val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount) + + val title = value.type.getTitle(validator?.name) + return title?.let { + BalanceState( + id = value.id, + validator = validator, + title = title, + subtitle = getSubtitle(value), + type = value.type, + cryptoValue = cryptoAmount.parseBigDecimal(cryptoCurrency.decimals), + cryptoDecimal = cryptoAmount, + cryptoAmount = stringReference( + BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = cryptoAmount, + cryptoCurrency = cryptoCurrency, + ), + ), + fiatAmount = stringReference( + BigDecimalFormatter.formatFiatAmount( + fiatAmount = fiatAmount, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ), + ), + rawCurrencyId = value.rawCurrencyId, + pendingActions = value.pendingActions.toPersistentList(), + isClickable = value.type.isClickable(), + ) + } + } + + private fun BalanceType.getTitle(validatorName: String?) = when (this) { + BalanceType.PREPARING, + BalanceType.STAKED, + -> validatorName?.let { stringReference(it) } + BalanceType.UNSTAKED -> resourceReference(R.string.staking_unstaked) + BalanceType.UNSTAKING -> resourceReference(R.string.staking_unstaking) + BalanceType.LOCKED -> resourceReference(R.string.staking_locked) + BalanceType.AVAILABLE, + BalanceType.REWARDS, + BalanceType.UNLOCKING, + BalanceType.UNKNOWN, + -> null + } + + private fun getSubtitle(balance: BalanceItem) = when (balance.type) { + BalanceType.UNSTAKING -> getUnbondingDate(balance.date) + BalanceType.UNSTAKED -> resourceReference(R.string.staking_tap_to_withdraw) + BalanceType.LOCKED -> resourceReference(R.string.staking_tap_to_unlock) + BalanceType.PREPARING -> { + val warmupPeriod = yield.metadata.warmupPeriod.days + combinedReference( + resourceReference(R.string.staking_details_warmup_period), + stringReference(" "), + pluralReference(R.plurals.common_days, warmupPeriod, wrappedList(warmupPeriod)), + ) + } + BalanceType.AVAILABLE, + BalanceType.STAKED, + BalanceType.UNLOCKING, + BalanceType.REWARDS, + BalanceType.UNKNOWN, + -> null + } + + private fun getUnbondingDate(date: DateTime?): TextReference { + val unbondingPeriod = yield.metadata.cooldownPeriod.days + if (date == null) { + return combinedReference( + resourceReference(R.string.staking_details_unbonding_period), + stringReference(" "), + pluralReference(R.plurals.common_days, unbondingPeriod, wrappedList(unbondingPeriod)), + ) + } + + val nowCalendar = Calendar.getInstance() + nowCalendar.resetHours() + + val endDate = Calendar.getInstance() + endDate.timeInMillis = date.millis + endDate.resetHours() + + val days = ((endDate.timeInMillis - nowCalendar.timeInMillis) / DAY_IN_MILLIS).toInt() + return if (days > 0) { + resourceReference( + R.string.common_left, + wrappedList( + pluralReference(R.plurals.common_days, days, wrappedList(days)), + ), + ) + } else { + resourceReference(R.string.common_today) + } + } + + private fun Calendar.resetHours() { + this[Calendar.HOUR_OF_DAY] = 0 + this[Calendar.MINUTE] = 0 + this[Calendar.SECOND] = 0 + this[Calendar.MILLISECOND] = 0 + } + + private companion object { + const val DAY_IN_MILLIS = 24 * 60 * 60 * 1000 + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt index a1c35482de..e3cc2f8e07 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt @@ -1,18 +1,18 @@ package com.tangem.features.staking.impl.presentation.state.converters -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.staking.model.stakekit.* +import com.tangem.domain.staking.model.stakekit.BalanceItem +import com.tangem.domain.staking.model.stakekit.BalanceType +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.utils.Provider -import com.tangem.utils.StringsSigns.PLUS import com.tangem.utils.converter.Converter -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList import java.math.BigDecimal @@ -46,56 +46,50 @@ internal class RewardsValidatorStateConverter( } val cryptoValue = balance.amount val fiatValue = cryptoCurrencyStatus.value.fiatRate?.times(cryptoValue) - val unbondingPeriod = yield.metadata.cooldownPeriod.days + validator?.toBalanceState( + balance = balance, cryptoCurrencyStatus = cryptoCurrencyStatus, cryptoValue = cryptoValue, fiatValue = fiatValue, - unbondingPeriod = pluralReference( - id = R.plurals.common_days, - count = unbondingPeriod, - formatArgs = wrappedList(unbondingPeriod), - ), - pendingActions = balance.pendingActions.toPersistentList(), ) } private fun Yield.Validator.toBalanceState( + balance: BalanceItem, cryptoCurrencyStatus: CryptoCurrencyStatus, cryptoValue: BigDecimal, fiatValue: BigDecimal?, - unbondingPeriod: TextReference, - pendingActions: ImmutableList, ): BalanceState { val appCurrency = appCurrencyProvider() val cryptoCurrency = cryptoCurrencyStatus.currency - val cryptoAmount = stringReference( BigDecimalFormatter.formatCryptoAmount( cryptoAmount = cryptoValue, cryptoCurrency = cryptoCurrency, ), ) - val fiatAmount = combinedReference( - stringReference(PLUS), - stringReference( - BigDecimalFormatter.formatFiatAmount( - fiatAmount = fiatValue, - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ), + val fiatAmount = stringReference( + BigDecimalFormatter.formatFiatAmount( + fiatAmount = fiatValue, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, ), ) return BalanceState( + id = balance.id, validator = this, + title = stringReference(this.name), + subtitle = null, cryptoValue = cryptoValue.parseBigDecimal(cryptoCurrency.decimals), cryptoDecimal = cryptoValue, cryptoAmount = cryptoAmount, fiatAmount = fiatAmount, rawCurrencyId = cryptoCurrency.id.rawCurrencyId, - unbondingPeriod = unbondingPeriod, - pendingActions = pendingActions, + pendingActions = balance.pendingActions.toPersistentList(), + isClickable = true, + type = balance.type, ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt index 85b64c0645..ae45ca8d1f 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt @@ -1,22 +1,14 @@ package com.tangem.features.staking.impl.presentation.state.converters import com.tangem.common.extensions.isZero -import com.tangem.core.ui.extensions.pluralReference -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.utils.BigDecimalFormatter -import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.stakekit.* import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.features.staking.impl.R -import com.tangem.features.staking.impl.presentation.state.BalanceGroupedState -import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState +import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.utils.Provider import com.tangem.utils.converter.Converter -import com.tangem.utils.isNullOrZero import kotlinx.collections.immutable.toPersistentList internal class YieldBalancesConverter( @@ -24,6 +16,11 @@ internal class YieldBalancesConverter( private val appCurrencyProvider: Provider, private val yield: Yield, ) : Converter { + + private val balanceItemConverter by lazy(LazyThreadSafetyMode.NONE) { + BalanceItemConverter(cryptoCurrencyStatusProvider, appCurrencyProvider, yield) + } + override fun convert(value: Unit): InnerYieldBalanceState { val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() val appCurrency = appCurrencyProvider() @@ -34,10 +31,7 @@ internal class YieldBalancesConverter( return if (yieldBalance is YieldBalance.Data) { val cryptoRewardsValue = yieldBalance.getRewardStakingBalance() val fiatRewardsValue = cryptoCurrencyStatus.value.fiatRate?.times(cryptoRewardsValue) - val groupedBalances = getGroupedBalance(yieldBalance.balance) - val isRewardsClaimable = yieldBalance.balance.items - .filter { it.type == BalanceType.REWARDS } - .any { it.pendingActions.isNotEmpty() } + InnerYieldBalanceState.Data( rewardsCrypto = BigDecimalFormatter.formatCryptoAmount( cryptoAmount = cryptoRewardsValue, @@ -48,109 +42,35 @@ internal class YieldBalancesConverter( fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol, ), - isRewardsToClaim = !cryptoRewardsValue.isNullOrZero(), - isRewardsClaimable = isRewardsClaimable, - balance = groupedBalances, + rewardBlockType = getRewardBlockType(), + balance = yieldBalance.balance.items.mapBalances(), ) } else { InnerYieldBalanceState.Empty } } - private fun getGroupedBalance(balance: YieldBalanceItem) = balance.items - .sortedBy { it.type } - .groupBy { it.type.toGroup() } - .mapNotNull { item -> - val (title, footer) = getGroupTitle(item.key) - val isClickable = getClickableType(item.key) - title?.let { - BalanceGroupedState( - items = item.value.mapBalances().toPersistentList(), - footer = footer, - title = it, - type = item.key, - isClickable = isClickable, - ) - } - } - .filterNot { it.items.isEmpty() } + private fun List.mapBalances() = asSequence() + .filterNot { it.amount.isZero() || it.type == BalanceType.REWARDS } + .mapNotNull(balanceItemConverter::convert) + .sortedByDescending { it.cryptoDecimal } + .sortedBy { it.type.order } .toPersistentList() - private fun List.mapBalances(): List { + private fun getRewardBlockType(): RewardBlockType { val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() - val appCurrency = appCurrencyProvider() - val cryptoCurrency = cryptoCurrencyStatus.currency - return this - .filterNot { it.amount.isZero() } - .mapNotNull { balance -> - val validator = yield.validators.firstOrNull { - balance.validatorAddress?.contains(it.address, ignoreCase = true) == true - } - val cryptoAmount = balance.amount - val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount) - val unbondingPeriod = yield.metadata.cooldownPeriod.days - validator?.let { - BalanceState( - validator = validator, - cryptoValue = cryptoAmount.parseBigDecimal(cryptoCurrency.decimals), - cryptoDecimal = cryptoAmount, - cryptoAmount = stringReference( - BigDecimalFormatter.formatCryptoAmount( - cryptoAmount = cryptoAmount, - cryptoCurrency = cryptoCurrency, - ), - ), - fiatAmount = stringReference( - BigDecimalFormatter.formatFiatAmount( - fiatAmount = fiatAmount, - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ), - ), - rawCurrencyId = balance.rawCurrencyId, - unbondingPeriod = pluralReference( - id = R.plurals.common_days, - count = unbondingPeriod, - formatArgs = wrappedList(unbondingPeriod), - ), - pendingActions = balance.pendingActions.toPersistentList(), - ) - } - } - } + val yieldBalance = cryptoCurrencyStatus.value.yieldBalance as? YieldBalance.Data + val isRewardsClaimable = yieldBalance?.balance?.items + ?.filter { it.type == BalanceType.REWARDS } + ?.any { it.pendingActions.isNotEmpty() } + ?: false - private fun BalanceType.toGroup() = when (this) { - BalanceType.REWARDS, - BalanceType.UNKNOWN, - -> BalanceType.UNKNOWN - else -> this - } + val isSolana = isSolana(cryptoCurrencyStatus.currency.network.id.value) - private fun getGroupTitle(type: BalanceType) = when (type) { - BalanceType.STAKED -> resourceReference(R.string.staking_active) to - resourceReference(R.string.staking_active_footer) - BalanceType.UNSTAKED -> resourceReference(R.string.staking_unstaked) to - resourceReference(R.string.staking_unstaked_footer) - BalanceType.UNSTAKING -> resourceReference(R.string.staking_unstaking) to null - BalanceType.AVAILABLE -> null to null - BalanceType.PREPARING -> null to null - BalanceType.REWARDS -> null to null - BalanceType.LOCKED -> null to null - BalanceType.UNLOCKING -> null to null - BalanceType.UNKNOWN -> null to null - } - - private fun getClickableType(type: BalanceType) = when (type) { - BalanceType.STAKED, - BalanceType.UNSTAKED, - -> true - BalanceType.AVAILABLE, - BalanceType.UNSTAKING, - BalanceType.PREPARING, - BalanceType.REWARDS, - BalanceType.LOCKED, - BalanceType.UNLOCKING, - BalanceType.UNKNOWN, - -> false + return when { + isSolana -> RewardBlockType.RewardUnavailable + isRewardsClaimable -> RewardBlockType.Rewards + else -> RewardBlockType.NoRewards + } } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt new file mode 100644 index 0000000000..f89f99f6a0 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt @@ -0,0 +1,29 @@ +package com.tangem.features.staking.impl.presentation.state.events + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.alerts.models.AlertUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.features.staking.impl.R + +@Immutable +internal sealed class StakingAlertUM : AlertUM { + + data class GenericError( + override val onConfirmClick: () -> Unit, + ) : StakingAlertUM() { + override val title: TextReference = resourceReference(R.string.common_error) + override val message: TextReference = resourceReference(R.string.common_unknown_error) + override val confirmButtonText: TextReference = resourceReference(id = R.string.common_support) + } + + data class StakingError( + val code: String, + override val onConfirmClick: () -> Unit, + ) : StakingAlertUM() { + override val title: TextReference = resourceReference(R.string.common_error) + override val message: TextReference = resourceReference(R.string.generic_error_code, wrappedList(code)) + override val confirmButtonText: TextReference = resourceReference(id = R.string.common_support) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEvent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEvent.kt new file mode 100644 index 0000000000..a1b9cce6d9 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEvent.kt @@ -0,0 +1,15 @@ +package com.tangem.features.staking.impl.presentation.state.events + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.alerts.models.AlertUM +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal sealed class StakingEvent { + + data class ShowSnackBar(val text: TextReference) : StakingEvent() + + data class ShowAlert(val alert: AlertUM) : StakingEvent() + + data class ShowShareDialog(val txUrl: String) : StakingEvent() +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt new file mode 100644 index 0000000000..5c29cbbf5b --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt @@ -0,0 +1,49 @@ +package com.tangem.features.staking.impl.presentation.state.events + +import com.tangem.common.ui.alerts.SendTransactionAlertConverter +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.features.staking.impl.presentation.state.StakingStateController + +internal class StakingEventFactory( + private val stateController: StakingStateController, + private val popBackStack: () -> Unit, + private val onFailedTxEmailClick: (String) -> Unit, +) { + + fun createGenericErrorAlert(error: String) { + val alert = StakingEvent.ShowAlert( + StakingAlertUM.GenericError( + onConfirmClick = { onFailedTxEmailClick(error) }, + ), + ) + stateController.updateEvent(alert) + } + + fun createSendTransactionErrorAlert(error: SendTransactionError?) { + val alert = error?.let { + SendTransactionAlertConverter( + popBackStack = popBackStack, + onFailedTxEmailClick = onFailedTxEmailClick, + ).convert(error) + }?.let { + StakingEvent.ShowAlert(it) + } + stateController.updateEvent(alert) + } + + fun createStakingErrorAlert(error: StakingError) { + val alert = StakingEvent.ShowAlert( + StakingAlertUM.StakingError( + code = error.toString(), + onConfirmClick = { onFailedTxEmailClick(error.toString()) }, + ), + ) + stateController.updateEvent(alert) + } + + fun createShareDialog(txUrl: String) { + val event = StakingEvent.ShowShareDialog(txUrl) + stateController.updateEvent(event) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt new file mode 100644 index 0000000000..55446f2303 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt @@ -0,0 +1,102 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import com.tangem.domain.staking.FetchStakingYieldBalanceUseCase +import com.tangem.domain.tokens.FetchPendingTransactionsUseCase +import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.utils.coroutines.DelayedWork +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.* + +@Suppress("LongParameterList") +internal class StakingBalanceUpdater @AssistedInject constructor( + private val fetchPendingTransactionsUseCase: FetchPendingTransactionsUseCase, + private val updateDelayedNetworkStatusUseCase: UpdateDelayedNetworkStatusUseCase, + private val stakingYieldBalanceUseCase: FetchStakingYieldBalanceUseCase, + private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val getTxHistoryItemsUseCase: GetTxHistoryItemsUseCase, + @DelayedWork private val coroutineScope: CoroutineScope, + @Assisted private val userWallet: UserWallet, + @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, +) { + fun scheduleUpdates() { + coroutineScope.launch { + listOf( + // we should update network to find pending tx after 1 sec + async { + fetchPendingTransactionsUseCase(userWallet.walletId, setOf(cryptoCurrencyStatus.currency.network)) + }, + // we should update tx history and network for new balances + async { + updateStakeBalance() + }, + async { + updateTxHistory() + }, + async { + updateNetworkStatuses() + }, + ).awaitAll() + } + } + + suspend fun instantUpdate() { + coroutineScope { + listOf( + async { + updateStakeBalance() + }, + async { + updateNetworkStatuses(delay = 0) + }, + ).awaitAll() + } + } + + private suspend fun updateNetworkStatuses(delay: Long = BALANCE_UPDATE_DELAY) { + updateDelayedNetworkStatusUseCase( + userWalletId = userWallet.walletId, + network = cryptoCurrencyStatus.currency.network, + delayMillis = delay, + refresh = true, + ) + } + + private suspend fun updateStakeBalance() { + stakingYieldBalanceUseCase( + userWalletId = userWallet.walletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + refresh = true, + ) + } + + private suspend fun updateTxHistory() { + delay(BALANCE_UPDATE_DELAY) + val txHistoryItemsCountEither = getTxHistoryItemsCountUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrencyStatus.currency, + ) + + txHistoryItemsCountEither.onRight { + getTxHistoryItemsUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrencyStatus.currency, + refresh = true, + ) + } + } + + @AssistedFactory + interface Factory { + fun create(cryptoCurrencyStatus: CryptoCurrencyStatus, userWallet: UserWallet): StakingBalanceUpdater + } + + private companion object { + const val BALANCE_UPDATE_DELAY = 11_000L + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt new file mode 100644 index 0000000000..0fdda4ab88 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt @@ -0,0 +1,238 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import arrow.core.Either +import arrow.core.getOrElse +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.extensions.isZero +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.domain.staking.EstimateGasUseCase +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.stakekit.PendingAction +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.transaction.ActionParams +import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.usecase.GetAllowanceUseCase +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.staking.impl.presentation.state.StakingStateController +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.ValidatorState +import com.tangem.features.staking.impl.presentation.state.utils.isSolanaWithdraw +import com.tangem.utils.extensions.orZero +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import java.math.BigDecimal + +@Suppress("LongParameterList") +internal class StakingFeeTransactionLoader @AssistedInject constructor( + private val stateController: StakingStateController, + private val getAllowanceUseCase: GetAllowanceUseCase, + private val getFeeUseCase: GetFeeUseCase, + private val estimateGasUseCase: EstimateGasUseCase, + @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, + @Assisted private val userWallet: UserWallet, + @Assisted private val yield: Yield, + @Assisted private val stakingApproval: StakingApproval, +) { + + suspend fun getFee( + pendingAction: PendingAction?, + pendingActions: ImmutableList?, + onFeeError: (GetFeeError) -> Unit, + onStakingFee: (Fee) -> Unit, + onApprovalFee: (TransactionFee) -> Unit, + ) { + val state = stateController.value + val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data + ?: error("No confirmation state") + val validatorState = confirmationState.validatorState as? ValidatorState.Content + ?: error("No validator provided") + + val amount = (state.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value + ?: error("No amount provided") + + val validatorAddress = validatorState.chosenValidator.address + + val approval = stakingApproval as? StakingApproval.Needed + if (approval != null) { + val allowance = getAllowanceUseCase( + userWalletId = userWallet.walletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + spenderAddress = approval.spenderAddress, + ).getOrElse { BigDecimal.ZERO } + + if (allowance < amount) { + getApproveFee( + amount = amount, + validatorAddress = validatorAddress, + onApprovalFee = onApprovalFee, + onApprovalFeeError = onFeeError, + ) + } else { + estimateGas( + pendingAction = pendingAction, + pendingActions = pendingActions, + amount = amount, + validatorAddress = validatorAddress, + onFeeError = onFeeError, + onStakingFee = onStakingFee, + ) + } + } else { + estimateGas( + pendingAction = pendingAction, + pendingActions = pendingActions, + amount = amount, + validatorAddress = validatorAddress, + onFeeError = onFeeError, + onStakingFee = onStakingFee, + ) + } + } + + private suspend fun estimateGas( + pendingAction: PendingAction?, + pendingActions: ImmutableList?, + amount: BigDecimal, + validatorAddress: String, + onFeeError: (GetFeeError) -> Unit, + onStakingFee: (Fee) -> Unit, + ) { + val sourceAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value + ?: error("No available address") + + val gasEstimate = if (isSolanaWithdraw(cryptoCurrencyStatus.currency.network.id.value, pendingActions)) { + val result = coroutineScope { + pendingActions?.map { action -> + async { + // Simultaneous or quick api calls can sometimes return ZERO fee + estimateFeeRetry { + estimateFee( + amount = amount, + sourceAddress = sourceAddress, + validatorAddress = validatorAddress, + action = action, + ) + }.getOrElse { + onFeeError(GetFeeError.DataError(Throwable(it.toString()))) + null + } + } + }?.awaitAll()?.filterNotNull() + } + + if (result.isNullOrEmpty()) { + onFeeError(GetFeeError.UnknownError) + return + } + + val totalAmount = result.sumOf { it.amount } + val totalGasLimit = result.sumOf { it.gasLimit?.toBigDecimalOrNull().orZero() } + StakingGasEstimate( + amount = totalAmount, + token = result.first().token, + gasLimit = totalGasLimit.toPlainString().orEmpty(), + ) + } else { + estimateFee( + amount = amount, + sourceAddress = sourceAddress, + validatorAddress = validatorAddress, + action = pendingAction, + ).getOrElse { + onFeeError(GetFeeError.DataError(Throwable(it.toString()))) + return + } + } + + onStakingFee( + Fee.Common( + Amount( + currencySymbol = gasEstimate.token.symbol, + value = gasEstimate.amount, + decimals = gasEstimate.token.decimals, + ), + ), + ) + } + + private suspend fun estimateFee( + amount: BigDecimal, + sourceAddress: String, + validatorAddress: String, + action: PendingAction?, + ) = estimateGasUseCase( + userWalletId = userWallet.walletId, + network = cryptoCurrencyStatus.currency.network, + params = ActionParams( + actionCommonType = stateController.value.actionType, + integrationId = yield.id, + amount = amount, + address = sourceAddress, + validatorAddress = validatorAddress, + token = yield.token, + passthrough = action?.passthrough, + type = action?.type, + ), + ) + + private suspend fun getApproveFee( + amount: BigDecimal, + validatorAddress: String, + onApprovalFee: (TransactionFee) -> Unit, + onApprovalFeeError: (GetFeeError) -> Unit, + ) { + getFeeUseCase( + amount = amount, + destination = validatorAddress, + userWallet = userWallet, + cryptoCurrency = cryptoCurrencyStatus.currency, + ).fold( + ifRight = { fee -> + onApprovalFee(fee) + }, + ifLeft = { error -> + onApprovalFeeError(error) + }, + ) + } + + private suspend fun estimateFeeRetry( + times: Int = 3, + delay: Long = 1000, + block: suspend () -> Either, + ): Either { + repeat(times - 1) { + val feeResult = block() + feeResult.fold( + ifLeft = { return feeResult }, + ifRight = { + if (!it.amount.isZero()) return feeResult + }, + ) + delay(delay) + } + return block() + } + + @AssistedFactory + interface Factory { + fun create( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + yield: Yield, + stakingApproval: StakingApproval, + ): StakingFeeTransactionLoader + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt new file mode 100644 index 0000000000..e6e0b2b6e0 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt @@ -0,0 +1,271 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import arrow.core.getOrElse +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.staking.GetConstructedStakingTransactionUseCase +import com.tangem.domain.staking.GetStakingTransactionUseCase +import com.tangem.domain.staking.SaveUnsubmittedHashUseCase +import com.tangem.domain.staking.SubmitHashUseCase +import com.tangem.domain.staking.model.stakekit.PendingAction +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.staking.model.stakekit.transaction.ActionParams +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionType +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.usecase.SendMultipleTransactionUseCase +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.staking.impl.analytics.StakingAnalyticsEvents +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalculateSubtractedAmount +import com.tangem.features.staking.impl.presentation.state.utils.isSolanaWithdraw +import com.tangem.utils.extensions.orZero +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import timber.log.Timber +import java.math.BigDecimal + +@Suppress("LongParameterList") +internal class StakingTransactionSender @AssistedInject constructor( + private val stateController: StakingStateController, + private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, + private val getStakingTransactionUseCase: GetStakingTransactionUseCase, + private val getConstructedStakingTransactionUseCase: GetConstructedStakingTransactionUseCase, + private val sendMultipleTransactionUseCase: SendMultipleTransactionUseCase, + private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val submitHashUseCase: SubmitHashUseCase, + private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, + @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, + @Assisted private val userWallet: UserWallet, + @Assisted private val yield: Yield, + @Assisted private val isAmountSubtractAvailable: Boolean, +) { + + private val balanceUpdater: StakingBalanceUpdater + get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet) + + suspend fun constructAndSendTransactions( + onConstructSuccess: (List) -> Unit, + onConstructError: (StakingError) -> Unit, + onSendSuccess: (String) -> Unit, + onSendError: (SendTransactionError?) -> Unit, + ) { + val state = stateController.value + + val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data + ?: error("No confirmation state") + val fee = (confirmationState.feeState as? FeeState.Content)?.fee + ?: error("No fee provided") + + val stakingTransactions = getStakingTransactions( + state = state, + confirmationState = confirmationState, + onConstructError = onConstructError, + ) + + val fullTransactionsData = getConstructedTransactions( + stakingTransactions = stakingTransactions, + fee = fee, + onConstructError = onConstructError, + ) + + if (fullTransactionsData.isNullOrEmpty()) { + onConstructError(StakingError.UnknownError) + return + } + + onConstructSuccess(fullTransactionsData.map { it.stakeKitTransaction }) + + sendStakingTransaction( + fullTransactionsData = fullTransactionsData, + onSendSuccess = onSendSuccess, + onSendError = onSendError, + ) + } + + private suspend fun getStakingTransactions( + state: StakingUiState, + confirmationState: StakingStates.ConfirmationState.Data, + onConstructError: (StakingError) -> Unit, + ) = coroutineScope { + val isAllWithdrawAction = isSolanaWithdraw( + cryptoCurrencyStatus.currency.network.id.value, + confirmationState.pendingActions, + ) + if (isAllWithdrawAction) { + confirmationState.pendingActions?.map { action -> + async { + getStakingTransaction( + state = state, + action = action, + confirmationState = confirmationState, + onConstructError = onConstructError, + ) + } + }?.awaitAll()?.flatten() + } else { + getStakingTransaction( + state = state, + confirmationState = confirmationState, + onConstructError = onConstructError, + ) + } + } + + private suspend fun getConstructedTransactions( + stakingTransactions: List?, + fee: Fee, + onConstructError: (StakingError) -> Unit, + ) = coroutineScope { + stakingTransactions?.filterNot { it.type == StakingTransactionType.APPROVAL } + ?.map { transaction -> + async { + getConstructedStakingTransactionUseCase( + networkId = cryptoCurrencyStatus.currency.network.id.value, + fee = fee, + transactionId = transaction.id, + ).fold( + ifRight = { (constructedTransaction, transactionData) -> + FullTransactionData( + stakeKitTransaction = constructedTransaction, + tangemTransaction = transactionData, + ) + }, + ifLeft = { + onConstructError(it) + null + }, + ) + } + }?.awaitAll()?.filterNotNull() + } + + private suspend fun getStakingTransaction( + state: StakingUiState, + confirmationState: StakingStates.ConfirmationState.Data, + action: PendingAction? = confirmationState.pendingAction, + onConstructError: (StakingError) -> Unit, + ): List { + val validatorState = confirmationState.validatorState as? ValidatorState.Content + ?: error("No validator provided") + val fee = (confirmationState.feeState as? FeeState.Content)?.fee + ?: error("No fee provided") + val defaultAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value + ?: error("No available address") + val amountState = state.amountState as? AmountState.Data + ?: error("No amount provided") + + val validatorAddress = validatorState.chosenValidator.address + val amount = getAmount(amountState, fee, confirmationState.reduceAmountBy) + + return getStakingTransactionUseCase( + userWalletId = userWallet.walletId, + network = cryptoCurrencyStatus.currency.network, + params = ActionParams( + actionCommonType = state.actionType, + integrationId = yield.id, + amount = amount, + address = defaultAddress, + validatorAddress = validatorAddress, + token = yield.token, + passthrough = action?.passthrough, + type = action?.type, + ), + ).getOrElse { + analyticsEventHandler.send(StakingAnalyticsEvents.StakingError(state.cryptoCurrencyName)) + onConstructError(it) + return emptyList() + } + } + + private suspend fun sendStakingTransaction( + fullTransactionsData: List, + onSendSuccess: (txUrl: String) -> Unit, + onSendError: (SendTransactionError?) -> Unit, + ) { + sendMultipleTransactionUseCase( + txsData = fullTransactionsData.map { it.tangemTransaction }, + userWallet = userWallet, + network = cryptoCurrencyStatus.currency.network, + ).fold( + ifLeft = { error -> + onSendError(error) + }, + ifRight = { transactionHashes -> + submitHash( + transactionIds = fullTransactionsData.map { it.stakeKitTransaction.id }, + transactionHashes = transactionHashes, + ) + val txUrl = getExplorerTransactionUrlUseCase( + txHash = transactionHashes.last(), + networkId = cryptoCurrencyStatus.currency.network.id, + ).getOrElse { "" } + + balanceUpdater.scheduleUpdates() + onSendSuccess(txUrl) + }, + ) + } + + private suspend fun submitHash(transactionIds: List, transactionHashes: List) { + transactionIds + .zip(transactionHashes) + .forEach { (transactionId, transactionHash) -> + submitHashUseCase.submitHash( + transactionId = transactionId, + transactionHash = transactionHash, + ) + .onLeft { + analyticsEventHandler.send( + StakingAnalyticsEvents.StakingError(stateController.value.cryptoCurrencyName), + ) + saveUnsubmittedHashUseCase.invoke( + transactionId = transactionId, + transactionHash = transactionHash, + ) + }.onRight { + Timber.d("Successful hash submission") + } + } + } + + private fun getAmount(amountState: AmountState.Data, fee: Fee, reduceAmountBy: BigDecimal?): BigDecimal { + val amountValue = amountState.amountTextField.cryptoAmount.value ?: error("No amount value") + val feeValue = fee.amount.value ?: error("No fee value") + val isEnterAction = stateController.value.actionType == StakingActionCommonType.ENTER + + return checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable = isAmountSubtractAvailable && isEnterAction, + cryptoCurrencyStatus = cryptoCurrencyStatus, + amountValue = amountValue, + feeValue = feeValue, + reduceAmountBy = reduceAmountBy.orZero(), + ) + } + + private data class FullTransactionData( + val stakeKitTransaction: StakingTransaction, + val tangemTransaction: TransactionData.Compiled, + ) + + @AssistedFactory + interface Factory { + fun create( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + yield: Yield, + isAmountSubtractAvailable: Boolean, + ): StakingTransactionSender + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ConfirmationStatePreviewData.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ConfirmationStatePreviewData.kt index 807e584ca3..b5b6e9061f 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ConfirmationStatePreviewData.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ConfirmationStatePreviewData.kt @@ -3,13 +3,14 @@ package com.tangem.features.staking.impl.presentation.state.previewdata import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.AmountType.Coin import com.tangem.blockchain.common.transaction.Fee +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.Yield.Validator.ValidatorStatus import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.* -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.ValidatorState import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal @@ -18,7 +19,7 @@ internal object ConfirmationStatePreviewData { private val validatorList = listOf( Yield.Validator( address = "0xa6e768fef2d1af36c0cfdb276422e7881a83e951", - status = "active", + status = ValidatorStatus.ACTIVE, name = "Luganodes", image = "https://assets.stakek.it/validators/luganodes.png", apr = BigDecimal("0.054823398040640445"), @@ -30,7 +31,7 @@ internal object ConfirmationStatePreviewData { ), Yield.Validator( address = "0x35b1ca0f398905cf752e6fe122b51c88022fca32", - status = "active", + status = ValidatorStatus.ACTIVE, name = "InfStones", image = "https://assets.stakek.it/validators/infstones.png", apr = BigDecimal("0.057786472172836965"), @@ -42,7 +43,7 @@ internal object ConfirmationStatePreviewData { ), Yield.Validator( address = "0xd14a87025109013b0a2354a775cb335f926af65a", - status = "active", + status = ValidatorStatus.ACTIVE, name = "Kiln", image = "https://assets.stakek.it/validators/kiln.png", apr = BigDecimal("0.057786472172836965"), @@ -78,15 +79,19 @@ internal object ConfirmationStatePreviewData { chosenValidator = validatorList[0], availableValidators = validatorList, ), - footerText = "You stake \$715.11 and will be receiving ~\$35 monthly", + footerText = stringReference("You stake \$715.11 and will be receiving ~\$35 monthly"), notifications = persistentListOf( - StakingNotification.Warning.EarnRewards( - currencyName = "Solana", - subtitleResourceId = R.string.staking_notification_earn_rewards_text_period_day, + StakingNotification.Info.EarnRewards( + subtitleText = resourceReference( + id = R.string.staking_notification_earn_rewards_text_period_day, + formatArgs = wrappedList("Solana"), + ), ), ), transactionDoneState = TransactionDoneState.Empty, - pendingActions = persistentListOf(), + pendingAction = null, isApprovalNeeded = false, + reduceAmountBy = null, + pendingActions = null, ) } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt index f31cd5f501..6ad1e50645 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt @@ -3,21 +3,27 @@ package com.tangem.features.staking.impl.presentation.state.previewdata import com.tangem.core.ui.components.list.RoundedListWithDividersItemData import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig import com.tangem.domain.staking.model.stakekit.BalanceType +import com.tangem.domain.staking.model.stakekit.RewardBlockType import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.features.staking.impl.R -import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.BalanceState +import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState +import com.tangem.features.staking.impl.presentation.state.StakingStates import kotlinx.collections.immutable.persistentListOf internal object InitialStakingStatePreview { val defaultState = StakingStates.InitialInfoState.Data( isPrimaryButtonEnabled = true, + showBanner = true, aprRange = stringReference("2.54-5.12%"), infoItems = persistentListOf( RoundedListWithDividersItemData( id = R.string.staking_details_available, startText = TextReference.Res(R.string.staking_details_available), endText = TextReference.Str("15 SOL"), + isEndTextHideable = true, ), RoundedListWithDividersItemData( id = R.string.staking_details_annual_percentage_rate, @@ -52,44 +58,39 @@ internal object InitialStakingStatePreview { ), onInfoClick = {}, yieldBalance = InnerYieldBalanceState.Empty, - isStakeMoreAvailable = true, + pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), ) val stateWithYield = defaultState.copy( yieldBalance = InnerYieldBalanceState.Data( rewardsFiat = "100 $", rewardsCrypto = "100 SOL", - isRewardsToClaim = false, - isRewardsClaimable = false, + rewardBlockType = RewardBlockType.RewardUnavailable, balance = persistentListOf( - BalanceGroupedState( - title = stringReference("Staked"), - footer = null, - type = BalanceType.STAKED, - isClickable = true, - items = persistentListOf( - BalanceState( - cryptoValue = "100", - cryptoAmount = stringReference("100 SOL"), - cryptoDecimal = "100".toBigDecimal(), - fiatAmount = stringReference("100 $"), - rawCurrencyId = null, - validator = Yield.Validator( - address = "address", - status = "status", - name = "Binance", - image = null, - website = null, - apr = "5".toBigDecimal(), - commission = null, - stakedBalance = null, - votingPower = null, - preferred = false, - ), - unbondingPeriod = stringReference("3 days"), - pendingActions = persistentListOf(), - ), + BalanceState( + id = "id", + title = stringReference("Binance"), + cryptoValue = "100", + cryptoAmount = stringReference("100 SOL"), + cryptoDecimal = "100".toBigDecimal(), + fiatAmount = stringReference("100 $"), + rawCurrencyId = null, + validator = Yield.Validator( + address = "address", + status = Yield.Validator.ValidatorStatus.ACTIVE, + name = "Binance", + image = null, + website = null, + apr = "5".toBigDecimal(), + commission = null, + stakedBalance = null, + votingPower = null, + preferred = false, ), + pendingActions = persistentListOf(), + isClickable = true, + type = BalanceType.STAKED, + subtitle = null, ), ), ), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt index c8e5a5d35a..b4c0e2a299 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt @@ -1,23 +1,34 @@ package com.tangem.features.staking.impl.presentation.state.stub +import com.tangem.common.ui.notifications.NotificationUM import com.tangem.domain.staking.model.stakekit.PendingAction import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.features.staking.impl.presentation.state.BalanceState -import com.tangem.features.staking.impl.presentation.state.transformers.InfoType +import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents import kotlinx.collections.immutable.ImmutableList +import java.math.BigDecimal -object StakingClickIntentsStub : StakingClickIntents { +@Suppress("TooManyFunctions") +internal object StakingClickIntentsStub : StakingClickIntents { override fun onBackClick() {} - override fun onNextClick(actionType: StakingActionCommonType?, pendingActions: ImmutableList) {} + override fun onNextClick( + actionTypeToOverwrite: StakingActionCommonType?, + pendingAction: PendingAction?, + pendingActions: ImmutableList?, + ) { + } - override fun onActionClick(pendingAction: PendingAction?) {} + override fun onActionClick() {} override fun onPrevClick() {} + override fun onRefreshSwipe(isRefreshing: Boolean) {} + override fun onInitialInfoBannerClick() {} override fun onInfoClick(infoType: InfoType) {} @@ -46,5 +57,25 @@ object StakingClickIntentsStub : StakingClickIntents { override fun onShareClick() {} + override fun onFailedTxEmailClick(errorMessage: String) {} + override fun onActiveStake(activeStake: BalanceState) {} + + override fun getFee(pendingAction: PendingAction?, pendingActions: ImmutableList?) { + } + + override fun onAmountReduceByClick( + reduceAmountBy: BigDecimal, + reduceAmountByDiff: BigDecimal, + notification: Class, + ) { + } + + override fun onAmountReduceToClick(reduceAmountTo: BigDecimal, notification: Class) {} + + override fun onNotificationCancel(notification: Class) {} + + override fun openTokenDetails(cryptoCurrency: CryptoCurrency) {} + + override fun onActiveStakeAnalytic() {} } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ActionTypeActiveStakeTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ActionTypeActiveStakeTransformer.kt new file mode 100644 index 0000000000..31e1f97f96 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ActionTypeActiveStakeTransformer.kt @@ -0,0 +1,33 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.domain.staking.model.stakekit.BalanceType +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.Network +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.lib.crypto.BlockchainUtils.isTron +import com.tangem.utils.transformer.Transformer + +internal class ActionTypeActiveStakeTransformer( + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val activeStake: BalanceState, +) : Transformer { + + override fun transform(prevState: StakingUiState): StakingUiState { + val isTronStakedBalance = isTronStakedBalance( + networkId = cryptoCurrencyStatus.currency.network.id, + activeStake = activeStake, + ) + val actionType = if (activeStake.pendingActions.isEmpty() || isTronStakedBalance) { + StakingActionCommonType.EXIT + } else { + StakingActionCommonType.PENDING_OTHER + } + + return prevState.copy(actionType = actionType) + } + + private fun isTronStakedBalance(networkId: Network.ID, activeStake: BalanceState): Boolean { + return isTron(networkId.value) && activeStake.type == BalanceType.STAKED + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/AddStakingErrorTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/AddStakingErrorTransformer.kt index 69278afeb6..5acae90ea3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/AddStakingErrorTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/AddStakingErrorTransformer.kt @@ -1,28 +1,37 @@ package com.tangem.features.staking.impl.presentation.state.transformers +import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.staking.model.stakekit.StakingError -import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.FeeState +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.StakingUiState import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.toPersistentList internal class AddStakingErrorTransformer( - private val error: StakingError, + private val error: StakingError? = null, ) : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState + val notifications = buildList { + addAll(confirmationState.notifications) + error?.let { add(convertToNotification(it)) } + }.toPersistentList() + return prevState.copy( confirmationState = confirmationState.copy( - notifications = (confirmationState.notifications + convertToNotification(error)).toPersistentList(), + notifications = notifications, feeState = FeeState.Error, ), ) } - private fun convertToNotification(error: StakingError): StakingNotification { + private fun convertToNotification(error: StakingError): NotificationUM { return when (error) { is StakingError.StakedPositionNotFoundError -> StakingNotification.Error.StakedPositionNotFoundError( message = error.toString(), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/AddStakingNotificationsTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/AddStakingNotificationsTransformer.kt new file mode 100644 index 0000000000..f4f4da10b4 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/AddStakingNotificationsTransformer.kt @@ -0,0 +1,327 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications +import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.core.ui.extensions.pluralReference +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.staking.model.stakekit.BalanceType +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.staking.model.stakekit.action.StakingActionType +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.FeeState +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.StakingUiState +import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalculateSubtractedAmount +import com.tangem.features.staking.impl.presentation.state.utils.checkFeeCoverage +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.lib.crypto.BlockchainUtils.isCosmos +import com.tangem.lib.crypto.BlockchainUtils.isTron +import com.tangem.utils.Provider +import com.tangem.utils.extensions.orZero +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +@Suppress("LongParameterList") +internal class AddStakingNotificationsTransformer( + private val cryptoCurrencyStatusProvider: Provider, + private val appCurrencyProvider: Provider, + private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?, + private val currencyWarning: CryptoCurrencyWarning?, + private val validatorError: Throwable?, + private val feeError: GetFeeError?, + private val currencyCheck: CryptoCurrencyCheck, + private val isSubtractAvailable: Boolean, + private val yield: Yield, +) : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { + val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() + val balance = cryptoCurrencyStatus.value.amount.orZero() + + val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState + val amountState = prevState.amountState as? AmountState.Data ?: return prevState + val feeState = confirmationState.feeState as? FeeState.Content + + val amountValue = amountState.amountTextField.cryptoAmount.value.orZero() + val feeValue = feeState?.fee?.amount?.value.orZero() + val reduceAmountBy = confirmationState.reduceAmountBy.orZero() + + val isEnterAction = prevState.actionType == StakingActionCommonType.ENTER + val isFeeCoverage = checkFeeCoverage( + amountValue = amountValue, + feeValue = feeValue, + balance = balance, + isSubtractAvailable = isSubtractAvailable, + reduceAmountBy = reduceAmountBy, + ) + val sendingAmount = if (isEnterAction) { + checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable = isSubtractAvailable, + cryptoCurrencyStatus = cryptoCurrencyStatus, + amountValue = amountValue, + feeValue = feeValue, + reduceAmountBy = reduceAmountBy, + ) + } else { + // No amount is taken from account balance on exit or pending actions + BigDecimal.ZERO + } + + val notifications = buildList { + // errors + addErrorNotifications( + prevState = prevState, + feeError = feeError, + sendingAmount = sendingAmount, + onReload = { + prevState.clickIntents.getFee( + confirmationState.pendingAction, + confirmationState.pendingActions, + ) + }, + feeValue = feeValue, + ) + // warnings + addWarningNotifications( + prevState = prevState, + amountState = amountState, + feeState = feeState, + sendingAmount = sendingAmount, + isFeeCoverage = isFeeCoverage && isEnterAction, + ) + + addInfoNotifications(prevState) + }.toImmutableList() + + return prevState.copy( + confirmationState = confirmationState.copy( + notifications = notifications.toImmutableList(), + isPrimaryButtonEnabled = notifications.none { + it is StakingNotification.Error || + it is NotificationUM.Error || + it is NotificationUM.Warning.NetworkFeeUnreachable + }, + ), + ) + } + + private fun MutableList.addErrorNotifications( + prevState: StakingUiState, + onReload: () -> Unit, + feeError: GetFeeError?, + sendingAmount: BigDecimal, + feeValue: BigDecimal, + ) { + val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() + val cryptoCurrency = cryptoCurrencyStatus.currency + val network = cryptoCurrency.network + + if (feeError != null) { + addFeeUnreachableNotification( + feeError = feeError, + tokenName = cryptoCurrencyStatusProvider().currency.name, + onReload = onReload, + ) + } + addStakeExceedBalanceNotification( + feeAmount = feeValue, + sendingAmount = sendingAmount, + actionType = prevState.actionType, + isSubtractionAvailable = isSubtractAvailable, + cryptoCurrencyStatus = cryptoCurrencyStatus, + onClick = prevState.clickIntents::openTokenDetails, + ) + addExceedsBalanceNotification( + cryptoCurrencyWarning = currencyWarning, + cryptoCurrencyStatus = cryptoCurrencyStatus, + shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(network.backendId), + onClick = prevState.clickIntents::openTokenDetails, + onAnalyticsEvent = { /* no-op */ }, + ) + if (!BlockchainUtils.isCardano(network.id.value)) { + addDustWarningNotification( + dustValue = currencyCheck.dustValue, + feeValue = feeValue, + sendingAmount = sendingAmount, + cryptoCurrencyStatus = cryptoCurrencyStatus, + feeCurrencyStatus = feeCryptoCurrencyStatus, + ) + } + addTransactionLimitErrorNotification( + utxoLimit = currencyCheck.utxoAmountLimit, + cryptoCurrency = cryptoCurrency, + onReduceClick = prevState.clickIntents::onAmountReduceToClick, + ) + addReserveAmountErrorNotification( + reserveAmount = currencyCheck.reserveAmount, + sendingAmount = sendingAmount, + cryptoCurrency = cryptoCurrency, + isAccountFunded = false, + ) + } + + private fun MutableList.addWarningNotifications( + prevState: StakingUiState, + amountState: AmountState.Data, + feeState: FeeState.Content?, + sendingAmount: BigDecimal, + isFeeCoverage: Boolean, + ) { + val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() + val appCurrency = appCurrencyProvider() + val cryptoCurrency = cryptoCurrencyStatus.currency + + addExistentialWarningNotification( + existentialDeposit = currencyCheck.existentialDeposit, + feeAmount = feeState?.fee?.amount?.value.orZero(), + receivedAmount = sendingAmount, + cryptoCurrencyStatus = cryptoCurrencyStatus, + onReduceClick = prevState.clickIntents::onAmountReduceByClick, + ) + addFeeCoverageNotification( + isFeeCoverage = isFeeCoverage, + amountField = amountState.amountTextField, + sendingValue = sendingAmount, + appCurrency = appCurrency, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ) + + // blockchain specific + addValidateTransactionNotifications( + dustValue = currencyCheck.dustValue.orZero(), + fee = feeState?.fee, + validationError = validatorError, + cryptoCurrency = cryptoCurrency, + onReduceClick = prevState.clickIntents::onAmountReduceToClick, + ) + } + + private fun MutableList.addStakeExceedBalanceNotification( + feeAmount: BigDecimal, + sendingAmount: BigDecimal, + actionType: StakingActionCommonType, + isSubtractionAvailable: Boolean, + cryptoCurrencyStatus: CryptoCurrencyStatus, + onClick: (CryptoCurrency) -> Unit, + ) { + val minimumRequirement = yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum + val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO + if (!isSubtractionAvailable) return + + val showNotification = sendingAmount + feeAmount > balance - minimumRequirement.orZero() + if (showNotification) { + val notification = if (actionType == StakingActionCommonType.ENTER) { + NotificationUM.Error.TotalExceedsBalance + } else { + with(cryptoCurrencyStatus.currency) { + NotificationUM.Error.ExceedsBalance( + networkIconId = networkIconResId, + networkName = name, + currencyName = name, + feeName = name, + feeSymbol = symbol, + mergeFeeNetworkName = BlockchainUtils.isArbitrum(network.backendId), + onClick = { onClick(this) }, + ) + } + } + add(notification) + } + } + + private fun MutableList.addInfoNotifications(prevState: StakingUiState) { + when (prevState.actionType) { + StakingActionCommonType.EXIT -> addExitInfoNotifications() + StakingActionCommonType.ENTER -> addEnterInfoNotifications() + else -> addPendingInfoNotifications(prevState) + } + } + + private fun MutableList.addExitInfoNotifications() { + add( + StakingNotification.Info.Unstake( + cooldownPeriodDays = yield.metadata.cooldownPeriod.days, + subtitleRes = if (isCosmos(cryptoCurrencyStatusProvider().currency.network.id.value)) { + R.string.staking_notification_unstake_cosmos_text + } else { + R.string.staking_notification_unstake_text + }, + ), + ) + } + + private fun MutableList.addEnterInfoNotifications() { + val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() + val isTron = isTron(cryptoCurrencyStatus.currency.network.id.value) + val hasStakedBalance = (cryptoCurrencyStatus.value.yieldBalance as? YieldBalance.Data)?.balance + ?.items?.any { + it.type == BalanceType.PREPARING || + it.type == BalanceType.STAKED || + it.type == BalanceType.LOCKED + } == true + if (hasStakedBalance && isTron) { + add(StakingNotification.Info.TronRevote) + } + } + + private fun MutableList.addPendingInfoNotifications(prevState: StakingUiState) { + val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data + val pendingActionType = confirmationState?.pendingAction?.type + val (titleReference, textReference) = when (pendingActionType) { + StakingActionType.CLAIM_REWARDS -> { + resourceReference(R.string.common_claim) to + resourceReference(R.string.staking_notification_claim_rewards_text) + } + StakingActionType.RESTAKE_REWARDS -> { + resourceReference(R.string.staking_restake) to + resourceReference(R.string.staking_notification_restake_rewards_text) + } + StakingActionType.WITHDRAW -> { + resourceReference(R.string.staking_withdraw) to + resourceReference(R.string.staking_notification_withdraw_text) + } + StakingActionType.UNLOCK_LOCKED -> { + val cooldownPeriodDays = yield.metadata.cooldownPeriod.days + resourceReference(R.string.staking_unlocked_locked) to resourceReference( + R.string.staking_notification_unlock_text, + wrappedList( + pluralReference( + id = R.plurals.common_days, + count = cooldownPeriodDays, + formatArgs = wrappedList(cooldownPeriodDays), + ), + ), + ) + } + else -> null to null + } + + if (titleReference != null && textReference != null) { + add( + StakingNotification.Info.PendingAction( + title = titleReference, + text = textReference, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/DismissBottomSheetStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/DismissBottomSheetStateTransformer.kt index 251b237e9d..fe77e4e0c2 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/DismissBottomSheetStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/DismissBottomSheetStateTransformer.kt @@ -3,7 +3,7 @@ package com.tangem.features.staking.impl.presentation.state.transformers import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.utils.transformer.Transformer -internal class DismissBottomSheetStateTransformer : Transformer { +internal object DismissBottomSheetStateTransformer : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy(bottomSheetConfig = null) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/DismissStakingNotificationsStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/DismissStakingNotificationsStateTransformer.kt new file mode 100644 index 0000000000..a5fc4d9bc5 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/DismissStakingNotificationsStateTransformer.kt @@ -0,0 +1,25 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +internal class DismissStakingNotificationsStateTransformer( + private val notification: Class, +) : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { + val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data + val updatedNotifications = confirmationState?.notifications + ?.filterNot { it::class == notification }?.toPersistentList() + ?: persistentListOf() + + return prevState.copy( + confirmationState = confirmationState?.copy( + notifications = updatedNotifications, + ) ?: StakingStates.ConfirmationState.Empty(), + ) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetActionToExecuteTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetActionToExecuteTransformer.kt new file mode 100644 index 0000000000..e3adab9f50 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetActionToExecuteTransformer.kt @@ -0,0 +1,25 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.domain.staking.model.stakekit.PendingAction +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.ImmutableList + +internal class SetActionToExecuteTransformer( + private val actionTypeToOverwrite: StakingActionCommonType, + private val pendingAction: PendingAction?, + private val pendingActions: ImmutableList?, +) : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { + val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data + return prevState.copy( + actionType = actionTypeToOverwrite, + confirmationState = confirmationState?.copy( + pendingAction = pendingAction, + pendingActions = pendingActions, + ) ?: StakingStates.ConfirmationState.Empty(), + ) + } +} \ No newline at end of file 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 6b4515a6ba..d9f2906670 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 @@ -2,18 +2,20 @@ package com.tangem.features.staking.impl.presentation.state.transformers import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationButtonsState +import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.R import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.staking.model.stakekit.PendingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType -import com.tangem.domain.staking.model.stakekit.action.StakingActionType import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.utils.getPendingActionTitle import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -internal class SetButtonsStateTransformer : Transformer { +internal class SetButtonsStateTransformer( + private val urlOpener: UrlOpener, +) : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data @@ -22,9 +24,9 @@ internal class SetButtonsStateTransformer : Transformer { NavigationButtonsState.Data( primaryButton = getPrimaryButton(prevState), prevButton = getPrevButton(prevState), - secondaryButton = getSecondaryButton(prevState), extraButtons = getExtraButtons(prevState), txUrl = (confirmState?.transactionDoneState as? TransactionDoneState.Content)?.txUrl, + onTextClick = urlOpener::openUrl, ) } else { NavigationButtonsState.Empty @@ -37,48 +39,22 @@ internal class SetButtonsStateTransformer : Transformer { val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data val innerConfirmState = confirmState?.innerState - val isPrimaryInProgress = - confirmState?.pendingActions?.getPrimaryAction() == confirmState?.pendingActionInProgress val isConfirmation = prevState.currentStep == StakingStep.Confirmation val isInProgress = innerConfirmState == InnerConfirmationStakingState.IN_PROGRESS val isCompleted = innerConfirmState == InnerConfirmationStakingState.COMPLETED val isIconVisible = isConfirmation && !isCompleted - val isShowProgress = isInProgress && isPrimaryInProgress return NavigationButton( textReference = prevState.getButtonText(), iconRes = R.drawable.ic_tangem_24, isSecondary = false, isIconVisible = isIconVisible, - showProgress = isShowProgress, + showProgress = isInProgress, isEnabled = prevState.isButtonEnabled(), onClick = { prevState.onPrimaryClick() }, ) } - private fun getSecondaryButton(prevState: StakingUiState): NavigationButton? { - val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data - val innerConfirmState = confirmState?.innerState - - val isConfirmation = prevState.currentStep == StakingStep.Confirmation - val isInProgress = innerConfirmState == InnerConfirmationStakingState.IN_PROGRESS - val isCompleted = innerConfirmState == InnerConfirmationStakingState.COMPLETED - - return confirmState?.pendingActions?.getSecondaryAction()?.let { pendingAction -> - val isSecondaryInProgress = pendingAction == confirmState.pendingActionInProgress - val isShowProgress = isInProgress && isSecondaryInProgress - NavigationButton( - textReference = getPendingActionTitle(pendingAction.type), - iconRes = R.drawable.ic_tangem_24, - isSecondary = true, - isIconVisible = true, - showProgress = isShowProgress, - isEnabled = prevState.isButtonEnabled(), - onClick = { prevState.clickIntents.onActionClick(pendingAction) }, - ).takeIf { isConfirmation && !isCompleted } - } - } - private fun getPrevButton(prevState: StakingUiState): NavigationButton? { return NavigationButton( textReference = TextReference.EMPTY, @@ -114,12 +90,7 @@ internal class SetButtonsStateTransformer : Transformer { ) } - private fun List.getPrimaryAction(): PendingAction? = getOrNull(0) - - private fun List.getSecondaryAction(): PendingAction? = getOrNull(1) - private fun StakingUiState.isButtonsVisible(): Boolean = when (currentStep) { - StakingStep.InitialInfo -> isStakeMoreAvailable() StakingStep.RewardsValidators -> false else -> true } @@ -159,7 +130,7 @@ internal class SetButtonsStateTransformer : Transformer { StakingActionCommonType.EXIT -> resourceReference(R.string.common_unstake) StakingActionCommonType.PENDING_OTHER, StakingActionCommonType.PENDING_REWARDS, - -> getPendingActionTitle(confirmationState.pendingActions.firstOrNull()?.type) + -> confirmationState.pendingAction?.type.getPendingActionTitle() } } } else { @@ -170,9 +141,8 @@ internal class SetButtonsStateTransformer : Transformer { private fun StakingUiState.onPrimaryClick() { when (currentStep) { StakingStep.InitialInfo -> { - val actionType = StakingActionCommonType.ENTER.takeIf { isStakeMoreAvailable() } clickIntents.onAmountValueChange("") // reset amount state - clickIntents.onNextClick(actionType) + clickIntents.onNextClick(StakingActionCommonType.ENTER) } StakingStep.Validators, StakingStep.Amount, @@ -185,7 +155,7 @@ internal class SetButtonsStateTransformer : Transformer { private fun StakingUiState.onConfirmationClick() { if (confirmationState is StakingStates.ConfirmationState.Data) { if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) { - clickIntents.onBackClick() + clickIntents.onNextClick() } else { val isEnterAction = actionType == StakingActionCommonType.ENTER val isApproveNeeded = confirmationState.isApprovalNeeded @@ -193,7 +163,7 @@ internal class SetButtonsStateTransformer : Transformer { if (isEnterAction && isApproveNeeded) { clickIntents.showApprovalBottomSheet() } else { - clickIntents.onActionClick(confirmationState.pendingActions.firstOrNull()) + clickIntents.onActionClick() } } } else { @@ -220,30 +190,4 @@ internal class SetButtonsStateTransformer : Transformer { StakingStep.Validators -> true } } - - @Suppress("CyclomaticComplexMethod") - private fun getPendingActionTitle(type: StakingActionType?): TextReference = when (type) { - StakingActionType.CLAIM_REWARDS -> resourceReference(R.string.common_claim_rewards) - StakingActionType.RESTAKE_REWARDS -> resourceReference(R.string.staking_restake_rewards) - StakingActionType.WITHDRAW -> resourceReference(R.string.staking_withdraw) - StakingActionType.RESTAKE -> resourceReference(R.string.staking_restake) - StakingActionType.CLAIM_UNSTAKED -> resourceReference(R.string.staking_claim_unstaked) - StakingActionType.UNLOCK_LOCKED -> resourceReference(R.string.staking_unlocked_locked) - StakingActionType.STAKE_LOCKED -> resourceReference(R.string.staking_stake_locked) - StakingActionType.VOTE -> resourceReference(R.string.staking_vote) - StakingActionType.REVOKE -> resourceReference(R.string.staking_revoke) - StakingActionType.VOTE_LOCKED -> resourceReference(R.string.staking_vote_locked) - StakingActionType.REVOTE -> resourceReference(R.string.staking_revote) - StakingActionType.REBOND -> resourceReference(R.string.staking_rebond) - StakingActionType.MIGRATE -> resourceReference(R.string.staking_migrate) - StakingActionType.STAKE -> resourceReference(R.string.common_stake) - StakingActionType.UNSTAKE -> resourceReference(R.string.common_unstake) - StakingActionType.UNKNOWN -> TextReference.EMPTY - null -> TextReference.EMPTY - } - - private fun StakingUiState.isStakeMoreAvailable(): Boolean { - val initialState = initialInfoState as? StakingStates.InitialInfoState.Data - return initialState?.isStakeMoreAvailable == true || initialState?.yieldBalance is InnerYieldBalanceState.Empty - } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt index ca470397b6..196784785f 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt @@ -1,10 +1,8 @@ package com.tangem.features.staking.impl.presentation.state.transformers -import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.stakekit.PendingAction -import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.features.staking.impl.presentation.state.FeeState import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState @@ -17,38 +15,32 @@ import kotlinx.collections.immutable.ImmutableList internal class SetConfirmationStateAssentTransformer( private val appCurrencyProvider: Provider, private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?, - private val stakingGasEstimate: StakingGasEstimate, - private val pendingActionList: ImmutableList, + private val fee: Fee, + private val action: PendingAction?, + private val actions: ImmutableList?, ) : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy( - confirmationState = prevState.confirmationState.copyWrapped(stakingGasEstimate), + confirmationState = prevState.confirmationState.copyWrapped(fee), ) } - private fun StakingStates.ConfirmationState.copyWrapped( - gasEstimate: StakingGasEstimate, - ): StakingStates.ConfirmationState { + private fun StakingStates.ConfirmationState.copyWrapped(fee: Fee): StakingStates.ConfirmationState { if (this is StakingStates.ConfirmationState.Data) { val isFeeConvertibleToFiat = feeCryptoCurrencyStatus?.currency?.network?.hasFiatFeeRate == true return copy( innerState = InnerConfirmationStakingState.ASSENT, feeState = FeeState.Content( - fee = Fee.Common( - Amount( - currencySymbol = gasEstimate.token.symbol, - value = gasEstimate.amount, - decimals = gasEstimate.token.decimals, - ), - ), + fee = fee, rate = feeCryptoCurrencyStatus?.value?.fiatRate, isFeeConvertibleToFiat = isFeeConvertibleToFiat, appCurrency = appCurrencyProvider(), isFeeApproximate = false, ), validatorState = validatorState.copySealed(isClickable = true), - pendingActions = pendingActionList, + pendingAction = action, + pendingActions = actions, isPrimaryButtonEnabled = true, isApprovalNeeded = false, ) 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 6b11ad41ab..db27e0c2d9 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 @@ -1,54 +1,28 @@ package com.tangem.features.staking.impl.presentation.state.transformers -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate -import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.core.ui.extensions.TextReference import com.tangem.features.staking.impl.presentation.state.* -import com.tangem.features.staking.impl.presentation.state.StakingStates -import com.tangem.features.staking.impl.presentation.state.StakingUiState -import com.tangem.features.staking.impl.presentation.state.TransactionDoneState -import com.tangem.utils.Provider import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf internal class SetConfirmationStateCompletedTransformer( - private val appCurrencyProvider: Provider, - private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?, - private val stakingGasEstimate: StakingGasEstimate, private val txUrl: String, ) : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy( - confirmationState = prevState.confirmationState.copyWrapped(stakingGasEstimate), + confirmationState = prevState.confirmationState.copyWrapped(), ) } - private fun StakingStates.ConfirmationState.copyWrapped( - gasEstimate: StakingGasEstimate, - ): StakingStates.ConfirmationState { + private fun StakingStates.ConfirmationState.copyWrapped(): StakingStates.ConfirmationState { if (this is StakingStates.ConfirmationState.Data) { - val isFeeConvertibleToFiat = feeCryptoCurrencyStatus?.currency?.network?.hasFiatFeeRate == true return copy( isPrimaryButtonEnabled = true, innerState = InnerConfirmationStakingState.COMPLETED, - feeState = FeeState.Content( - fee = Fee.Common( - Amount( - currencySymbol = gasEstimate.token.symbol, - value = gasEstimate.amount, - decimals = gasEstimate.token.decimals, - ), - ), - rate = feeCryptoCurrencyStatus?.value?.fiatRate, - isFeeConvertibleToFiat = isFeeConvertibleToFiat, - appCurrency = appCurrencyProvider(), - isFeeApproximate = false, - ), - validatorState = validatorState.copySealed( - isClickable = false, - ), + validatorState = validatorState.copySealed(isClickable = false), + footerText = TextReference.EMPTY, + notifications = persistentListOf(), transactionDoneState = TransactionDoneState.Content( timestamp = System.currentTimeMillis(), txUrl = txUrl, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInProgressTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInProgressTransformer.kt index 817ff94491..24bdd43c84 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInProgressTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInProgressTransformer.kt @@ -1,14 +1,12 @@ package com.tangem.features.staking.impl.presentation.state.transformers -import com.tangem.domain.staking.model.stakekit.PendingAction +import com.tangem.core.ui.extensions.TextReference 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.StakingUiState import com.tangem.utils.transformer.Transformer -internal class SetConfirmationStateInProgressTransformer( - private val pendingAction: PendingAction?, -) : Transformer { +internal class SetConfirmationStateInProgressTransformer : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy( @@ -22,7 +20,7 @@ internal class SetConfirmationStateInProgressTransformer( isPrimaryButtonEnabled = false, innerState = InnerConfirmationStakingState.IN_PROGRESS, validatorState = validatorState.copySealed(isClickable = false), - pendingActionInProgress = pendingAction, + footerText = TextReference.EMPTY, ) } else { this diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateLoadingTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateLoadingTransformer.kt index 1e77078e6e..bd88fa6ffc 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateLoadingTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateLoadingTransformer.kt @@ -1,15 +1,22 @@ package com.tangem.features.staking.impl.presentation.state.transformers +import com.tangem.common.ui.amountScreen.models.AmountState +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.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.utils.extensions.orZero import com.tangem.utils.transformer.Transformer -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf internal class SetConfirmationStateLoadingTransformer( private val yield: Yield, + private val appCurrency: AppCurrency, ) : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { @@ -27,50 +34,46 @@ internal class SetConfirmationStateLoadingTransformer( chosenValidator = chosenValidator, availableValidators = yield.validators, ), - notifications = getNotifications(prevState), - footerText = "", + notifications = persistentListOf(), + footerText = getFooter(prevState), transactionDoneState = TransactionDoneState.Empty, - pendingActions = persistentListOf(), - pendingActionInProgress = null, + pendingAction = possibleConfirmationState?.pendingAction, + pendingActions = possibleConfirmationState?.pendingActions, isApprovalNeeded = false, + reduceAmountBy = null, ), ) } - private fun getNotifications(prevState: StakingUiState): ImmutableList { - return persistentListOf( - if (prevState.actionType == StakingActionCommonType.EXIT) { - StakingNotification.Warning.Unstake( - cooldownPeriodDays = yield.metadata.cooldownPeriod.days, - ) - } else { - StakingNotification.Warning.EarnRewards( - currencyName = yield.token.name, - subtitleResourceId = getEarnRewardsPeriod(yield.metadata.rewardSchedule), - ) - }, + private fun getFooter(state: StakingUiState): TextReference { + val amountState = state.amountState as? AmountState.Data + val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data + val validatorState = confirmationState?.validatorState as? ValidatorState.Content + + val isEnterAction = state.actionType == StakingActionCommonType.ENTER + + val apr = validatorState?.chosenValidator?.apr.orZero() + val amountDecimal = amountState?.amountTextField?.fiatAmount?.value + val potentialReward = amountDecimal?.multiply(apr) + + val amountValue = BigDecimalFormatter.formatFiatAmount( + fiatAmount = amountDecimal, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, ) - } - - private fun getEarnRewardsPeriod(rewardSchedule: Yield.Metadata.RewardSchedule): Int { - return when (rewardSchedule) { - Yield.Metadata.RewardSchedule.BLOCK, - Yield.Metadata.RewardSchedule.DAY, - Yield.Metadata.RewardSchedule.ERA, - Yield.Metadata.RewardSchedule.EPOCH, - -> R.string.staking_notification_earn_rewards_text_period_day - - Yield.Metadata.RewardSchedule.HOUR, - -> R.string.staking_notification_earn_rewards_text_period_hour - - Yield.Metadata.RewardSchedule.WEEK, - -> R.string.staking_notification_earn_rewards_text_period_week - - Yield.Metadata.RewardSchedule.MONTH, - -> R.string.staking_notification_earn_rewards_text_period_month - - else - -> R.string.staking_notification_earn_rewards_text_period_day + val potentialRewardValue = BigDecimalFormatter.formatFiatAmount( + fiatAmount = potentialReward, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + withApproximateSign = true, + ) + return if (isEnterAction && amountDecimal != null && potentialReward != null) { + resourceReference( + id = R.string.staking_summary_description_text, + formatArgs = wrappedList(amountValue, potentialRewardValue), + ) + } else { + TextReference.EMPTY } } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateResetAssentTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateResetAssentTransformer.kt new file mode 100644 index 0000000000..ee866db72c --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateResetAssentTransformer.kt @@ -0,0 +1,23 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +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.StakingUiState +import com.tangem.utils.transformer.Transformer + +internal object SetConfirmationStateResetAssentTransformer : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { + val confirmationState = prevState.confirmationState + return prevState.copy( + confirmationState = if (confirmationState is StakingStates.ConfirmationState.Data) { + confirmationState.copy( + isPrimaryButtonEnabled = true, + innerState = InnerConfirmationStakingState.ASSENT, + validatorState = confirmationState.validatorState.copySealed(isClickable = true), + ) + } else { + confirmationState + }, + ) + } +} \ No newline at end of file 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 8d8a69e019..c2a2c681a5 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 @@ -6,6 +6,7 @@ import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.list.RoundedListWithDividersItemData import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.core.serialization.SerializedBigDecimal @@ -14,14 +15,13 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.* -import com.tangem.features.staking.impl.presentation.state.StakingStates -import com.tangem.features.staking.impl.presentation.state.StakingUiState -import com.tangem.features.staking.impl.presentation.state.TransactionDoneState -import com.tangem.features.staking.impl.presentation.state.ValidatorState +import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType import com.tangem.features.staking.impl.presentation.state.converters.RewardsValidatorStateConverter import com.tangem.features.staking.impl.presentation.state.converters.YieldBalancesConverter import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents +import com.tangem.lib.crypto.BlockchainUtils.isPolkadot import com.tangem.utils.Provider +import com.tangem.utils.isNullOrZero import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf @@ -32,8 +32,8 @@ import java.math.BigDecimal internal class SetInitialDataStateTransformer( private val clickIntents: StakingClickIntents, private val yield: Yield, - private val isStakeMoreAvailable: Boolean, private val isApprovalNeeded: Boolean, + private val isAnyTokenStaked: Boolean, private val cryptoCurrencyStatusProvider: Provider, private val userWalletProvider: Provider, private val appCurrencyProvider: Provider, @@ -56,16 +56,19 @@ internal class SetInitialDataStateTransformer( } private val yieldBalancesConverter by lazy(LazyThreadSafetyMode.NONE) { - YieldBalancesConverter(cryptoCurrencyStatusProvider, appCurrencyProvider, yield) + YieldBalancesConverter( + cryptoCurrencyStatusProvider, + appCurrencyProvider, + yield, + ) } override fun transform(prevState: StakingUiState): StakingUiState { + val cryptoCurrency = cryptoCurrencyStatusProvider().currency return prevState.copy( - title = TextReference.Res( - R.string.staking_title_stake, - wrappedList(cryptoCurrencyStatusProvider().currency.name), - ), - cryptoCurrencyName = cryptoCurrencyStatusProvider.invoke().currency.name, + title = TextReference.EMPTY, + cryptoCurrencyName = cryptoCurrency.name, + cryptoCurrencySymbol = cryptoCurrency.symbol, clickIntents = clickIntents, currentStep = StakingStep.InitialInfo, initialInfoState = createInitialInfoState(), @@ -77,13 +80,18 @@ internal class SetInitialDataStateTransformer( } private fun createInitialInfoState(): StakingStates.InitialInfoState.Data { + val yieldBalance = yieldBalancesConverter.convert(Unit) return StakingStates.InitialInfoState.Data( - isPrimaryButtonEnabled = true, + isPrimaryButtonEnabled = !cryptoCurrencyStatusProvider().value.amount.isNullOrZero(), + showBanner = !isAnyTokenStaked && yieldBalance == InnerYieldBalanceState.Empty, aprRange = getAprRange(yield.validators), infoItems = getInfoItems(), onInfoClick = clickIntents::onInfoClick, - yieldBalance = yieldBalancesConverter.convert(Unit), - isStakeMoreAvailable = isStakeMoreAvailable, + yieldBalance = yieldBalance, + pullToRefreshConfig = PullToRefreshConfig( + onRefresh = { clickIntents.onRefreshSwipe(it.value) }, + isRefreshing = false, + ), ) } @@ -110,6 +118,7 @@ internal class SetInitialDataStateTransformer( startText = TextReference.Res(R.string.staking_details_annual_percentage_rate), endText = getAprRange(validators), iconClick = { clickIntents.onInfoClick(InfoType.ANNUAL_PERCENTAGE_RATE) }, + isEndTextHighlighted = true, ) } @@ -124,6 +133,7 @@ internal class SetInitialDataStateTransformer( decimals = cryptoCurrencyStatus.currency.decimals, ), ), + isEndTextHideable = true, ) } @@ -144,18 +154,19 @@ internal class SetInitialDataStateTransformer( cryptoCurrencyStatus: CryptoCurrencyStatus, minimumCryptoAmount: SerializedBigDecimal?, ): RoundedListWithDividersItemData? { - minimumCryptoAmount ?: return null + if (minimumCryptoAmount == null) return null + if (!isPolkadot(cryptoCurrencyStatus.currency.network.id.value)) return null + + val formattedAmount = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = minimumCryptoAmount, + cryptoCurrency = cryptoCurrencyStatus.currency.symbol, + decimals = cryptoCurrencyStatus.currency.decimals, + ) return RoundedListWithDividersItemData( id = R.string.staking_details_minimum_requirement, startText = TextReference.Res(R.string.staking_details_minimum_requirement), - endText = TextReference.Str( - value = BigDecimalFormatter.formatCryptoAmount( - cryptoAmount = minimumCryptoAmount, - cryptoCurrency = cryptoCurrencyStatus.currency.symbol, - decimals = cryptoCurrencyStatus.currency.decimals, - ), - ), + endText = TextReference.Str(formattedAmount), ) } @@ -211,11 +222,12 @@ internal class SetInitialDataStateTransformer( feeState = FeeState.Loading, validatorState = ValidatorState.Loading, notifications = persistentListOf(), - footerText = "", + footerText = TextReference.EMPTY, transactionDoneState = TransactionDoneState.Empty, - pendingActions = persistentListOf(), - pendingActionInProgress = null, + pendingAction = null, + pendingActions = null, isApprovalNeeded = isApprovalNeeded, + reduceAmountBy = null, ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialLoadingStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialLoadingStateTransformer.kt new file mode 100644 index 0000000000..416ec78676 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialLoadingStateTransformer.kt @@ -0,0 +1,20 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.utils.transformer.Transformer + +internal class SetInitialLoadingStateTransformer( + private val isRefreshing: Boolean, +) : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { + val initialState = prevState.initialInfoState as? StakingStates.InitialInfoState.Data + return prevState.copy( + initialInfoState = initialState?.copy( + pullToRefreshConfig = prevState.initialInfoState.pullToRefreshConfig.copy( + isRefreshing = isRefreshing, + ), + ) ?: prevState.initialInfoState, + ) + } +} \ No newline at end of file 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 ffb66a5ee1..578af70403 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,10 +1,15 @@ package com.tangem.features.staking.impl.presentation.state.transformers +import com.tangem.core.ui.extensions.isNullOrEmpty import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.R -import com.tangem.features.staking.impl.presentation.state.* +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.utils.getPendingActionTitle import com.tangem.utils.transformer.Transformer internal object SetTitleTransformer : Transformer { @@ -13,29 +18,48 @@ internal object SetTitleTransformer : Transformer { val actionType = prevState.actionType val currentStep = prevState.currentStep - val title = when { - currentStep == StakingStep.Amount -> { - resourceReference(R.string.send_amount_label) - } + val title = when (currentStep) { + StakingStep.Amount -> resourceReference(R.string.send_amount_label) + StakingStep.Validators -> resourceReference(R.string.staking_validators) + StakingStep.RewardsValidators -> resourceReference(R.string.common_claim_rewards) + StakingStep.InitialInfo -> resourceReference( + R.string.staking_title_stake, + wrappedList(prevState.cryptoCurrencyName), + ) - currentStep == StakingStep.Validators -> { - resourceReference(R.string.staking_validators) - } + StakingStep.Confirmation -> { + when (actionType) { + StakingActionCommonType.ENTER -> resourceReference( + R.string.staking_title_stake, + wrappedList(prevState.cryptoCurrencyName), + ) + StakingActionCommonType.EXIT -> resourceReference( + R.string.staking_title_unstake, + wrappedList(prevState.cryptoCurrencyName), + ) + else -> { + val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data + val title = confirmationState?.pendingAction?.type?.getPendingActionTitle() - actionType == StakingActionCommonType.EXIT && currentStep != StakingStep.InitialInfo -> { - resourceReference( - R.string.staking_title_unstake, - wrappedList(prevState.cryptoCurrencyName), - ) - } - else -> { - resourceReference( - R.string.staking_title_stake, - wrappedList(prevState.cryptoCurrencyName), - ) + title.takeIf { !it.isNullOrEmpty() } + ?: resourceReference( + id = R.string.staking_title_stake, + formatArgs = wrappedList(prevState.cryptoCurrencyName), + ) + } + } } } - return prevState.copy(title = title) + val subtitle = if (currentStep == StakingStep.Confirmation && actionType == StakingActionCommonType.ENTER) { + stringReference(prevState.walletName) + } else { + null + } + + return prevState.copy( + title = title, + subtitle = subtitle, + ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowActionSelectorBottomSheetTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowActionSelectorBottomSheetTransformer.kt new file mode 100644 index 0000000000..2c927652c7 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowActionSelectorBottomSheetTransformer.kt @@ -0,0 +1,30 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.staking.model.stakekit.PendingAction +import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.bottomsheet.StakingActionSelectionBottomSheetConfig +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.ImmutableList + +internal class ShowActionSelectorBottomSheetTransformer( + private val pendingActions: ImmutableList, + private val onActionSelect: (PendingAction) -> Unit, + private val onDismiss: () -> Unit, +) : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { + return prevState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + content = StakingActionSelectionBottomSheetConfig( + title = resourceReference(R.string.common_choose_action), + actions = pendingActions, + onActionSelect = onActionSelect, + ), + onDismissRequest = onDismiss, + ), + ) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowInfoBottomSheetStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowInfoBottomSheetStateTransformer.kt index 06f89bdd94..f0e0ade2cc 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowInfoBottomSheetStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowInfoBottomSheetStateTransformer.kt @@ -4,6 +4,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType import com.tangem.features.staking.impl.presentation.state.bottomsheet.StakingInfoBottomSheetConfig import com.tangem.utils.transformer.Transformer @@ -42,12 +43,4 @@ internal class ShowInfoBottomSheetStateTransformer( ), ) } -} - -enum class InfoType { - ANNUAL_PERCENTAGE_RATE, - UNBONDING_PERIOD, - REWARD_CLAIMING, - WARMUP_PERIOD, - REWARD_SCHEDULE, } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt index b87f8a0251..f91d878456 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt @@ -8,16 +8,10 @@ import com.tangem.utils.transformer.Transformer internal class AmountChangeStateTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, - private val yield: Yield, private val value: String, + private val yield: Yield, ) : Transformer { - private val amountRequirementStateTransformer = AmountRequirementStateTransformer( - cryptoCurrencyStatus, - yield, - value, - ) - override fun transform(prevState: StakingUiState): StakingUiState { val updatedAmountState = AmountFieldChangeTransformer( cryptoCurrencyStatus, @@ -25,7 +19,11 @@ internal class AmountChangeStateTransformer( ).transform(prevState.amountState) return prevState.copy( - amountState = amountRequirementStateTransformer.transform(updatedAmountState), + amountState = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + yield = yield, + actionType = prevState.actionType, + ).transform(updatedAmountState), ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt index ae53eab63e..088528794d 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt @@ -1,7 +1,6 @@ package com.tangem.features.staking.impl.presentation.state.transformers.amount import com.tangem.common.ui.amountScreen.converters.field.AmountFieldMaxAmountTransformer -import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.features.staking.impl.presentation.state.StakingUiState @@ -12,18 +11,14 @@ internal class AmountMaxValueStateTransformer( private val yield: Yield, ) : Transformer { - private val amountRequirementStateTransformer = AmountRequirementStateTransformer( - cryptoCurrencyStatus = cryptoCurrencyStatus, - yield = yield, - value = cryptoCurrencyStatus.value.amount - ?.parseBigDecimal(cryptoCurrencyStatus.currency.decimals) - .orEmpty(), - ) - override fun transform(prevState: StakingUiState): StakingUiState { val updatedAmountState = AmountFieldMaxAmountTransformer(cryptoCurrencyStatus).transform(prevState.amountState) return prevState.copy( - amountState = amountRequirementStateTransformer.transform(updatedAmountState), + amountState = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + yield = yield, + actionType = prevState.actionType, + ).transform(updatedAmountState), ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountReduceByStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountReduceByStateTransformer.kt new file mode 100644 index 0000000000..91468a21bd --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountReduceByStateTransformer.kt @@ -0,0 +1,19 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.amount + +import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer +import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.utils.transformer.Transformer + +internal class AmountReduceByStateTransformer( + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val value: ReduceByData, +) : Transformer { + + override fun transform(prevState: StakingUiState): StakingUiState { + return prevState.copy( + amountState = AmountReduceByTransformer(cryptoCurrencyStatus, value).transform(prevState.amountState), + ) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountReduceToStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountReduceToStateTransformer.kt new file mode 100644 index 0000000000..b826aae59e --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountReduceToStateTransformer.kt @@ -0,0 +1,18 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.amount + +import com.tangem.common.ui.amountScreen.converters.AmountReduceToTransformer +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.utils.transformer.Transformer +import java.math.BigDecimal + +internal class AmountReduceToStateTransformer( + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val value: BigDecimal, +) : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { + return prevState.copy( + amountState = AmountReduceToTransformer(cryptoCurrencyStatus, value).transform(prevState.amountState), + ) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt index f16597ae54..d669d5a7e9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt @@ -1,57 +1,93 @@ package com.tangem.features.staking.impl.presentation.state.transformers.amount +import androidx.compose.ui.text.input.ImeAction import com.tangem.common.extensions.isZero import com.tangem.common.ui.amountScreen.models.AmountState +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.wrappedList import com.tangem.core.ui.utils.BigDecimalFormatter -import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.staking.model.stakekit.AddressArgument import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.features.staking.impl.R +import com.tangem.lib.crypto.BlockchainUtils.isTron import com.tangem.utils.transformer.Transformer +import java.math.BigDecimal +import java.math.RoundingMode internal class AmountRequirementStateTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val yield: Yield, - private val value: String, + private val actionType: StakingActionCommonType, ) : Transformer { override fun transform(prevState: AmountState): AmountState { val amountRequirements = yield.args.enter.args[Yield.Args.ArgType.AMOUNT] - return if (prevState !is AmountState.Data || amountRequirements == null) { - prevState - } else { - updateWithError(prevState, amountRequirements) - } - } - - private fun updateWithError(prevState: AmountState.Data, amountRequirements: AddressArgument): AmountState { - val isRequirementError = isRequirementError(prevState, amountRequirements) - return if (isRequirementError) { - prevState.copy( - amountTextField = prevState.amountTextField.copy( - isError = true, - error = resourceReference( - R.string.staking_amount_requirement_error, - wrappedList( - BigDecimalFormatter.formatCryptoAmount( - amountRequirements.minimum, - cryptoCurrencyStatus.currency.symbol, - cryptoCurrencyStatus.currency.decimals, - ), - ), - ), - ), + return if (prevState is AmountState.Data && amountRequirements != null) { + updateWithError( + prevState, + actionType, + amountRequirements, ) } else { prevState } } + private fun updateWithError( + amountState: AmountState.Data, + actionType: StakingActionCommonType, + amountRequirements: AddressArgument, + ): AmountState { + val isRequirementError = isRequirementError(amountState, amountRequirements) + val isIntegerOnlyError = isIntegerOnlyError(amountState, actionType) + + val cryptoAmount = amountState.amountTextField.cryptoAmount + val roundedDownCrypto = cryptoAmount.value?.setScale(0, RoundingMode.DOWN) + val value = roundedDownCrypto?.parseBigDecimal(0).orEmpty() + + val errorText = when { + amountState.amountTextField.isError -> amountState.amountTextField.error + isRequirementError -> resourceReference( + R.string.staking_amount_requirement_error, + wrappedList( + BigDecimalFormatter.formatCryptoAmount( + amountRequirements.minimum, + cryptoCurrencyStatus.currency.symbol, + cryptoCurrencyStatus.currency.decimals, + ), + ), + ) + isIntegerOnlyError -> resourceReference( + R.string.staking_amount_tron_integer_error, + wrappedList(value), + ) + else -> TextReference.EMPTY + } + val isError = amountState.amountTextField.isError || isRequirementError + return if (!errorText.isNullOrEmpty()) { + amountState.copy( + isPrimaryButtonEnabled = !isError, + amountTextField = amountState.amountTextField.copy( + isError = isError, + isWarning = isIntegerOnlyError, + error = errorText, + keyboardOptions = amountState.amountTextField.keyboardOptions.copy( + imeAction = ImeAction.None, + ), + ), + ) + } else { + amountState + } + } + private fun isRequirementError(prevState: AmountState.Data, amountRequirements: AddressArgument): Boolean { - val amountDecimal = value.parseToBigDecimal(cryptoCurrencyStatus.currency.decimals) + val amountDecimal = prevState.amountTextField.cryptoAmount.value ?: return false val isAlreadyErrorState = prevState.amountTextField.isError val isAmountRequired = amountRequirements.required @@ -62,4 +98,20 @@ internal class AmountRequirementStateTransformer( return !isAmountZero && isAmountRequired && isExceedsRequirements && !isAlreadyErrorState } + + private fun isIntegerOnlyError(amountState: AmountState.Data, actionType: StakingActionCommonType): Boolean { + val cryptoAmountValue = amountState.amountTextField.cryptoAmount.value ?: return false + + val isEnter = actionType == StakingActionCommonType.ENTER + val isTron = isTron(cryptoCurrencyStatus.currency.network.id.value) + + val isIntegerOnly = cryptoAmountValue.isZero() || cryptoAmountValue.remainder(BigDecimal.ONE).isZero() + + return isEnter && isTron && !isIntegerOnly + } + + data class Data( + val amountState: AmountState, + val actionType: StakingActionCommonType, + ) } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRoundToIntegerTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRoundToIntegerTransformer.kt new file mode 100644 index 0000000000..d2b2e7fe50 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRoundToIntegerTransformer.kt @@ -0,0 +1,40 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.amount + +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.utils.transformer.Transformer +import java.math.RoundingMode + +internal class AmountRoundToIntegerTransformer( + private val cryptoCurrencyStatus: CryptoCurrencyStatus, +) : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { + val amountState = prevState.amountState as? AmountState.Data ?: return prevState + val amountTextField = amountState.amountTextField + if (amountTextField.value.isEmpty()) return prevState + + val cryptoAmount = amountState.amountTextField.cryptoAmount + val fiatAmount = amountState.amountTextField.fiatAmount + val fiatDecimals = fiatAmount.decimals + + val roundedDownCrypto = cryptoAmount.value?.setScale(0, RoundingMode.DOWN) + val roundedDownFiat = roundedDownCrypto?.multiply(cryptoCurrencyStatus.value.fiatRate) + + val value = roundedDownCrypto?.parseBigDecimal(0).orEmpty() + val fiatValue = roundedDownFiat?.parseBigDecimal(fiatDecimals, RoundingMode.HALF_UP).orEmpty() + + return prevState.copy( + amountState = amountState.copy( + amountTextField = amountState.amountTextField.copy( + cryptoAmount = cryptoAmount.copy(value = roundedDownCrypto), + fiatAmount = fiatAmount.copy(value = roundedDownFiat), + value = value, + fiatValue = fiatValue, + isWarning = false, + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt index eba4aae409..e6ba4e32db 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt @@ -7,10 +7,12 @@ import com.tangem.features.staking.impl.presentation.state.ValidatorState import com.tangem.utils.transformer.Transformer internal class ValidatorSelectChangeTransformer( - private val selectedValidator: Yield.Validator, + private val selectedValidator: Yield.Validator?, ) : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState + selectedValidator ?: return prevState val validatorState = (confirmationState.validatorState as? ValidatorState.Content)?.copy( chosenValidator = selectedValidator, ) ?: ValidatorState.Content( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/FeeCalculation.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/FeeCalculation.kt new file mode 100644 index 0000000000..0f1857c60b --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/FeeCalculation.kt @@ -0,0 +1,53 @@ +package com.tangem.features.staking.impl.presentation.state.utils + +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.lib.crypto.BlockchainUtils.isTron +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode + +/** + * Check and calculates subtracted amount + */ +internal fun checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable: Boolean, + cryptoCurrencyStatus: CryptoCurrencyStatus, + amountValue: BigDecimal, + feeValue: BigDecimal, + reduceAmountBy: BigDecimal, +): BigDecimal { + val balance = cryptoCurrencyStatus.value.amount ?: return amountValue + val isTron = isTron(cryptoCurrencyStatus.currency.network.id.value) + val feeValueRounded = if (isTron) { + feeValue.round(MathContext(0, RoundingMode.UP)) + } else { + feeValue + } + val isFeeCoverage = checkFeeCoverage( + isSubtractAvailable = isAmountSubtractAvailable, + balance = balance, + amountValue = amountValue, + feeValue = feeValueRounded, + reduceAmountBy = reduceAmountBy, + ) + return if (isFeeCoverage) { + balance.minus(reduceAmountBy).minus(feeValueRounded) + } else { + amountValue + } +} + +/** + * Checks if sending amount with fee is greater than balance + */ +internal fun checkFeeCoverage( + isSubtractAvailable: Boolean, + balance: BigDecimal, + amountValue: BigDecimal, + feeValue: BigDecimal, + reduceAmountBy: BigDecimal?, +): Boolean { + if (!isSubtractAvailable) return false + val reducedBy = balance - (reduceAmountBy ?: BigDecimal.ZERO) + return reducedBy < amountValue + feeValue && reducedBy > feeValue && reducedBy >= amountValue +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingPendingActionUtils.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingPendingActionUtils.kt new file mode 100644 index 0000000000..2822f01758 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingPendingActionUtils.kt @@ -0,0 +1,36 @@ +package com.tangem.features.staking.impl.presentation.state.utils + +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.staking.model.stakekit.PendingAction +import com.tangem.domain.staking.model.stakekit.action.StakingActionType +import com.tangem.lib.crypto.BlockchainUtils.isSolana +import kotlinx.collections.immutable.ImmutableList + +@Suppress("CyclomaticComplexMethod") +internal fun StakingActionType?.getPendingActionTitle(): TextReference = when (this) { + StakingActionType.CLAIM_REWARDS -> resourceReference(R.string.common_claim_rewards) + StakingActionType.RESTAKE_REWARDS -> resourceReference(R.string.staking_restake_rewards) + StakingActionType.WITHDRAW -> resourceReference(R.string.staking_withdraw) + StakingActionType.RESTAKE -> resourceReference(R.string.staking_restake) + StakingActionType.CLAIM_UNSTAKED -> resourceReference(R.string.staking_claim_unstaked) + StakingActionType.UNLOCK_LOCKED -> resourceReference(R.string.staking_unlocked_locked) + StakingActionType.STAKE_LOCKED -> resourceReference(R.string.staking_stake_locked) + StakingActionType.VOTE -> resourceReference(R.string.staking_vote) + StakingActionType.REVOKE -> resourceReference(R.string.staking_revoke) + StakingActionType.VOTE_LOCKED -> resourceReference(R.string.staking_vote_locked) + StakingActionType.REVOTE -> resourceReference(R.string.staking_revote) + StakingActionType.REBOND -> resourceReference(R.string.staking_rebond) + StakingActionType.MIGRATE -> resourceReference(R.string.staking_migrate) + StakingActionType.STAKE -> resourceReference(R.string.common_stake) + StakingActionType.UNSTAKE -> resourceReference(R.string.common_unstake) + StakingActionType.UNKNOWN -> TextReference.EMPTY + null -> TextReference.EMPTY +} + +internal fun isSolanaWithdraw(networkId: String, pendingActions: ImmutableList?): Boolean { + val isSolana = isSolana(networkId) + val isWithdraw = pendingActions?.all { it.type == StakingActionType.WITHDRAW } == true + return isSolana && isWithdraw +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt index 3e6723c8b2..cbcb806d97 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt @@ -33,16 +33,16 @@ internal fun StakingClaimRewardsValidatorContent( .verticalScroll(rememberScrollState()), ) { state.rewards.forEachIndexed { index, item -> - key(item.validator.address) { + key(item.title.resolveReference() + index) { InputRowImageInfo( - subtitle = stringReference(item.validator.name), + subtitle = item.title, caption = combinedReference( resourceReference(R.string.staking_details_apr), annotatedReference { appendSpace() appendColored( text = BigDecimalFormatter.formatPercent( - percent = item.validator.apr.orZero(), + percent = item.validator?.apr.orZero(), useAbsoluteValue = true, ), color = TangemTheme.colors.text.accent, @@ -51,7 +51,7 @@ internal fun StakingClaimRewardsValidatorContent( ), infoTitle = item.fiatAmount, infoSubtitle = item.cryptoAmount, - imageUrl = item.validator.image.orEmpty(), + imageUrl = item.validator?.image.orEmpty(), modifier = modifier .roundedShapeItemDecoration(index, state.rewards.lastIndex, false) .background(TangemTheme.colors.background.action) 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 ae5079b938..8fc799719a 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 @@ -3,23 +3,24 @@ package com.tangem.features.staking.impl.presentation.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign 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.SpacerHMax 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.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.R +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.TransactionDoneState import com.tangem.features.staking.impl.presentation.state.previewdata.ConfirmationStatePreviewData @@ -37,7 +38,8 @@ internal fun StakingConfirmationContent( type: StakingActionCommonType, ) { if (state !is StakingStates.ConfirmationState.Data) return - + val isEnterAction = type == StakingActionCommonType.ENTER + val isTransactionSent = state.innerState == InnerConfirmationStakingState.COMPLETED Column( modifier = Modifier .background(TangemTheme.colors.background.secondary) @@ -45,43 +47,30 @@ internal fun StakingConfirmationContent( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), ) { + val doneState = state.transactionDoneState AnimatedVisibility( - visible = state.transactionDoneState is TransactionDoneState.Content, + visible = doneState is TransactionDoneState.Content, modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12), ) { - val transactionDoneStateContent = state.transactionDoneState as TransactionDoneState.Content TransactionDoneTitle( - titleRes = R.string.sent_transaction_sent_title, - date = transactionDoneStateContent.timestamp, + title = resourceReference(R.string.common_in_progress), + subtitle = resourceReference(R.string.staking_transaction_in_progress_text), ) } AmountBlock( amountState = amountState, - isClickDisabled = true, - isEditingDisabled = true, - onClick = {}, + isClickDisabled = !isEnterAction || isTransactionSent, + isEditingDisabled = !isEnterAction && state.innerState != InnerConfirmationStakingState.COMPLETED, + onClick = clickIntents::onPrevClick, ) - if (type == StakingActionCommonType.ENTER) { + if (isEnterAction) { ValidatorBlock(validatorState = state.validatorState, onClick = clickIntents::openValidators) } - StakingFeeBlock(feeState = state.feeState) + StakingFeeBlock(feeState = state.feeState, isTransactionSent = isTransactionSent) NotificationsBlock(notifications = state.notifications) - SpacerHMax() - FooterText(text = state.footerText) } } -@Composable -private fun FooterText(text: String) { - Text( - modifier = Modifier.fillMaxWidth(), - text = text, - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography.caption2, - textAlign = TextAlign.Center, - ) -} - @Preview(widthDp = 360, showBackground = true) @Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingEventEffect.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingEventEffect.kt new file mode 100644 index 0000000000..b134e9d549 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingEventEffect.kt @@ -0,0 +1,84 @@ +package com.tangem.features.staking.impl.presentation.ui + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import com.tangem.common.ui.alerts.models.AlertUM +import com.tangem.core.ui.components.BasicDialog +import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.core.ui.event.EventEffect +import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.shareText +import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.events.StakingEvent + +@Composable +internal fun StakingEventEffect(event: StateEvent, snackbarHostState: SnackbarHostState) { + val context = LocalContext.current + val resources = LocalContext.current.resources + var alertConfig by remember { mutableStateOf(value = null) } + + val keyboardController = LocalSoftwareKeyboardController.current + LaunchedEffect(key1 = alertConfig) { + keyboardController?.hide() + } + + alertConfig?.let { + StakingAlert(state = it, onDismiss = { alertConfig = null }) + } + + EventEffect( + event = event, + onTrigger = { value -> + when (value) { + is StakingEvent.ShowSnackBar -> { + snackbarHostState.showSnackbar(message = value.text.resolveReference(resources)) + } + is StakingEvent.ShowAlert -> { + alertConfig = value.alert + } + is StakingEvent.ShowShareDialog -> { + context.shareText(value.txUrl) + } + } + }, + ) +} + +@Composable +internal fun StakingAlert(state: AlertUM, onDismiss: () -> Unit) { + val confirmButton: DialogButtonUM + val dismissButton: DialogButtonUM? + + val onActionClick = state.onConfirmClick + if (onActionClick != null) { + confirmButton = DialogButtonUM( + title = state.confirmButtonText.resolveReference(), + onClick = { + onActionClick() + onDismiss() + }, + ) + dismissButton = DialogButtonUM( + title = stringResource(id = R.string.common_cancel), + onClick = onDismiss, + ) + } else { + confirmButton = DialogButtonUM( + title = state.confirmButtonText.resolveReference(), + onClick = onDismiss, + ) + dismissButton = null + } + + BasicDialog( + message = state.message.resolveReference(), + confirmButton = confirmButton, + onDismissDialog = onDismiss, + title = state.title?.resolveReference(), + dismissButton = dismissButton, + ) +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt index 6174dcc79c..6fa945af4f 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt @@ -8,11 +8,16 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable -import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -28,19 +33,23 @@ import androidx.compose.ui.text.withStyle 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.Density +import com.tangem.common.ui.navigationButtons.NavigationButtonsState +import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.components.containers.FooterContainer import com.tangem.core.ui.components.inputrow.InputRowDefault import com.tangem.core.ui.components.inputrow.InputRowImageInfo import com.tangem.core.ui.components.list.roundedListWithDividersItems +import com.tangem.core.ui.components.rows.CornersToRound import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig 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.utils.BigDecimalFormatter import com.tangem.domain.staking.model.stakekit.BalanceType +import com.tangem.domain.staking.model.stakekit.RewardBlockType import com.tangem.features.staking.impl.R -import com.tangem.features.staking.impl.presentation.state.BalanceGroupedState import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -50,60 +59,132 @@ import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickInten import com.tangem.utils.StringsSigns.DOT import com.tangem.utils.StringsSigns.PLUS import com.tangem.utils.extensions.orZero -import kotlinx.collections.immutable.ImmutableList private const val BANNER_BLOCK_KEY = "BannerBlock" private const val STAKING_REWARD_BLOCK_KEY = "StakingRewardBlock" private const val ACTIVE_STAKING_BLOCK_KEY = "ActiveStakingBlock" +private const val STAKE_PRIMARY_BUTTON_KEY = "StakePrimaryButton" -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) @Composable -internal fun StakingInitialInfoContent(state: StakingStates.InitialInfoState, clickIntents: StakingClickIntents) { +internal fun StakingInitialInfoContent( + state: StakingStates.InitialInfoState, + buttonState: NavigationButtonsState, + clickIntents: StakingClickIntents, + isBalanceHidden: Boolean, +) { if (state !is StakingStates.InitialInfoState.Data) return - LazyColumn( - modifier = Modifier - .background(TangemTheme.colors.background.secondary) - .padding(horizontal = TangemTheme.dimens.spacing16), - ) { - if (state.yieldBalance == InnerYieldBalanceState.Empty) { - item(key = BANNER_BLOCK_KEY) { - Column( - modifier = Modifier.animateItemPlacement(), - ) { - BannerBlock(onClick = clickIntents::onInitialInfoBannerClick) - SpacerH12() + val pullRefreshState = rememberPullRefreshState( + refreshing = state.pullToRefreshConfig.isRefreshing, + onRefresh = { state.pullToRefreshConfig.onRefresh(PullToRefreshConfig.ShowRefreshState()) }, + ) + Box(modifier = Modifier.pullRefresh(pullRefreshState)) { + LazyColumn( + verticalArrangement = alignLastToBottom(), + modifier = Modifier + .fillMaxSize() + .background(TangemTheme.colors.background.secondary) + .padding(horizontal = TangemTheme.dimens.spacing16), + ) { + if (state.showBanner) { + item(key = BANNER_BLOCK_KEY) { + Column( + modifier = Modifier.animateItemPlacement(), + ) { + BannerBlock(onClick = clickIntents::onInitialInfoBannerClick) + SpacerH12() + } } } + + this.roundedListWithDividersItems( + rows = state.infoItems, + footerContent = { SpacerH12() }, + hideEndText = isBalanceHidden, + ) + + if (state.yieldBalance is InnerYieldBalanceState.Data) { + item(key = STAKING_REWARD_BLOCK_KEY) { + Column(modifier = Modifier.animateItemPlacement()) { + StakingRewardBlock( + rewardCrypto = state.yieldBalance.rewardsCrypto, + rewardFiat = state.yieldBalance.rewardsFiat, + rewardBlockType = state.yieldBalance.rewardBlockType, + onRewardsClick = clickIntents::openRewardsValidators, + isBalanceHidden = isBalanceHidden, + ) + SpacerH12() + } + } + } + + activeStakingBlock( + state = state, + clickIntents = clickIntents, + isBalanceHidden = isBalanceHidden, + ) + + item(STAKE_PRIMARY_BUTTON_KEY) { + SpacerH12() + StakeButtonBlock(buttonState) + } } - this.roundedListWithDividersItems( - rows = state.infoItems, - footerContent = { SpacerH12() }, + PullRefreshIndicator( + modifier = Modifier.align(Alignment.TopCenter), + refreshing = state.pullToRefreshConfig.isRefreshing, + state = pullRefreshState, ) + } +} - if (state.yieldBalance is InnerYieldBalanceState.Data) { - item(key = STAKING_REWARD_BLOCK_KEY) { - Column(modifier = Modifier.animateItemPlacement()) { - StakingRewardBlock( - rewardCrypto = state.yieldBalance.rewardsCrypto, - rewardFiat = state.yieldBalance.rewardsFiat, - isRewardsToClaim = state.yieldBalance.isRewardsToClaim, - isRewardsClaimable = state.yieldBalance.isRewardsClaimable, - onRewardsClick = clickIntents::openRewardsValidators, - ) - SpacerH12() - } - } +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.activeStakingBlock( + state: StakingStates.InitialInfoState.Data, + clickIntents: StakingClickIntents, + isBalanceHidden: Boolean, +) { + if (state.yieldBalance is InnerYieldBalanceState.Data) { + item(ACTIVE_STAKING_BLOCK_KEY) { + Text( + text = stringResource(id = R.string.staking_your_stakes), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .fillMaxWidth() + .clip(CornersToRound.TOP_2.getShape()) + .background(TangemTheme.colors.background.action) + .padding( + top = TangemTheme.dimens.spacing12, + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing4, + ), + ) } - - if (state.yieldBalance is InnerYieldBalanceState.Data) { - item(key = ACTIVE_STAKING_BLOCK_KEY) { - Column(modifier = Modifier.animateItemPlacement()) { - ActiveStakingBlock(state.yieldBalance.balance, clickIntents::onActiveStake) - SpacerH12() - } - } + items( + items = state.yieldBalance.balance, + key = { + // Staked balance does not have unique identifier. + it.toString() + }, + ) { balance -> + ActiveStakingBlock( + balance = balance, + isBalanceHidden = isBalanceHidden, + onClick = clickIntents::onActiveStake, + onAnalytic = clickIntents::onActiveStakeAnalytic, + modifier = Modifier + .animateItemPlacement() + .then( + if (state.yieldBalance.balance.last() == balance) { + Modifier.clip(CornersToRound.BOTTOM_2.getShape()) + } else { + Modifier + }, + ), + ) } } } @@ -115,7 +196,7 @@ private fun BannerBlock(onClick: () -> Unit) { .clip(RoundedCornerShape(size = TangemTheme.dimens.radius14)) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(), + indication = ripple(), onClick = onClick, ), ) { @@ -143,121 +224,123 @@ private fun BannerBlock(onClick: () -> Unit) { private fun StakingRewardBlock( rewardCrypto: String, rewardFiat: String, - isRewardsToClaim: Boolean, - isRewardsClaimable: Boolean, + rewardBlockType: RewardBlockType, onRewardsClick: () -> Unit, + isBalanceHidden: Boolean, ) { - val (text, textColor) = if (isRewardsToClaim) { - annotatedReference { - append(PLUS) - appendSpace() - append(rewardFiat) - appendSpace() - append(DOT) - appendSpace() - append(rewardCrypto) - } to TangemTheme.colors.text.primary1 - } else { - resourceReference(R.string.staking_details_no_rewards_to_claim) to TangemTheme.colors.text.tertiary + val (text, textColor) = when (rewardBlockType) { + RewardBlockType.Rewards -> { + annotatedReference { + append(PLUS) + appendSpace() + append(rewardFiat.orMaskWithStars(isBalanceHidden)) + appendSpace() + append(DOT) + appendSpace() + append(rewardCrypto.orMaskWithStars(isBalanceHidden)) + } to TangemTheme.colors.text.primary1 + } + RewardBlockType.RewardUnavailable -> { + resourceReference(R.string.staking_details_auto_claiming_rewards_daily_text) to + TangemTheme.colors.text.tertiary + } + RewardBlockType.NoRewards -> { + resourceReference(R.string.staking_details_no_rewards_to_claim) to TangemTheme.colors.text.tertiary + } } InputRowDefault( title = resourceReference(R.string.staking_rewards), text = text, - iconRes = R.drawable.ic_chevron_right_24.takeIf { isRewardsToClaim && isRewardsClaimable }, + iconRes = R.drawable.ic_chevron_right_24.takeIf { rewardBlockType == RewardBlockType.Rewards }, textColor = textColor, modifier = Modifier .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(), - enabled = isRewardsToClaim && isRewardsClaimable, + indication = ripple(), + enabled = rewardBlockType == RewardBlockType.Rewards, onClick = onRewardsClick, ), ) } @Composable -private fun ActiveStakingBlock(groups: ImmutableList, onClick: (BalanceState) -> Unit) { +private fun ActiveStakingBlock( + balance: BalanceState, + isBalanceHidden: Boolean, + onClick: (BalanceState) -> Unit, + onAnalytic: () -> Unit, + modifier: Modifier = Modifier, +) { + val (icon, iconTint) = balance.type.getIcon() + InputRowImageInfo( + subtitle = balance.title, + caption = balance.subtitle ?: balance.getAprText(), + infoTitle = balance.fiatAmount.orMaskWithStars(isBalanceHidden), + infoSubtitle = balance.cryptoAmount.orMaskWithStars(isBalanceHidden), + imageUrl = balance.getImage(), + iconRes = icon, + iconTint = iconTint, + modifier = modifier + .background(TangemTheme.colors.background.action) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + enabled = balance.isClickable, + onClick = { + onAnalytic() + onClick(balance) + }, + ), + ) +} + +@Composable +private fun StakeButtonBlock(buttonState: NavigationButtonsState) { + val state = buttonState as? NavigationButtonsState.Data + val primaryButton = state?.primaryButton + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { - groups.forEach { group -> - key(group.title) { - FooterContainer( - footer = group.footer?.resolveReference(), - modifier = Modifier, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(TangemTheme.colors.background.action), - ) { - Text( - text = group.title.resolveReference(), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier.padding( - top = TangemTheme.dimens.spacing12, - start = TangemTheme.dimens.spacing12, - end = TangemTheme.dimens.spacing12, - ), - ) - group.items.forEach { balance -> - key(balance.validator.address) { - InputRowImageInfo( - subtitle = stringReference(balance.validator.name), - caption = getCaption(group.type, balance), - isGrayscaleImage = group.type == BalanceType.UNSTAKING, - infoTitle = balance.fiatAmount, - infoSubtitle = balance.cryptoAmount, - imageUrl = balance.validator.image.orEmpty(), - iconEndRes = R.drawable.ic_chevron_right_24.takeIf { group.isClickable }, - modifier = Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(), - enabled = group.isClickable, - onClick = { onClick(balance) }, - ), - ) - } - } - } - } - } - } + state?.onTextClick?.let { StakingTosText(it) } + NavigationPrimaryButton(primaryButton = primaryButton) } } @Composable -private fun getCaption(balanceType: BalanceType, balance: BalanceState): TextReference { - return if (balanceType == BalanceType.UNSTAKING) { - combinedReference( - resourceReference(R.string.staking_details_unbonding_period), - annotatedReference { - appendSpace() - appendColored( - text = balance.unbondingPeriod.resolveReference(), - color = TangemTheme.colors.text.accent, - ) - }, +private fun BalanceState.getAprText() = combinedReference( + resourceReference(R.string.staking_details_apr), + annotatedReference { + appendSpace() + appendColored( + text = BigDecimalFormatter.formatPercent( + percent = validator?.apr.orZero(), + useAbsoluteValue = true, + ), + color = TangemTheme.colors.text.accent, ) - } else { - combinedReference( - resourceReference(R.string.app_name), - annotatedReference { - appendSpace() - appendColored( - text = BigDecimalFormatter.formatPercent( - percent = balance.validator.apr.orZero(), - useAbsoluteValue = true, - ), - color = TangemTheme.colors.text.accent, - ) - }, - ) - } + }, +) + +@Composable +private fun BalanceType.getIcon() = when (this) { + BalanceType.UNSTAKING -> R.drawable.ic_connection_18 to TangemTheme.colors.icon.accent + BalanceType.UNSTAKED -> R.drawable.ic_connection_18 to TangemTheme.colors.icon.informative + BalanceType.LOCKED -> R.drawable.ic_lock_24 to TangemTheme.colors.icon.informative + else -> null to TangemTheme.colors.icon.informative +} + +@Composable +private fun BalanceState.getImage() = when (type) { + BalanceType.UNSTAKING, + BalanceType.UNSTAKED, + BalanceType.LOCKED, + -> null + else -> validator?.image } private val textGradientColors = listOf( @@ -265,6 +348,24 @@ private val textGradientColors = listOf( Color(0xff8fb4df), ) +@Composable +private fun alignLastToBottom() = remember { + object : Arrangement.Vertical { + override fun Density.arrange(totalSize: Int, sizes: IntArray, outPositions: IntArray) { + var currentOffset = 0 + + sizes.forEachIndexed { index, size -> + if (index == sizes.lastIndex) { + outPositions[index] = totalSize - size + } else { + outPositions[index] = currentOffset + currentOffset += size + } + } + } + } +} + // region preview @Preview(showBackground = true, widthDp = 360) @@ -276,7 +377,9 @@ private fun StakingInitialInfoContent_Preview( TangemThemePreview { StakingInitialInfoContent( state = feeState, + buttonState = NavigationButtonsState.Empty, clickIntents = StakingClickIntentsStub, + isBalanceHidden = false, ) } } 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 ef94fa0c1d..5c2e5ceb51 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 @@ -8,6 +8,7 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -15,6 +16,7 @@ import com.tangem.common.ui.amountScreen.AmountScreenContent import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig import com.tangem.common.ui.navigationButtons.NavigationButtonsBlock +import com.tangem.common.ui.navigationButtons.NavigationButtonsState import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.resolveReference @@ -23,7 +25,9 @@ import com.tangem.features.staking.impl.R 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.bottomsheet.StakingActionSelectionBottomSheetConfig import com.tangem.features.staking.impl.presentation.state.bottomsheet.StakingInfoBottomSheetConfig +import com.tangem.features.staking.impl.presentation.ui.bottomsheet.StakingActionSelectorBottomSheet import com.tangem.features.staking.impl.presentation.ui.bottomsheet.StakingInfoBottomSheet import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -32,6 +36,9 @@ import kotlinx.coroutines.flow.withIndex @Composable internal fun StakingScreen(uiState: StakingUiState) { + val snackbarHostState = remember { SnackbarHostState() } + val confirmationState = uiState.confirmationState as? StakingStates.ConfirmationState.Data + BackHandler(onBack = uiState.clickIntents::onPrevClick) Column( modifier = Modifier @@ -41,7 +48,7 @@ internal fun StakingScreen(uiState: StakingUiState) { .systemBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { - SendAppBar( + StakingAppBar( uiState = uiState, ) StakingScreenContent( @@ -49,7 +56,9 @@ internal fun StakingScreen(uiState: StakingUiState) { modifier = Modifier.weight(1f), ) NavigationButtonsBlock( - buttonState = uiState.buttonsState, + buttonState = uiState.buttonsState.takeUnless { uiState.currentStep == StakingStep.InitialInfo } + ?: NavigationButtonsState.Empty, + footerText = confirmationState?.footerText.takeIf { uiState.currentStep == StakingStep.Confirmation }, modifier = Modifier.padding( start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing16, @@ -58,6 +67,11 @@ internal fun StakingScreen(uiState: StakingUiState) { ) StakingBottomSheet(bottomSheetConfig = uiState.bottomSheetConfig) } + + StakingEventEffect( + event = uiState.event, + snackbarHostState = snackbarHostState, + ) } @Composable @@ -66,28 +80,30 @@ fun StakingBottomSheet(bottomSheetConfig: TangemBottomSheetConfig?) { when (bottomSheetConfig.content) { is StakingInfoBottomSheetConfig -> StakingInfoBottomSheet(bottomSheetConfig) is GiveTxPermissionBottomSheetConfig -> GiveTxPermissionBottomSheet(bottomSheetConfig) + is StakingActionSelectionBottomSheetConfig -> StakingActionSelectorBottomSheet(bottomSheetConfig) } } @Composable -private fun SendAppBar(uiState: StakingUiState) { - val backIcon = when (uiState.currentStep) { +private fun StakingAppBar(uiState: StakingUiState) { + val (backIcon, click) = when (uiState.currentStep) { StakingStep.Amount, - StakingStep.Validators, StakingStep.Confirmation, -> { - R.drawable.ic_close_24 + R.drawable.ic_close_24 to uiState.clickIntents::onBackClick } + StakingStep.Validators, StakingStep.RewardsValidators, StakingStep.InitialInfo, -> { - R.drawable.ic_back_24 + R.drawable.ic_back_24 to uiState.clickIntents::onPrevClick } } AppBarWithBackButtonAndIcon( text = uiState.title.resolveReference(), + subtitle = uiState.subtitle?.resolveReference(), backIconRes = backIcon, - onBackClick = uiState.clickIntents::onBackClick, + onBackClick = click, backgroundColor = TangemTheme.colors.background.secondary, modifier = Modifier.height(TangemTheme.dimens.size56), ) @@ -105,9 +121,7 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M snapshotFlow { isTransitionAnimationRunning } .withIndex() .map { (index, running) -> - if (running && index != 0) { - delay(timeMillis = 200) - } + if (running && index != 0) delay(timeMillis = 200) running } .first { !it } @@ -139,7 +153,9 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M when (state) { StakingStep.InitialInfo -> StakingInitialInfoContent( state = uiState.initialInfoState, + buttonState = uiState.buttonsState, clickIntents = uiState.clickIntents, + isBalanceHidden = uiState.isBalanceHidden, ) StakingStep.RewardsValidators -> { StakingClaimRewardsValidatorContent( @@ -149,8 +165,9 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M } StakingStep.Amount -> AmountScreenContent( amountState = uiState.amountState, - isBalanceHiding = uiState.isBalanceHidden, + isBalanceHidden = uiState.isBalanceHidden, clickIntents = uiState.clickIntents, + modifier = Modifier.background(TangemTheme.colors.background.secondary), ) StakingStep.Confirmation -> StakingConfirmationContent( amountState = uiState.amountState, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTosText.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTosText.kt new file mode 100644 index 0000000000..2f239888e8 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTosText.kt @@ -0,0 +1,62 @@ +package com.tangem.features.staking.impl.presentation.ui + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import com.tangem.core.ui.extensions.appendColored +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.staking.impl.R + +private const val TERMS_OF_USE_KEY = "termsOfUse" +private const val PRIVACY_POLICY_KEY = "privacyPolicy" + +private const val TERMS_OF_USE_URL = "https://docs.stakek.it/docs/terms-of-use" +private const val PRIVACY_POLICY_URL = "https://docs.stakek.it/docs/privacy-policy" + +@Composable +internal fun StakingTosText(onTextClick: (String) -> Unit) { + val termsOfUse = stringResource(R.string.common_terms_of_use) + val privacyPolicy = stringResource(R.string.common_privacy_policy) + val tosText = stringResource(R.string.staking_legal, termsOfUse, privacyPolicy) + + val clickableAnnotation = buildAnnotatedString { + append(tosText.substringBefore(termsOfUse)) + + pushStringAnnotation(TERMS_OF_USE_KEY, "") + appendColored(termsOfUse, TangemTheme.colors.text.accent) + pop() + + append(tosText.substringAfter(termsOfUse).substringBefore(privacyPolicy)) + + pushStringAnnotation(PRIVACY_POLICY_KEY, "") + appendColored(privacyPolicy, TangemTheme.colors.text.accent) + pop() + } + + ClickableText( + text = clickableAnnotation, + style = TangemTheme.typography.caption2.copy( + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ), + onClick = { offset -> + clickableAnnotation.getStringAnnotations( + tag = TERMS_OF_USE_KEY, + start = offset, + end = offset, + ).firstOrNull()?.let { + onTextClick(TERMS_OF_USE_URL) + } + + clickableAnnotation.getStringAnnotations( + tag = PRIVACY_POLICY_KEY, + start = offset, + end = offset, + ).firstOrNull()?.let { + onTextClick(PRIVACY_POLICY_URL) + } + }, + ) +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt index e6ed596800..9f8db40d8e 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt @@ -44,7 +44,9 @@ internal fun StakingValidatorListContent( LazyColumn( contentPadding = PaddingValues(bottom = bottomBarHeight), - modifier = modifier.padding(horizontal = TangemTheme.dimens.spacing16), + modifier = modifier + .background(TangemTheme.colors.background.secondary) + .padding(horizontal = TangemTheme.dimens.spacing16), ) { if (state is ValidatorState.Content) { val validators = state.availableValidators @@ -58,7 +60,7 @@ internal fun StakingValidatorListContent( InputRowImageSelector( subtitle = stringReference(item.name), caption = combinedReference( - resourceReference(R.string.staking_details_apr), + resourceReference(R.string.staking_details_annual_percentage_rate), annotatedReference { appendSpace() appendColored( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/NotificationsBlock.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/NotificationsBlock.kt index a9dcad67cf..6b88e7ae2f 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/NotificationsBlock.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/NotificationsBlock.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.ui.Modifier +import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.CardWithIcon import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.extensions.resolveReference @@ -13,7 +14,7 @@ import com.tangem.features.staking.impl.presentation.state.StakingNotification import kotlinx.collections.immutable.ImmutableList @Composable -internal fun NotificationsBlock(notifications: ImmutableList) { +internal fun NotificationsBlock(notifications: ImmutableList) { notifications.forEach { notification -> key(notification) { if (notification is StakingNotification.Warning.TransactionInProgress) { @@ -32,8 +33,19 @@ internal fun NotificationsBlock(notifications: ImmutableList TangemTheme.colors.icon.warning - is StakingNotification.Warning -> TangemTheme.colors.icon.accent + is StakingNotification.Info, + is NotificationUM.Info, + -> TangemTheme.colors.icon.accent + + is StakingNotification.Warning, + is NotificationUM.Error.TokenExceedsBalance, + is NotificationUM.Error.ExceedsBalance, + is NotificationUM.Warning, + -> null + + is StakingNotification.Error, + is NotificationUM.Error, + -> TangemTheme.colors.icon.warning }, ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt index fdca1be81a..e666de9908 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt @@ -29,12 +29,17 @@ import com.tangem.features.staking.impl.presentation.state.FeeState import java.math.BigDecimal @Composable -internal fun StakingFeeBlock(feeState: FeeState) { +internal fun StakingFeeBlock(feeState: FeeState, isTransactionSent: Boolean) { + val backgroundColor = if (isTransactionSent) { + TangemTheme.colors.background.action + } else { + TangemTheme.colors.button.disabled + } Column( modifier = Modifier .fillMaxWidth() .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(TangemTheme.colors.background.action) + .background(backgroundColor) .padding(TangemTheme.dimens.spacing12), ) { Text( @@ -43,16 +48,13 @@ internal fun StakingFeeBlock(feeState: FeeState) { color = TangemTheme.colors.text.tertiary, ) - Box( - modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), - ) { + Box(modifier = Modifier.padding(top = TangemTheme.dimens.spacing8)) { when (feeState) { is FeeState.Content -> { val feeAmount = feeState.fee?.amount - val (title, icon) = R.string.common_fee_selector_option_market to R.drawable.ic_bird_24 SelectorRowItem( - titleRes = title, - iconRes = icon, + titleRes = R.string.common_fee_selector_option_market, + iconRes = R.drawable.ic_bird_24, preDot = stringReference( BigDecimalFormatter.formatCryptoFeeAmount( cryptoAmount = feeAmount?.value, @@ -74,9 +76,22 @@ internal fun StakingFeeBlock(feeState: FeeState) { ) } is FeeState.Loading -> { + SelectorRowItem( + titleRes = R.string.common_fee_selector_option_market, + iconRes = R.drawable.ic_bird_24, + isSelected = true, + paddingValues = PaddingValues(), + showDivider = false, + ) FeeLoading(feeState) } is FeeState.Error -> { + SelectorRowItem( + titleRes = R.string.common_fee_selector_option_market, + iconRes = R.drawable.ic_bird_24, + isSelected = true, + paddingValues = PaddingValues(), + ) FeeError(feeState) } } @@ -128,6 +143,7 @@ private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) va TangemThemePreview { StakingFeeBlock( feeState = value, + isTransactionSent = false, ) } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt index abb57b51a7..be7909007e 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -28,7 +28,7 @@ internal fun ValidatorBlock(validatorState: ValidatorState, onClick: () -> Unit) .clickable( enabled = validatorState.isClickable, interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(), + indication = ripple(), onClick = onClick, ), ) { diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/StakingActionSelectorBottomSheet.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/StakingActionSelectorBottomSheet.kt new file mode 100644 index 0000000000..8f4434e496 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/StakingActionSelectorBottomSheet.kt @@ -0,0 +1,92 @@ +package com.tangem.features.staking.impl.presentation.ui.bottomsheet + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle +import com.tangem.core.ui.components.inputrow.InputRowDefault +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.staking.model.stakekit.PendingAction +import com.tangem.domain.staking.model.stakekit.action.StakingActionType +import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.bottomsheet.StakingActionSelectionBottomSheetConfig +import com.tangem.features.staking.impl.presentation.state.utils.getPendingActionTitle +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun StakingActionSelectorBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + title = { content -> + TangemBottomSheetTitle(title = content.title) + }, + containerColor = TangemTheme.colors.background.tertiary, + ) { content -> + Column( + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing32, + ) + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) { + content.actions.forEachIndexed { index, action -> + InputRowDefault( + text = action.type.getPendingActionTitle(), + textColor = TangemTheme.colors.text.primary1, + showDivider = index != content.actions.lastIndex, + modifier = Modifier + .fillMaxWidth() + .clickable { + content.onActionSelect(action) + }, + ) + } + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_StakingActionSelectorBottomSheet() { + TangemThemePreview { + StakingActionSelectorBottomSheet( + config = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = {}, + content = StakingActionSelectionBottomSheetConfig( + title = resourceReference(R.string.common_select_action), + actions = persistentListOf( + PendingAction( + type = StakingActionType.CLAIM_REWARDS, + passthrough = "", + args = null, + ), + PendingAction( + type = StakingActionType.RESTAKE_REWARDS, + passthrough = "", + args = null, + ), + ), + onActionSelect = {}, + ), + ), + ) + } +} +// endregion Preview \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingClickIntents.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingClickIntents.kt index db14a50094..c6fe6235d4 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingClickIntents.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingClickIntents.kt @@ -1,32 +1,40 @@ package com.tangem.features.staking.impl.presentation.viewmodel import com.tangem.common.ui.amountScreen.AmountScreenClickIntents +import com.tangem.common.ui.notifications.NotificationUM import com.tangem.domain.staking.model.stakekit.PendingAction import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.features.staking.impl.presentation.state.BalanceState -import com.tangem.features.staking.impl.presentation.state.transformers.InfoType +import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal +@Suppress("TooManyFunctions") internal interface StakingClickIntents : AmountScreenClickIntents { fun onBackClick() fun onNextClick( - actionType: StakingActionCommonType? = null, - pendingActions: ImmutableList = persistentListOf(), + actionTypeToOverwrite: StakingActionCommonType? = null, + pendingAction: PendingAction? = null, + pendingActions: ImmutableList? = null, ) - fun onActionClick(pendingAction: PendingAction?) + fun onActionClick() fun onPrevClick() + fun onRefreshSwipe(isRefreshing: Boolean) + fun onInitialInfoBannerClick() fun onInfoClick(infoType: InfoType) - override fun onAmountNext() = onNextClick(actionType = null) + fun getFee(pendingAction: PendingAction?, pendingActions: ImmutableList?) + + override fun onAmountNext() = onNextClick(actionTypeToOverwrite = null) fun openValidators() @@ -36,11 +44,27 @@ internal interface StakingClickIntents : AmountScreenClickIntents { fun onActiveStake(activeStake: BalanceState) + fun onActiveStakeAnalytic() + fun showApprovalBottomSheet() fun onApprovalClick() + fun onAmountReduceByClick( + reduceAmountBy: BigDecimal, + reduceAmountByDiff: BigDecimal, + notification: Class, + ) + + fun onAmountReduceToClick(reduceAmountTo: BigDecimal, notification: Class) + + fun onNotificationCancel(notification: Class) + fun onExploreClick() fun onShareClick() + + fun onFailedTxEmailClick(errorMessage: String) + + fun openTokenDetails(cryptoCurrency: CryptoCurrency) } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingViewModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingViewModel.kt index 33f5b8df76..cebace0c6e 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingViewModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingViewModel.kt @@ -6,86 +6,103 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import arrow.core.getOrElse -import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.routing.AppRoute import com.tangem.common.routing.bundle.unbundle +import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.staking.* +import com.tangem.domain.feedback.FeedbackManager +import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.SaveBlockchainErrorUseCase +import com.tangem.domain.feedback.models.BlockchainErrorInfo +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.staking.IsAnyTokenStakedUseCase +import com.tangem.domain.staking.IsApproveNeededUseCase import com.tangem.domain.staking.model.StakingApproval import com.tangem.domain.staking.model.stakekit.PendingAction import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType -import com.tangem.domain.staking.model.stakekit.transaction.ActionParams -import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate -import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionType -import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase -import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction +import com.tangem.domain.tokens.* import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.tokens.model.CryptoCurrencyAddress import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase import com.tangem.domain.transaction.usecase.GetAllowanceUseCase -import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase -import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase +import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.staking.impl.analytics.StakeScreenSource +import com.tangem.features.staking.impl.analytics.StakingAnalyticsEvents +import com.tangem.features.staking.impl.analytics.utils.StakingAnalyticSender import com.tangem.features.staking.impl.navigation.InnerStakingRouter import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType +import com.tangem.features.staking.impl.presentation.state.events.StakingEventFactory +import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater +import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeTransactionLoader +import com.tangem.features.staking.impl.presentation.state.helpers.StakingTransactionSender import com.tangem.features.staking.impl.presentation.state.transformers.* -import com.tangem.features.staking.impl.presentation.state.transformers.amount.AmountChangeStateTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.amount.AmountCurrencyChangeStateTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.amount.AmountMaxValueStateTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.amount.AmountPasteDismissStateTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.amount.* import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalBottomSheetInProgressTransformer import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalInProgressTransformer import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetConfirmationStateAssentApprovalTransformer import com.tangem.features.staking.impl.presentation.state.transformers.approval.ShowApprovalBottomSheetTransformer import com.tangem.features.staking.impl.presentation.state.transformers.validator.ValidatorSelectChangeTransformer +import com.tangem.features.staking.impl.presentation.state.utils.isSolanaWithdraw import com.tangem.utils.Provider import com.tangem.utils.coroutines.* +import com.tangem.utils.extensions.isSingleItem import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber import java.math.BigDecimal +import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject import kotlin.properties.Delegates -@Suppress("LargeClass", "LongParameterList") +@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") @HiltViewModel internal class StakingViewModel @Inject constructor( private val stateController: StakingStateController, private val dispatchers: CoroutineDispatcherProvider, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, - private val getCryptoCurrencyStatusSyncUseCase: GetCryptoCurrencyStatusSyncUseCase, + private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, - private val getStakingTransactionUseCase: GetStakingTransactionUseCase, - private val getConstructedStakingTransactionUseCase: GetConstructedStakingTransactionUseCase, - private val estimateGasUseCase: EstimateGasUseCase, private val sendTransactionUseCase: SendTransactionUseCase, - private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, - private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, - private val submitHashUseCase: SubmitHashUseCase, - private val isStakeMoreAvailableUseCase: IsStakeMoreAvailableUseCase, - private val stakingYieldBalanceUseCase: FetchStakingYieldBalanceUseCase, private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, private val getAllowanceUseCase: GetAllowanceUseCase, - private val getFeeUseCase: GetFeeUseCase, private val isApproveNeededUseCase: IsApproveNeededUseCase, - private val clipboardManager: ClipboardManager, private val vibratorHapticManager: VibratorHapticManager, + private val feedbackManager: FeedbackManager, + private val getCardInfoUseCase: GetCardInfoUseCase, + private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, + private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, + private val validateTransactionUseCase: ValidateTransactionUseCase, + private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, + private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, + private val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase, + private val stakingTransactionLoader: StakingTransactionSender.Factory, + private val stakingFeeTransactionLoader: StakingFeeTransactionLoader.Factory, + private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, + private val analyticsEventHandler: AnalyticsEventHandler, + @DelayedWork private val coroutineScope: CoroutineScope, savedStateHandle: SavedStateHandle, ) : ViewModel(), DefaultLifecycleObserver, StakingClickIntents { @@ -114,10 +131,53 @@ internal class StakingViewModel @Inject constructor( private var userWallet: UserWallet by Delegates.notNull() private var appCurrency: AppCurrency by Delegates.notNull() + private var isInitialInfoAnalyticSent: Boolean = false + + private val balanceUpdater by lazy(LazyThreadSafetyMode.NONE) { + stakingBalanceUpdater.create( + cryptoCurrencyStatus, + userWallet, + ) + } + + private val feeLoader by lazy(LazyThreadSafetyMode.NONE) { + stakingFeeTransactionLoader.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + userWallet = userWallet, + yield = yield, + stakingApproval = stakingApproval, + ) + } + + private val transactionSender by lazy(LazyThreadSafetyMode.NONE) { + stakingTransactionLoader.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + userWallet = userWallet, + yield = yield, + isAmountSubtractAvailable = isAmountSubtractAvailable, + ) + } + + private val stakingEventFactory: StakingEventFactory + get() = StakingEventFactory( + stateController = stateController, + popBackStack = stakingStateRouter::onBackClick, + onFailedTxEmailClick = ::onFailedTxEmailClick, + ) + + private val stakingAnalyticSender = StakingAnalyticSender( + analyticsEventHandler = analyticsEventHandler, + ) + private var stakingApproval: StakingApproval = StakingApproval.Empty + private var isAmountSubtractAvailable: Boolean = false private val allowanceTaskScheduler = SingleTaskScheduler() + private val transactionsInProgress: CopyOnWriteArrayList = CopyOnWriteArrayList() + private var approvalJobHolder: JobHolder = JobHolder() + private var feeJobHolder: JobHolder = JobHolder() + private var sendTransactionJobHolder = JobHolder() init { subscribeOnSelectedAppCurrency() @@ -125,200 +185,160 @@ internal class StakingViewModel @Inject constructor( subscribeOnCurrencyStatusUpdates() } + override fun onCleared() { + super.onCleared() + approvalJobHolder.cancel() + feeJobHolder.cancel() + sendTransactionJobHolder.cancel() + } + override fun onBackClick() { stakingStateRouter.onBackClick() } - override fun onNextClick(actionType: StakingActionCommonType?, pendingActions: ImmutableList) { - if (actionType != null) { - stateController.update { it.copy(actionType = actionType) } + override fun onNextClick( + actionTypeToOverwrite: StakingActionCommonType?, + pendingAction: PendingAction?, + pendingActions: ImmutableList?, + ) { + if (actionTypeToOverwrite != null) { + stateController.update(SetActionToExecuteTransformer(actionTypeToOverwrite, pendingAction, pendingActions)) } stakingStateRouter.onNextClick() - if (isAssentState()) { - getFee(pendingActions) + when { + isInitState() -> { + stateController.update(SetConfirmationStateLoadingTransformer(yield, appCurrency)) + onRefreshSwipe(isRefreshing = false) + } + isAssentState() -> { + getFee(pendingAction, pendingActions) + val amountState = value.amountState as? AmountState.Data + if (amountState?.amountTextField?.isWarning == true) { + stateController.update( + AmountRoundToIntegerTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + ), + ) + } + } } } - override fun onActionClick(pendingAction: PendingAction?) { - handleOnNextConfirmationClick(pendingAction) - stakingStateRouter.onNextClick() + override fun onActionClick() { + handleOnNextConfirmationClick() } - private fun handleOnNextConfirmationClick(pendingAction: PendingAction?) { + override fun getFee(pendingAction: PendingAction?, pendingActions: ImmutableList?) { + stateController.update(SetConfirmationStateLoadingTransformer(yield, appCurrency)) + viewModelScope.launch { + feeLoader.getFee( + pendingAction = pendingAction, + pendingActions = pendingActions, + onStakingFee = { gasEstimate -> + stateController.update( + SetConfirmationStateAssentTransformer( + appCurrencyProvider = Provider { appCurrency }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fee = gasEstimate, + action = pendingAction, + actions = pendingActions, + ), + ) + updateNotifications() + }, + onFeeError = { error -> + analyticsEventHandler.send(StakingAnalyticsEvents.StakingError(value.cryptoCurrencyName)) + stateController.update(AddStakingErrorTransformer()) + updateNotifications(error) + }, + onApprovalFee = { fee -> + stateController.update( + SetConfirmationStateAssentApprovalTransformer( + appCurrencyProvider = Provider { appCurrency }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fee = fee, + ), + ) + updateNotifications() + }, + ) + }.saveIn(feeJobHolder) + } + + private fun handleOnNextConfirmationClick() { if (isAssentState()) { viewModelScope.launch { - stateController.update(SetConfirmationStateInProgressTransformer(pendingAction)) - - val confirmationState = - value.confirmationState as? StakingStates.ConfirmationState.Data ?: error("No confirmation state") - val validatorState = confirmationState.validatorState as? ValidatorState.Content - ?: error("No validator provided") - val amountState = value.amountState as? AmountState.Data ?: error("No amount provided") - val amountValue = amountState.amountTextField.cryptoAmount.value ?: error("No amount value") - val fee = (confirmationState.feeState as? FeeState.Content)?.fee ?: error("No fee provided") - - val stakingTransaction = getStakingTransactionUseCase( - userWalletId = userWalletId, - network = cryptoCurrencyStatus.currency.network, - params = ActionParams( - actionCommonType = value.actionType, - integrationId = yield.id, - amount = amountValue, - address = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value - ?: error("No available address"), - validatorAddress = validatorState.chosenValidator.address, - token = yield.token, - passthrough = pendingAction?.passthrough, - type = pendingAction?.type, - ), - ).getOrElse { - error(it) - } - - stakingTransaction - .filterNot { it.type == StakingTransactionType.APPROVAL } - .forEach { transaction -> - val (constructedTransaction, transactionData) = getConstructedStakingTransactionUseCase( - networkId = cryptoCurrencyStatus.currency.network.id.value, - fee = fee, - transactionId = transaction.id, - ).getOrNull() ?: error("No constructed transaction") - - sendStakingTransaction( - transactionId = constructedTransaction.id, - gasEstimate = constructedTransaction.gasEstimate ?: error("No gas estimate available"), - txData = transactionData, - pendingActionList = confirmationState.pendingActions, - ) - } - } - } - } - - private fun getFee(pendingActions: ImmutableList) { - viewModelScope.launch { - stateController.update( - SetConfirmationStateLoadingTransformer( - yield = yield, - ), - ) - val cryptoCurrencyValue = cryptoCurrencyStatus.value - val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data - ?: error("No confirmation state") - val validatorState = confirmationState.validatorState as? ValidatorState.Content - ?: error("No validator provided") - - val amount = (value.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value - ?: error("No amount provided") - val sourceAddress = cryptoCurrencyValue.networkAddress?.defaultAddress?.value - ?: error("No available address") - val validatorAddress = validatorState.chosenValidator.address - - val approval = stakingApproval as? StakingApproval.Needed - if (approval != null) { - val allowance = getAllowanceUseCase( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - spenderAddress = approval.spenderAddress, - ).getOrElse { BigDecimal.ZERO } - - if (allowance < amount) { - getApproveFee( - amount = amount, - validatorAddress = validatorAddress, - ) - } else { - estimateGas( - pendingActions = pendingActions, - amount = amount, - sourceAddress = sourceAddress, - validatorAddress = validatorAddress, - ) - } - } else { - estimateGas( - pendingActions = pendingActions, - amount = amount, - sourceAddress = sourceAddress, - validatorAddress = validatorAddress, + stateController.update(SetConfirmationStateInProgressTransformer()) + transactionSender.constructAndSendTransactions( + onConstructSuccess = { constructedTransactions -> + transactionsInProgress.addAll(constructedTransactions) + }, + onConstructError = { error -> + Timber.e(error.toString()) + analyticsEventHandler.send(StakingAnalyticsEvents.StakingError(value.cryptoCurrencyName)) + stakingEventFactory.createStakingErrorAlert(error) + stateController.update(SetConfirmationStateResetAssentTransformer) + }, + onSendSuccess = { txUrl -> + stakingAnalyticSender.sendTransactionStakingAnalytics(stateController.value) + transactionsInProgress.clear() + stateController.update(SetConfirmationStateCompletedTransformer(txUrl)) + }, + onSendError = { error -> + Timber.e(error.toString()) + analyticsEventHandler.send(StakingAnalyticsEvents.StakingError(value.cryptoCurrencyName)) + stakingEventFactory.createSendTransactionErrorAlert(error) + stateController.update(SetConfirmationStateResetAssentTransformer) + }, ) - } + }.saveIn(sendTransactionJobHolder) } } - private suspend fun estimateGas( - pendingActions: ImmutableList, - amount: BigDecimal, - sourceAddress: String, - validatorAddress: String, - ) { - val pendingAction = pendingActions.firstOrNull() - val stakingGasEstimate = estimateGasUseCase( - userWalletId = userWalletId, - network = cryptoCurrencyStatus.currency.network, - params = ActionParams( - actionCommonType = value.actionType, - integrationId = yield.id, - amount = amount, - address = sourceAddress, - validatorAddress = validatorAddress, - token = yield.token, - passthrough = pendingAction?.passthrough, - type = pendingAction?.type, - ), - ).getOrElse { - stateController.update(AddStakingErrorTransformer(it)) - return - } - - stateController.update( - SetConfirmationStateAssentTransformer( - appCurrencyProvider = Provider { appCurrency }, - feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, - stakingGasEstimate = stakingGasEstimate, - pendingActionList = pendingActions, - ), - ) - } - - private suspend fun getApproveFee(amount: BigDecimal, validatorAddress: String) { - val approvalFee = getFeeUseCase( - amount = amount, - destination = validatorAddress, - userWallet = userWallet, - cryptoCurrency = cryptoCurrencyStatus.currency, - ).getOrElse { - // TODO staking error - return - } - - stateController.update( - SetConfirmationStateAssentApprovalTransformer( - appCurrencyProvider = Provider { appCurrency }, - feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, - fee = approvalFee, - ), - ) - } - override fun onPrevClick() { - stakingStateRouter.onPrevClick() + if (value.currentStep == StakingStep.Confirmation) { + when ((value.confirmationState as? StakingStates.ConfirmationState.Data)?.innerState) { + InnerConfirmationStakingState.ASSENT -> { + stakingStateRouter.onPrevClick() + } + null, + InnerConfirmationStakingState.IN_PROGRESS, + -> { + // do nothing while transaction is in progress + } + InnerConfirmationStakingState.COMPLETED -> { + onNextClick() + } + } + } else { + stakingStateRouter.onPrevClick() + } + } + + override fun onRefreshSwipe(isRefreshing: Boolean) { + stateController.update(SetInitialLoadingStateTransformer(isRefreshing)) + coroutineScope.launch { + balanceUpdater.instantUpdate() + }.invokeOnCompletion { + stateController.update(SetInitialLoadingStateTransformer(false)) + } } override fun onInitialInfoBannerClick() { - // innerRouter.openUrl(WHAT_IS_STAKING_ARTICLE_URL) + analyticsEventHandler.send(StakingAnalyticsEvents.WhatIsStaking(cryptoCurrencyStatus.currency.symbol)) + innerRouter.openUrl(WHAT_IS_STAKING_ARTICLE_URL) } override fun onInfoClick(infoType: InfoType) { stateController.update( ShowInfoBottomSheetStateTransformer(infoType) { - stateController.update(DismissBottomSheetStateTransformer()) + stateController.update(DismissBottomSheetStateTransformer) }, ) } override fun onAmountValueChange(value: String) { - stateController.update(AmountChangeStateTransformer(cryptoCurrencyStatus, yield, value)) + stateController.update(AmountChangeStateTransformer(cryptoCurrencyStatus, value, yield)) } override fun onAmountPasteTriggerDismiss() { @@ -326,30 +346,99 @@ internal class StakingViewModel @Inject constructor( } override fun onMaxValueClick() { + analyticsEventHandler.send(StakingAnalyticsEvents.ButtonMax(cryptoCurrencyStatus.currency.symbol)) stateController.update(AmountMaxValueStateTransformer(cryptoCurrencyStatus, yield)) } override fun onCurrencyChangeClick(isFiat: Boolean) { + analyticsEventHandler.send( + StakingAnalyticsEvents.AmountSelectCurrency(cryptoCurrencyStatus.currency.symbol, isFiat), + ) stateController.update(AmountCurrencyChangeStateTransformer(cryptoCurrencyStatus, isFiat)) } - override fun openValidators() = stakingStateRouter.showValidators() + override fun openValidators() { + analyticsEventHandler.send( + StakingAnalyticsEvents.ButtonValidator( + source = StakeScreenSource.Confirmation, + token = cryptoCurrencyStatus.currency.symbol, + ), + ) + stakingStateRouter.showValidators() + } override fun onValidatorSelect(validator: Yield.Validator) { stateController.update(ValidatorSelectChangeTransformer(validator)) } - override fun openRewardsValidators() = onNextClick(actionType = StakingActionCommonType.PENDING_REWARDS) + override fun openRewardsValidators() { + analyticsEventHandler.send( + StakingAnalyticsEvents.ButtonRewards(value.cryptoCurrencyName), + ) + val rewardsValidators = + stateController.value.rewardsValidatorsState as? StakingStates.RewardsValidatorsState.Data + val rewards = rewardsValidators?.rewards + if (rewards != null && rewards.isSingleItem()) { + onActiveStake(rewards.first()) + } else { + analyticsEventHandler.send( + StakingAnalyticsEvents.ButtonValidator( + source = StakeScreenSource.Info, + token = cryptoCurrencyStatus.currency.symbol, + ), + ) + onNextClick(actionTypeToOverwrite = StakingActionCommonType.PENDING_REWARDS) + } + } override fun onActiveStake(activeStake: BalanceState) { - val actionType = if (activeStake.pendingActions.isEmpty()) { - StakingActionCommonType.EXIT + val isAllWithdrawActions = isSolanaWithdraw( + cryptoCurrencyStatus.currency.network.id.value, + activeStake.pendingActions, + ) + val isMultiplePendingActions = activeStake.pendingActions.size > 1 + if (isMultiplePendingActions && !isAllWithdrawActions) { + stateController.update( + ShowActionSelectorBottomSheetTransformer( + pendingActions = activeStake.pendingActions, + onActionSelect = { action -> + stateController.update(ValidatorSelectChangeTransformer(activeStake.validator)) + stateController.update( + AmountChangeStateTransformer( + cryptoCurrencyStatus, + activeStake.cryptoValue, + yield, + ), + ) + onNextClick( + actionTypeToOverwrite = StakingActionCommonType.PENDING_OTHER, + pendingAction = action, + ) + stateController.update(DismissBottomSheetStateTransformer) + }, + onDismiss = { stateController.update(DismissBottomSheetStateTransformer) }, + ), + ) } else { - StakingActionCommonType.PENDING_OTHER + stateController.update(ActionTypeActiveStakeTransformer(cryptoCurrencyStatus, activeStake)) + stateController.update(ValidatorSelectChangeTransformer(activeStake.validator)) + stateController.update(AmountChangeStateTransformer(cryptoCurrencyStatus, activeStake.cryptoValue, yield)) + + onNextClick( + actionTypeToOverwrite = null, + pendingAction = activeStake.pendingActions.firstOrNull(), + pendingActions = activeStake.pendingActions.takeIf { isAllWithdrawActions }, + ) } - stateController.update(ValidatorSelectChangeTransformer(activeStake.validator)) - stateController.update(AmountChangeStateTransformer(cryptoCurrencyStatus, yield, activeStake.cryptoValue)) - onNextClick(actionType, activeStake.pendingActions) + } + + override fun onActiveStakeAnalytic() { + analyticsEventHandler.send( + StakingAnalyticsEvents.ButtonValidator( + source = StakeScreenSource.Info, + token = cryptoCurrencyStatus.currency.symbol, + ), + ) } override fun showApprovalBottomSheet() { @@ -359,7 +448,7 @@ internal class StakingViewModel @Inject constructor( cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, ) { - stateController.update(DismissBottomSheetStateTransformer()) + stateController.update(DismissBottomSheetStateTransformer) }, ) } @@ -368,7 +457,7 @@ internal class StakingViewModel @Inject constructor( viewModelScope.launch { stateController.update( SetApprovalBottomSheetInProgressTransformer { - stateController.update(DismissBottomSheetStateTransformer()) + stateController.update(DismissBottomSheetStateTransformer) }, ) @@ -392,6 +481,7 @@ internal class StakingViewModel @Inject constructor( ).fold( ifLeft = { error -> Timber.e(error.toString()) + analyticsEventHandler.send(StakingAnalyticsEvents.TransactionError(value.cryptoCurrencyName)) stateController.update( SetConfirmationStateAssentApprovalTransformer( appCurrencyProvider = Provider { appCurrency }, @@ -399,7 +489,8 @@ internal class StakingViewModel @Inject constructor( fee = TransactionFee.Single(fee), ), ) - // TODO staking error + stakingEventFactory.createGenericErrorAlert(error.message ?: error.toString()) + stateController.update(SetConfirmationStateResetAssentTransformer) return@launch }, ifRight = { it }, @@ -412,6 +503,7 @@ internal class StakingViewModel @Inject constructor( ).fold( ifLeft = { error -> Timber.e(error.toString()) + analyticsEventHandler.send(StakingAnalyticsEvents.TransactionError(value.cryptoCurrencyName)) stateController.update( SetConfirmationStateAssentApprovalTransformer( appCurrencyProvider = Provider { appCurrency }, @@ -419,18 +511,100 @@ internal class StakingViewModel @Inject constructor( fee = TransactionFee.Single(fee), ), ) - // TODO staking error + stakingEventFactory.createSendTransactionErrorAlert(error) + stateController.update(SetConfirmationStateResetAssentTransformer) }, ifRight = { + stakingAnalyticSender.sendTransactionApprovalAnalytics(tokenCryptoCurrency) stateController.update(SetApprovalInProgressTransformer) - stateController.update(DismissBottomSheetStateTransformer()) - awaitForAllowance(confirmationState.pendingActions) + stateController.update(DismissBottomSheetStateTransformer) + awaitForAllowance(confirmationState.pendingAction) }, ) }.saveIn(approvalJobHolder) } - private fun awaitForAllowance(pendingActions: ImmutableList) { + private fun updateNotifications(feeError: GetFeeError? = null) { + viewModelScope.launch { + val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data + val feeState = confirmationState?.feeState as? FeeState.Content + val amountState = value.amountState as? AmountState.Data + + val amount = amountState?.amountTextField?.cryptoAmount?.value + val fee = feeState?.fee?.amount?.value + val currencyWarning = if (feeCryptoCurrencyStatus != null && fee != null) { + getBalanceNotEnoughForFeeWarningUseCase( + fee = fee, + userWalletId = userWalletId, + tokenStatus = cryptoCurrencyStatus, + coinStatus = feeCryptoCurrencyStatus ?: cryptoCurrencyStatus, + ).getOrNull() + } else { + null + } + val validation = amount?.let { + validateTransactionUseCase( + userWalletId = userWalletId, + amount = amount.convertToSdkAmount(cryptoCurrencyStatus.currency), + fee = feeState?.fee, + memo = null, + destination = "", + network = cryptoCurrencyStatus.currency.network, + ).leftOrNull() + } + + val currencyStatus = getCurrencyCheckUseCase( + userWalletId = userWalletId, + currencyStatus = cryptoCurrencyStatus, + amount = amount, + fee = fee, + ) + stateController.update( + AddStakingNotificationsTransformer( + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + appCurrencyProvider = Provider { appCurrency }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + currencyWarning = currencyWarning, + validatorError = validation, + currencyCheck = currencyStatus, + isSubtractAvailable = isAmountSubtractAvailable, + feeError = feeError, + yield = yield, + ), + ) + } + } + + override fun onAmountReduceByClick( + reduceAmountBy: BigDecimal, + reduceAmountByDiff: BigDecimal, + notification: Class, + ) { + AmountReduceByStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + value = AmountReduceByTransformer.ReduceByData( + reduceAmountBy = reduceAmountBy, + reduceAmountByDiff = reduceAmountByDiff, + ), + ) + onNotificationCancel(notification) + } + + override fun onAmountReduceToClick(reduceAmountTo: BigDecimal, notification: Class) { + stateController.update( + AmountReduceToStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + value = reduceAmountTo, + ), + ) + onNotificationCancel(notification) + } + + override fun onNotificationCancel(notification: Class) { + stateController.update(DismissStakingNotificationsStateTransformer(notification)) + } + + private fun awaitForAllowance(pendingAction: PendingAction?) { val approval = stakingApproval as? StakingApproval.Needed ?: return allowanceTaskScheduler.scheduleTask( scope = viewModelScope, @@ -449,7 +623,7 @@ internal class StakingViewModel @Inject constructor( val amount = (value.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value ?: error("No amount provided") if (allowance >= amount) { - getFee(pendingActions) + getFee(pendingAction = pendingAction, pendingActions = null) allowanceTaskScheduler.cancelTask() } }, @@ -459,10 +633,10 @@ internal class StakingViewModel @Inject constructor( } override fun onExploreClick() { + analyticsEventHandler.send(StakingAnalyticsEvents.ButtonExplore) val confirmationDataState = uiState.value.confirmationState as? StakingStates.ConfirmationState.Data val transactionDoneState = confirmationDataState?.transactionDoneState as? TransactionDoneState.Content val txUrl = transactionDoneState?.txUrl - if (txUrl != null) { innerRouter.openUrl(txUrl) } @@ -473,12 +647,51 @@ internal class StakingViewModel @Inject constructor( val transactionDoneState = confirmationDataState?.transactionDoneState as? TransactionDoneState.Content val txUrl = transactionDoneState?.txUrl + analyticsEventHandler.send(StakingAnalyticsEvents.ButtonShare) if (txUrl != null) { vibratorHapticManager.performOneTime(TangemHapticEffect.OneTime.Click) - clipboardManager.setText(text = txUrl) + stakingEventFactory.createShareDialog(txUrl = txUrl) } + } - // TODO staking [REDACTED_TASK_KEY] + override fun onFailedTxEmailClick(errorMessage: String) { + viewModelScope.launch { + val network = cryptoCurrencyStatus.currency.network + + val cardInfo = getCardInfoUseCase(userWallet.scanResponse).getOrElse { error("CardInfo must be not null") } + val amountState = uiState.value.amountState as? AmountState.Data + val confirmationState = uiState.value.confirmationState as? StakingStates.ConfirmationState.Data + val validatorState = confirmationState?.validatorState as? ValidatorState.Content + val feeState = confirmationState?.feeState as? FeeState.Content + + val validator = validatorState?.chosenValidator + val feeAmount = feeState?.fee?.amount + val amount = amountState?.amountTextField?.cryptoAmount + saveBlockchainErrorUseCase( + error = BlockchainErrorInfo( + errorMessage = errorMessage, + blockchainId = network.id.value, + derivationPath = network.derivationPath.value, + destinationAddress = validator?.address.orEmpty(), + tokenSymbol = (cryptoCurrencyStatus.currency as? CryptoCurrency.Token)?.symbol, + amount = amount?.run { value?.toPlainString() + currencySymbol }.orEmpty(), + fee = feeAmount?.run { value?.toPlainString() + currencySymbol }.orEmpty(), + ), + ) + + val email = FeedbackEmailType.StakingProblem( + cardInfo = cardInfo, + validatorName = validator?.name, + transactionTypes = transactionsInProgress.map { it.type.name }, + unsignedTransactions = transactionsInProgress.map { it.unsignedTransaction }, + ) + + feedbackManager.sendEmail(email) + } + } + + override fun openTokenDetails(cryptoCurrency: CryptoCurrency) { + innerRouter.openTokenDetails(userWalletId, cryptoCurrency) } fun setRouter(router: InnerStakingRouter, stateRouter: StakingStateRouter) { @@ -491,41 +704,63 @@ internal class StakingViewModel @Inject constructor( } private fun subscribeOnCurrencyStatusUpdates() { - viewModelScope.launch { - getUserWalletUseCase(userWalletId).fold( - ifRight = { wallet -> - userWallet = wallet - }, - ifLeft = { - // TODO staking error - }, - ) - getCryptoCurrencyStatusSyncUseCase(userWalletId, cryptoCurrencyId).fold( - ifRight = { - feeCryptoCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase(userWalletId, it).getOrNull() - cryptoCurrencyStatus = it + getUserWalletUseCase(userWalletId).fold( + ifRight = { wallet -> + userWallet = wallet + }, + ifLeft = { + Timber.e(it.toString()) + stakingEventFactory.createGenericErrorAlert(it.toString()) + stateController.update(SetConfirmationStateResetAssentTransformer) + }, + ) + getCurrencyStatusUpdatesUseCase(userWalletId, cryptoCurrencyId, false) + .conflate() + .distinctUntilChanged() + .filter { value.currentStep == StakingStep.InitialInfo } + .onEach { maybeStatus -> + maybeStatus.fold( + ifRight = { status -> + if (!isInitialInfoAnalyticSent) { + isInitialInfoAnalyticSent = true + val balances = status.value.yieldBalance as? YieldBalance.Data + analyticsEventHandler.send( + StakingAnalyticsEvents.StakingInfoScreenOpened( + validatorsCount = balances?.getValidatorsCount() ?: 0, + token = status.currency.symbol, + ), + ) + } - setupApprovalNeeded() + feeCryptoCurrencyStatus = + getFeePaidCryptoCurrencyStatusSyncUseCase(userWalletId, status).getOrNull() + cryptoCurrencyStatus = status + val isAnyTokenStaked = isAnyTokenStakedUseCase(userWalletId).getOrNull() ?: false - val networkId = cryptoCurrencyStatus.currency.network.id - val isStakeMoreAvailable = isStakeMoreAvailableUseCase(networkId) - stateController.update( - transformer = SetInitialDataStateTransformer( - clickIntents = this@StakingViewModel, - yield = yield, - isStakeMoreAvailable = isStakeMoreAvailable.getOrElse { false }, - isApprovalNeeded = stakingApproval is StakingApproval.Needed, - cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, - userWalletProvider = Provider { userWallet }, - appCurrencyProvider = Provider { appCurrency }, - ), - ) - }, - ifLeft = { - // TODO staking error - }, - ) - } + setupApprovalNeeded() + checkIfSubtractAvailable() + + stateController.update( + transformer = SetInitialDataStateTransformer( + clickIntents = this@StakingViewModel, + yield = yield, + isAnyTokenStaked = isAnyTokenStaked, + isApprovalNeeded = stakingApproval is StakingApproval.Needed, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + userWalletProvider = Provider { userWallet }, + appCurrencyProvider = Provider { appCurrency }, + ), + ) + }, + ifLeft = { error -> + Timber.e(error.toString()) + stakingEventFactory.createGenericErrorAlert(error.toString()) + stateController.update(SetConfirmationStateResetAssentTransformer) + }, + ) + } + .flowOn(dispatchers.main) + .launchIn(viewModelScope) } private fun subscribeOnBalanceHiding() { @@ -550,85 +785,23 @@ internal class StakingViewModel @Inject constructor( .launchIn(viewModelScope) } - private suspend fun sendStakingTransaction( - transactionId: String, - gasEstimate: StakingGasEstimate, - txData: TransactionData, - pendingActionList: ImmutableList, - ) { - sendTransactionUseCase( - txData = txData, - userWallet = userWallet, - network = cryptoCurrencyStatus.currency.network, - ).fold( - ifLeft = { error -> - Timber.e(error.toString()) - stateController.update( - SetConfirmationStateAssentTransformer( - appCurrencyProvider = Provider { appCurrency }, - feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, - stakingGasEstimate = gasEstimate, - pendingActionList = pendingActionList, - ), - ) - // todo add error dialog - }, - ifRight = { txHash -> - submitHash(transactionId, txHash) - updateStakeBalance() - val txUrl = getExplorerTransactionUrlUseCase( - txHash = txHash, - networkId = cryptoCurrencyStatus.currency.network.id, - ).getOrElse { "" } - - stateController.update( - SetConfirmationStateCompletedTransformer( - appCurrencyProvider = Provider { appCurrency }, - feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, - stakingGasEstimate = gasEstimate, - txUrl = txUrl, - ), - ) - }, - ) - } - - private suspend fun submitHash(transactionId: String, transactionHash: String) { - submitHashUseCase.submitHash( - transactionId = transactionId, - transactionHash = transactionHash, - ) - .onLeft { - saveUnsubmittedHashUseCase.invoke( - transactionId = transactionId, - transactionHash = transactionHash, - ) - }.onRight { - Timber.d("Successful hash submission") - } - } - - private fun updateStakeBalance() { - viewModelScope.launch { - stakingYieldBalanceUseCase( - userWalletId = userWalletId, - address = CryptoCurrencyAddress( - cryptoCurrencyStatus.currency, - cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value.orEmpty(), - ), - refresh = true, - ) - } - } - private fun isAssentState(): Boolean { return value.currentStep == StakingStep.Confirmation && (value.confirmationState as? StakingStates.ConfirmationState.Data)?.innerState == InnerConfirmationStakingState.ASSENT } + private fun isInitState(): Boolean { + return value.currentStep == StakingStep.InitialInfo + } + + private suspend fun checkIfSubtractAvailable() { + isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(userWalletId, cryptoCurrencyStatus.currency) + .getOrElse { false } + } + private companion object { - const val WHAT_IS_STAKING_ARTICLE_URL = "TODO staking" + const val WHAT_IS_STAKING_ARTICLE_URL = "https://tangem.com/en/blog/post/how-to-stake-cryptocurrency/" const val ALLOWANCE_UPDATE_DELAY = 10_000L } } \ No newline at end of file diff --git a/features/swap/api/src/main/java/com/tangem/feature/swap/api/SwapFeatureToggleManager.kt b/features/swap/api/src/main/java/com/tangem/feature/swap/api/SwapFeatureToggleManager.kt deleted file mode 100644 index c9b4962de4..0000000000 --- a/features/swap/api/src/main/java/com/tangem/feature/swap/api/SwapFeatureToggleManager.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tangem.feature.swap.api - -/** Feature toggles manager of "swap" feature */ -interface SwapFeatureToggleManager { - - val isOptimismSwapEnabled: Boolean -} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index 31b1659a73..f3a0d6cbe4 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -19,7 +19,6 @@ import com.tangem.datasource.api.express.models.response.ExchangeDataResponseWit import com.tangem.datasource.api.express.models.response.SwapPair import com.tangem.datasource.api.express.models.response.SwapPairsWithProviders import com.tangem.datasource.api.express.models.response.TxDetails -import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.crypto.DataSignatureVerifier import com.tangem.domain.common.util.derivationStyleProvider import com.tangem.domain.tokens.model.CryptoCurrency @@ -44,7 +43,6 @@ import com.tangem.datasource.api.express.models.request.LeastTokenInfo as Networ @Suppress("LongParameterList", "LargeClass") internal class DefaultSwapRepository @Inject constructor( - private val tangemTechApi: TangemTechApi, private val tangemExpressApi: TangemExpressApi, private val coroutineDispatcher: CoroutineDispatcherProvider, private val walletManagersFacade: WalletManagersFacade, @@ -188,29 +186,6 @@ internal class DefaultSwapRepository @Inject constructor( } } - override suspend fun getRates(currencyId: String, tokenIds: List): Map { - // workaround cause backend do not return arbitrum and optimism rates - val addedTokens = if (tokenIds.contains(OPTIMISM_ID) || tokenIds.contains(ARBITRUM_ID)) { - tokenIds.toMutableList().apply { - add(ETHEREUM_ID) - } - } else { - tokenIds - } - return withContext(coroutineDispatcher.io) { - val rates = tangemTechApi.getRates(currencyId.lowercase(), addedTokens.joinToString(",")).rates - val ethRate = rates[ETHEREUM_ID] - if (tokenIds.contains(OPTIMISM_ID) || tokenIds.contains(ARBITRUM_ID)) { - rates.toMutableMap().apply { - put(OPTIMISM_ID, ethRate ?: 0.0) - put(ARBITRUM_ID, ethRate ?: 0.0) - } - } else { - rates - } - } - } - override suspend fun findBestQuote( fromContractAddress: String, fromNetwork: String, @@ -432,11 +407,4 @@ internal class DefaultSwapRepository @Inject constructor( DataError.UnknownError } } - - companion object { - // TODO("get this ids from blockchain enum later") - private const val OPTIMISM_ID = "optimistic-ethereum" - private const val ARBITRUM_ID = "arbitrum-one" - private const val ETHEREUM_ID = "ethereum" - } } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt index ad6e5fb457..1fde5f7040 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt @@ -1,7 +1,5 @@ package com.tangem.feature.swap.converters -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.datasource.api.express.models.request.LeastTokenInfo import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.utils.converter.Converter @@ -11,7 +9,7 @@ class LeastTokenInfoConverter : Converter { override fun convert(value: CryptoCurrency): LeastTokenInfo { return LeastTokenInfo( contractAddress = (value as? CryptoCurrency.Token)?.contractAddress ?: "0", - network = Blockchain.fromId(value.id.rawNetworkId).toNetworkId(), + network = value.network.backendId, ) } } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt index 5cc2b81a41..fde540c8ab 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt @@ -3,7 +3,6 @@ package com.tangem.feature.swap.di import com.squareup.moshi.Moshi import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.express.models.response.ExpressErrorResponse -import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.crypto.DataSignatureVerifier import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.preferences.AppPreferencesStore @@ -28,7 +27,6 @@ internal class SwapDataModule { @Provides @Singleton internal fun provideSwapRepository( - tangemTechApi: TangemTechApi, tangemExpressApi: TangemExpressApi, coroutineDispatcher: CoroutineDispatcherProvider, dataSignature: DataSignatureVerifier, @@ -38,7 +36,6 @@ internal class SwapDataModule { @NetworkMoshi moshi: Moshi, ): SwapRepository { return DefaultSwapRepository( - tangemTechApi = tangemTechApi, tangemExpressApi = tangemExpressApi, coroutineDispatcher = coroutineDispatcher, walletManagersFacade = walletManagerFacade, diff --git a/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt b/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt index d5469ce433..99bc772d79 100644 --- a/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt +++ b/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt @@ -14,8 +14,6 @@ interface SwapRepository { /** Express getPairs request variant without providers request */ suspend fun getPairsOnly(initialCurrency: LeastTokenInfo, currencyList: List): PairsWithProviders - suspend fun getRates(currencyId: String, tokenIds: List): Map - suspend fun getExchangeStatus(txId: String): Either @Suppress("LongParameterList") diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index dafaff9a51..8c6f6a8de7 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -2,7 +2,7 @@ package com.tangem.feature.swap.domain import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount import com.tangem.feature.swap.domain.models.domain.PermissionOptions @@ -79,12 +79,14 @@ interface SwapInteractor { */ fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount - fun getSelectedWallet(): UserWallet? - suspend fun selectInitialCurrencyToSwap( initialCryptoCurrency: CryptoCurrency, state: TokensDataStateExpress, ): CryptoCurrencyStatus? fun getNativeToken(networkId: String): CryptoCurrency + + interface Factory { + fun create(selectedWalletId: UserWalletId): SwapInteractor + } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 088ef3b856..b892338148 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -14,7 +14,6 @@ import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.appcurrency.repository.AppCurrencyRepository -import com.tangem.domain.demo.DemoConfig import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase import com.tangem.domain.tokens.model.* @@ -29,7 +28,7 @@ import com.tangem.domain.transaction.usecase.* import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.converters.SwapCurrencyConverter import com.tangem.feature.swap.domain.models.DataError @@ -43,21 +42,22 @@ import com.tangem.lib.crypto.UserWalletManager import com.tangem.lib.crypto.models.ProxyAmount import com.tangem.lib.crypto.models.ProxyFee import com.tangem.lib.crypto.models.ProxyFees +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.firstOrNull import timber.log.Timber import java.math.BigDecimal import java.math.BigInteger import java.math.RoundingMode -import javax.inject.Inject @Suppress("LargeClass", "LongParameterList") -internal class SwapInteractorImpl @Inject constructor( +internal class SwapInteractorImpl @AssistedInject constructor( private val transactionManager: TransactionManager, private val userWalletManager: UserWalletManager, private val repository: SwapRepository, private val allowPermissionsHandler: AllowPermissionsHandler, - private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val getMultiCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase, private val sendTransactionUseCase: SendTransactionUseCase, private val createTransactionUseCase: CreateTransactionUseCase, @@ -69,9 +69,10 @@ internal class SwapInteractorImpl @Inject constructor( private val appCurrencyRepository: AppCurrencyRepository, private val currenciesRepository: CurrenciesRepository, private val initialToCurrencyResolver: InitialToCurrencyResolver, - private val demoConfig: DemoConfig, private val validateTransactionUseCase: ValidateTransactionUseCase, private val estimateFeeUseCase: EstimateFeeUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + @Assisted private val userWalletId: UserWalletId, ) : SwapInteractor { private val getSelectedAppCurrencyUseCase by lazy(LazyThreadSafetyMode.NONE) { @@ -82,15 +83,13 @@ internal class SwapInteractorImpl @Inject constructor( private val amountFormatter = AmountFormatter() private val hundredPercent = BigInteger("100") + private val userWallet + get() = getUserWalletUseCase(userWalletId).getOrElse { + error("Failed to get user wallet") + } + override suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { - val selectedWallet = getSelectedWalletSyncUseCase().fold( - ifLeft = { null }, - ifRight = { it }, - ) - - requireNotNull(selectedWallet) { "No selected wallet" } - - val walletCurrencyStatuses = getMultiCryptoCurrencyStatusUseCase(selectedWallet.walletId) + val walletCurrencyStatuses = getMultiCryptoCurrencyStatusUseCase(userWalletId) .getOrElse { emptyList() } val walletCurrencyStatusesExceptInitial = walletCurrencyStatuses @@ -133,10 +132,6 @@ internal class SwapInteractorImpl @Inject constructor( ) } - override fun getSelectedWallet(): UserWallet? { - return getSelectedWalletSyncUseCase().getOrNull() - } - private fun getToCurrenciesGroup( currency: CryptoCurrency, leastPairs: List, @@ -224,7 +219,7 @@ internal class SwapInteractorImpl @Inject constructor( memo = null, destination = getTokenAddress(permissionOptions.fromToken), network = permissionOptions.fromToken.network, - userWalletId = requireNotNull(getSelectedWallet()).walletId, + userWalletId = userWalletId, txExtras = createDexTxExtras( dataToSign, permissionOptions.fromToken.network, @@ -237,7 +232,7 @@ internal class SwapInteractorImpl @Inject constructor( val result = sendTransactionUseCase( txData = approveTransaction, - userWallet = requireNotNull(getSelectedWallet()), + userWallet = userWallet, network = permissionOptions.fromToken.network, ) return result.fold( @@ -403,7 +398,6 @@ internal class SwapInteractorImpl @Inject constructor( minAdaValue: BigDecimal?, ): List { val fromToken = fromTokenStatus.currency - val userWalletId = getSelectedWallet()?.walletId ?: return emptyList() val warnings = mutableListOf() manageExistentialDepositWarning(warnings, userWalletId, amount, fromToken, feeState) manageDustWarning(warnings, feeState, userWalletId, fromTokenStatus, amount) @@ -597,7 +591,8 @@ internal class SwapInteractorImpl @Inject constructor( """.trimIndent(), ) - val cardId = getSelectedWallet()?.scanResponse?.card?.cardId ?: return SwapTransactionState.UnknownError + val userWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return SwapTransactionState.UnknownError + val cardId = userWallet.scanResponse.card.cardId if (isDemoCardUseCase(cardId)) return SwapTransactionState.DemoMode return when (swapProvider.type) { @@ -615,7 +610,6 @@ internal class SwapInteractorImpl @Inject constructor( amount = amountToSwapWithFee, txFee = fee, swapProvider = swapProvider, - userWalletId = requireNotNull(getSelectedWallet()).walletId, ) } ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { @@ -627,7 +621,6 @@ internal class SwapInteractorImpl @Inject constructor( currencyToGetStatus = currencyToGet, fee = fee, amountToSwap = amountToSwap, - userWalletId = requireNotNull(getSelectedWallet()).walletId, ) } } @@ -679,7 +672,6 @@ internal class SwapInteractorImpl @Inject constructor( currencyToGetStatus: CryptoCurrencyStatus, amountToSwap: String, fee: TxFee, - userWalletId: UserWalletId, ): SwapTransactionState { val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) @@ -706,7 +698,7 @@ internal class SwapInteractorImpl @Inject constructor( val result = sendTransactionUseCase( txData = txData, - userWallet = requireNotNull(getSelectedWallet()), + userWallet = getUserWalletUseCase(userWalletId).getOrElse { return SwapTransactionState.UnknownError }, network = currencyToSendStatus.currency.network, ) return result.fold( @@ -766,7 +758,6 @@ internal class SwapInteractorImpl @Inject constructor( amount: SwapAmount, txFee: TxFee, swapProvider: SwapProvider, - userWalletId: UserWalletId, ): SwapTransactionState { val exchangeData = repository.getExchangeData( fromContractAddress = currencyToSend.currency.getContractAddress(), @@ -787,8 +778,9 @@ internal class SwapInteractorImpl @Inject constructor( val exchangeDataCex = exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.UnknownError - val cardId = getSelectedWallet()?.scanResponse?.card?.cardId ?: return SwapTransactionState.UnknownError - if (demoConfig.isDemoCardId(cardId)) return SwapTransactionState.UnknownError + val cardId = userWallet.scanResponse.card.cardId + + if (isDemoCardUseCase(cardId)) return SwapTransactionState.UnknownError val txData = createTransactionUseCase( amount = amount.value.convertToSdkAmount(currencyToSend.currency), @@ -811,7 +803,7 @@ internal class SwapInteractorImpl @Inject constructor( val result = sendTransactionUseCase( txData = txData, - userWallet = requireNotNull(getSelectedWallet()), + userWallet = userWallet, network = currencyToSend.currency.network, ) @@ -887,7 +879,7 @@ internal class SwapInteractorImpl @Inject constructor( return when { blockchain.isEvm() -> { val feeAmountWithDecimals = feeAmountValue.movePointRight(fee.decimals) - Fee.Ethereum( + Fee.Ethereum.Legacy( amount = feeAmount, gasLimit = fee.gasLimit.toBigInteger(), gasPrice = (feeAmountWithDecimals / fee.gasLimit.toBigDecimal()).toBigInteger(), @@ -941,9 +933,8 @@ internal class SwapInteractorImpl @Inject constructor( txExternalUrl: String? = null, txExternalId: String? = null, ) { - val selectedWallet = getSelectedWallet() ?: return swapTransactionRepository.storeTransaction( - userWalletId = selectedWallet.walletId, + userWalletId = userWalletId, fromCryptoCurrency = currencyToSend.currency, toCryptoCurrency = currencyToGet.currency, transaction = SavedSwapTransactionModel( @@ -994,23 +985,16 @@ internal class SwapInteractorImpl @Inject constructor( spenderAddress: String, ): Boolean { if (fromToken is CryptoCurrency.Coin) return true - return getSelectedWalletSyncUseCase().fold( - ifRight = { userWallet -> - val allowance = repository.getAllowance( - userWalletId = userWallet.walletId, - networkId = networkId, - derivationPath = fromToken.network.derivationPath.value, - tokenDecimalCount = fromToken.decimals, - tokenAddress = getTokenAddress(fromToken), - spenderAddress = spenderAddress, - ) - allowance >= amount.value - }, - ifLeft = { - Timber.e("Swap Error on isAllowedToSpend") - false - }, + + val allowance = repository.getAllowance( + userWalletId = userWallet.walletId, + networkId = networkId, + derivationPath = fromToken.network.derivationPath.value, + tokenDecimalCount = fromToken.decimals, + tokenAddress = getTokenAddress(fromToken), + spenderAddress = spenderAddress, ) + return allowance >= amount.value } private suspend fun createEmptyAmountState(): SwapState { @@ -1040,13 +1024,11 @@ internal class SwapInteractorImpl @Inject constructor( val fromToken = fromTokenStatus.currency val toToken = toTokenStatus.currency return coroutineScope { - val txFeeResult = getSelectedWalletSyncUseCase().getOrNull()?.let { userWallet -> - getUnhandledFee( - amount = amount.value, - userWallet = userWallet, - cryptoCurrency = fromToken, - ) - } + val txFeeResult = getUnhandledFee( + amount = amount.value, + userWallet = userWallet, + cryptoCurrency = fromToken, + ) val txFee = if (provider.type == ExchangeProviderType.CEX) { getFeeForCex(txFeeResult, fromTokenStatus) @@ -1211,7 +1193,6 @@ internal class SwapInteractorImpl @Inject constructor( is TxFeeState.SingleFeeState -> txFee.fee.feeIncludeOtherNativeFee } val feePaidCurrency = getFeePaidCurrency( - userWalletId = requireNotNull(getSelectedWallet()).walletId, currency = fromToken, ) @@ -1271,7 +1252,6 @@ internal class SwapInteractorImpl @Inject constructor( private suspend fun getFormattedFiatFees(fromToken: CryptoCurrency, vararg fees: BigDecimal): List { val appCurrency = getSelectedAppCurrencyUseCase.unwrap() val feePaidCurrency = getFeePaidCurrency( - userWalletId = requireNotNull(getSelectedWallet()).walletId, currency = fromToken, ) val feeCurrencyId: CryptoCurrency.ID = when (feePaidCurrency) { @@ -1323,13 +1303,12 @@ internal class SwapInteractorImpl @Inject constructor( val otherNativeFee = transaction.otherNativeFeeWei ?.movePointLeft(nativeCoinDecimals) ?: BigDecimal.ZERO - val userWallet = getSelectedWallet() val txFeeState = when ( val feeData = getFeeDataForDexSwap( networkId = networkId, transaction = transaction, fromToken = fromToken.currency, - cardId = userWallet?.scanResponse?.card?.cardId, + cardId = userWallet.scanResponse.card.cardId, ) ) { is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(fromToken.currency, otherNativeFee) @@ -1524,9 +1503,8 @@ internal class SwapInteractorImpl @Inject constructor( swapAmount = swapAmount, spenderAddress = requireNotNull(spenderAddress) { "Spender address is null" }, ) - val userWallet = getSelectedWallet() - val cardId = userWallet?.scanResponse?.card?.cardId - val feeData = if (cardId != null && isDemoCardUseCase(cardId)) { + val cardId = userWallet.scanResponse.card.cardId + val feeData = if (isDemoCardUseCase(cardId)) { getDemoFees(fromTokenStatus.currency) } else { try { @@ -1827,10 +1805,10 @@ internal class SwapInteractorImpl @Inject constructor( val increasedAmount = this.amount.copy( value = increasedGasLimit.toBigDecimal().multiply(increasedGasPrice).movePointLeft(this.amount.decimals), ) - return this.copy( - amount = increasedAmount, - gasLimit = increasedGasLimit, - ) + return when (this) { + is Fee.Ethereum.EIP1559 -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) + is Fee.Ethereum.Legacy -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) + } } private fun hasOutgoingTransaction(cryptoCurrencyStatuses: CryptoCurrencyStatus): Boolean { @@ -1865,7 +1843,6 @@ internal class SwapInteractorImpl @Inject constructor( ): Boolean { val tokenBalance = getTokenBalance(fromToken).value val feePaidCurrency = getFeePaidCurrency( - userWalletId = requireNotNull(getSelectedWallet()).walletId, currency = fromToken.currency, ) return when (feePaidCurrency) { @@ -1880,7 +1857,7 @@ internal class SwapInteractorImpl @Inject constructor( } } - private suspend fun getFeePaidCurrency(userWalletId: UserWalletId, currency: CryptoCurrency): FeePaidCurrency { + private suspend fun getFeePaidCurrency(currency: CryptoCurrency): FeePaidCurrency { return currenciesRepository.getFeePaidCurrency( userWalletId = userWalletId, currency = currency, @@ -1913,13 +1890,12 @@ internal class SwapInteractorImpl @Inject constructor( networkId: String, fromTokenStatus: CryptoCurrencyStatus, ): SwapFeeState { - val userWalletId = requireNotNull(getSelectedWallet()).walletId if (fee == null) { return SwapFeeState.NotEnough() } val percentsToFeeIncrease = BigDecimal.ONE - return when (val feePaidCurrency = getFeePaidCurrency(userWalletId, fromTokenStatus.currency)) { + return when (val feePaidCurrency = getFeePaidCurrency(fromTokenStatus.currency)) { FeePaidCurrency.Coin -> { val nativeTokenBalance = userWalletManager.getNativeTokenBalance( networkId, @@ -2013,21 +1989,13 @@ internal class SwapInteractorImpl @Inject constructor( swapAmount: SwapAmount? = null, spenderAddress: String, ): String { - return getSelectedWalletSyncUseCase().fold( - ifRight = { userWallet -> - repository.getApproveData( - userWalletId = userWallet.walletId, - networkId = networkId, - derivationPath = derivationPath, - currency = fromToken, - amount = swapAmount?.value, - spenderAddress = spenderAddress, - ) - }, - ifLeft = { - Timber.e("Swap Error on getApproveData") - error("Swap Error on getApproveData") - }, + return repository.getApproveData( + userWalletId = userWalletId, + networkId = networkId, + derivationPath = derivationPath, + currency = fromToken, + amount = swapAmount?.value, + spenderAddress = spenderAddress, ) } @@ -2078,4 +2046,9 @@ internal class SwapInteractorImpl @Inject constructor( private val normalDemoFee = "0.0002".toBigDecimal() private val priorityDemoFee = "0.0003".toBigDecimal() } + + @AssistedFactory + interface Factory : SwapInteractor.Factory { + override fun create(selectedWalletId: UserWalletId): SwapInteractorImpl + } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt index 3f20bc09bc..9f95b3e0c6 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -1,79 +1,34 @@ package com.tangem.feature.swap.domain.di -import com.tangem.domain.appcurrency.repository.AppCurrencyRepository -import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.domain.demo.DemoConfig -import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.staking.repositories.StakingRepository -import com.tangem.domain.tokens.GetCardTokensListUseCase import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.NetworksRepository import com.tangem.domain.tokens.repository.QuotesRepository -import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.usecase.* -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.feature.swap.domain.* -import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.lib.crypto.TransactionManager -import com.tangem.lib.crypto.UserWalletManager import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import javax.inject.Qualifier import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -class SwapDomainModule { +internal class SwapDomainModule { + + @Provides + fun provideAllowPermissionsHandler(): AllowPermissionsHandler { + return AllowPermissionsHandlerImpl() + } @Provides @Singleton - fun provideSwapInteractor( - swapRepository: SwapRepository, - userWalletManager: UserWalletManager, - transactionManager: TransactionManager, - @SwapScope getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, - getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase, - @SwapScope sendTransactionUseCase: SendTransactionUseCase, - @SwapScope createTransactionUseCase: CreateTransactionUseCase, - createTransactionDataExtrasUseCase: CreateTransactionDataExtrasUseCase, - isDemoCardUseCase: IsDemoCardUseCase, - quotesRepository: QuotesRepository, - swapTransactionRepository: SwapTransactionRepository, - appCurrencyRepository: AppCurrencyRepository, - currencyChecksRepository: CurrencyChecksRepository, - initialToCurrencyResolver: InitialToCurrencyResolver, - currenciesRepository: CurrenciesRepository, - validateTransactionUseCase: ValidateTransactionUseCase, - estimateFeeUseCase: EstimateFeeUseCase, - ): SwapInteractor { - return SwapInteractorImpl( - transactionManager = transactionManager, - userWalletManager = userWalletManager, - repository = swapRepository, - allowPermissionsHandler = AllowPermissionsHandlerImpl(), - getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, - getMultiCryptoCurrencyStatusUseCase = getCryptoCurrencyStatusUseCase, - sendTransactionUseCase = sendTransactionUseCase, - createTransactionUseCase = createTransactionUseCase, - createTransactionExtrasUseCase = createTransactionDataExtrasUseCase, - isDemoCardUseCase = isDemoCardUseCase, - quotesRepository = quotesRepository, - swapTransactionRepository = swapTransactionRepository, - appCurrencyRepository = appCurrencyRepository, - currencyChecksRepository = currencyChecksRepository, - currenciesRepository = currenciesRepository, - initialToCurrencyResolver = initialToCurrencyResolver, - demoConfig = DemoConfig(), - validateTransactionUseCase = validateTransactionUseCase, - estimateFeeUseCase = estimateFeeUseCase, - ) + fun provideSwapInteractorFactory(factory: SwapInteractorImpl.Factory): SwapInteractor.Factory { + return factory } @Provides @@ -84,13 +39,6 @@ class SwapDomainModule { ) } - @SwapScope - @Provides - @Singleton - fun providesGetSelectedWalletUseCase(userWalletsListManager: UserWalletsListManager): GetSelectedWalletSyncUseCase { - return GetSelectedWalletSyncUseCase(userWalletsListManager = userWalletsListManager) - } - @Provides @Singleton fun providesGetCryptoCurrencyStatusUseCase( @@ -109,58 +57,10 @@ class SwapDomainModule { ) } - @SwapScope - @Provides - @Singleton - fun providesGetCardTokensListUseCase( - currenciesRepository: CurrenciesRepository, - quotesRepository: QuotesRepository, - networksRepository: NetworksRepository, - stakingRepository: StakingRepository, - ): GetCardTokensListUseCase { - return GetCardTokensListUseCase( - currenciesRepository = currenciesRepository, - quotesRepository = quotesRepository, - networksRepository = networksRepository, - stakingRepository = stakingRepository, - ) - } - - @SwapScope - @Provides - fun provideDemoCardUseCase(): IsDemoCardUseCase { - return IsDemoCardUseCase(config = DemoConfig()) - } - - @SwapScope - @Provides - @Singleton - fun provideCreateTransactionUseCase(transactionRepository: TransactionRepository): CreateTransactionUseCase { - return CreateTransactionUseCase( - transactionRepository = transactionRepository, - ) - } - - @SwapScope - @Provides - @Singleton - fun provideSendTransactionUseCase( - cardSdkConfigRepository: CardSdkConfigRepository, - transactionRepository: TransactionRepository, - walletManagersFacade: WalletManagersFacade, - ): SendTransactionUseCase { - return SendTransactionUseCase( - demoConfig = DemoConfig(), - cardSdkConfigRepository = cardSdkConfigRepository, - transactionRepository = transactionRepository, - walletManagersFacade = walletManagersFacade, - ) - } - @Provides @Singleton fun provideInitialToCurrencyResolver( - @SwapScope getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, swapTransactionRepository: SwapTransactionRepository, ): InitialToCurrencyResolver { return DefaultInitialToCurrencyResolver( @@ -168,8 +68,4 @@ class SwapDomainModule { swapTransactionRepository = swapTransactionRepository, ) } -} - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class SwapScope \ No newline at end of file +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/di/SwapFeatureTogglesManagerModule.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/di/SwapFeatureTogglesManagerModule.kt deleted file mode 100644 index 15ef8179e1..0000000000 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/di/SwapFeatureTogglesManagerModule.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.feature.swap.di - -import com.tangem.core.featuretoggle.manager.FeatureTogglesManager -import com.tangem.feature.swap.api.SwapFeatureToggleManager -import com.tangem.feature.swap.toggles.DefaultFeatureTogglesManager -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityRetainedComponent -import dagger.hilt.android.scopes.ActivityRetainedScoped - -@Module -@InstallIn(ActivityRetainedComponent::class) -internal object SwapFeatureTogglesManagerModule { - - @Provides - @ActivityRetainedScoped - fun provideSwapFeatureTogglesManager(featureTogglesManager: FeatureTogglesManager): SwapFeatureToggleManager { - return DefaultFeatureTogglesManager(featureTogglesManager) - } -} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/toggles/DefaultFeatureTogglesManager.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/toggles/DefaultFeatureTogglesManager.kt deleted file mode 100644 index 2448fefafd..0000000000 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/toggles/DefaultFeatureTogglesManager.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.feature.swap.toggles - -import com.tangem.core.featuretoggle.manager.FeatureTogglesManager - -/** Feature toggles manager implementation of "swap" feature */ -class DefaultFeatureTogglesManager( - private val featureTogglesManager: FeatureTogglesManager, -) : InnerSwapFeatureTogglesManager { - - override val isOptimismSwapEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(name = "OPTIMISM_SWAP_FEATURE_ENABLED") -} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/toggles/InnerSwapFeatureTogglesManager.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/toggles/InnerSwapFeatureTogglesManager.kt deleted file mode 100644 index f1035d737a..0000000000 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/toggles/InnerSwapFeatureTogglesManager.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.feature.swap.toggles - -import com.tangem.feature.swap.api.SwapFeatureToggleManager - -/** Feature toggles manager of "swap" feature for internal logic */ -internal interface InnerSwapFeatureTogglesManager : SwapFeatureToggleManager \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index c013b188e0..34bf5fb2ec 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -323,14 +323,14 @@ internal class StateBuilder( return TosState( tosLink = swapProvider.termsOfUse?.let { LegalState( - title = resourceReference(R.string.express_terms_of_use), + title = resourceReference(R.string.common_terms_of_use), link = it, onClick = actions.onTosClick, ) }, policyLink = swapProvider.privacyPolicy?.let { LegalState( - title = resourceReference(R.string.express_privacy_policy), + title = resourceReference(R.string.common_privacy_policy), link = it, onClick = actions.onPolicyClick, ) @@ -475,10 +475,7 @@ internal class StateBuilder( ), iconResId = R.drawable.ic_alert_circle_24, buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig( - text = resourceReference( - R.string.send_notification_leave_button, - wrappedList(deposit), - ), + text = resourceReference(R.string.common_ok), onClick = { actions.onLeaveExistentialDeposit( SwapAmount( @@ -1261,7 +1258,6 @@ internal class StateBuilder( selectedProviderId: String, pricesLowerBest: Map, providersStates: Map, - unavailableProviders: List, onDismiss: () -> Unit, ): SwapStateHolder { val availableProvidersStates = providersStates.entries @@ -1269,15 +1265,10 @@ internal class StateBuilder( it.convertToProviderBottomSheetState(pricesLowerBest, actions.onProviderSelect) } .sortedWith(ProviderPercentDiffComparator) - val unavailableProviderStates = unavailableProviders.map { - it.convertToUnavailableProviderState( - alertText = resourceReference(R.string.express_provider_not_available), - selectionType = ProviderState.SelectionType.NONE, - ) - } + .toImmutableList() val config = ChooseProviderBottomSheetConfig( selectedProviderId = selectedProviderId, - providers = (availableProvidersStates + unavailableProviderStates).toImmutableList(), + providers = availableProvidersStates, ) return uiState.copy( bottomSheetConfig = TangemBottomSheetConfig( @@ -1631,22 +1622,6 @@ internal class StateBuilder( ) } - private fun SwapProvider.convertToUnavailableProviderState( - alertText: TextReference, - selectionType: ProviderState.SelectionType, - onProviderClick: ((String) -> Unit)? = null, - ): ProviderState { - return ProviderState.Unavailable( - id = this.providerId, - name = this.name, - iconUrl = this.imageLarge, - type = this.type.providerName, - selectionType = selectionType, - alertText = alertText, - onProviderClick = onProviderClick, - ) - } - private fun SwapProvider.convertToAvailableFromProviderState( swapProvider: SwapProvider, alertText: TextReference, diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index f3004d1c73..f64eb3344e 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -28,6 +28,7 @@ import com.tangem.core.ui.components.* import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.getActiveIconResByCoinId +import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme @@ -38,7 +39,6 @@ import com.tangem.feature.swap.models.* import com.tangem.feature.swap.models.states.FeeItemState import com.tangem.feature.swap.models.states.ProviderState import com.tangem.feature.swap.presentation.R -import com.tangem.utils.StringsSigns.STARS @Suppress("LongMethod") @Composable @@ -110,7 +110,7 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi BasicDialog( title = state.alert.title?.resolveReference(), message = message, - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = stringResource(id = R.string.common_ok), onClick = state.alert.onClick, ), @@ -176,11 +176,7 @@ private fun TransactionCardData( is SwapCardState.SwapCardData -> { TransactionCard( type = swapCardState.type, - balance = if (swapCardState.isBalanceHidden) { - STARS - } else { - swapCardState.balance - }, + balance = swapCardState.balance.orMaskWithStars(swapCardState.isBalanceHidden), textFieldValue = swapCardState.amountTextFieldValue, amountEquivalent = swapCardState.amountEquivalent, tokenIconUrl = swapCardState.tokenIconUrl ?: "", diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index 5066dc9be6..a0d643fffd 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -24,6 +24,7 @@ import com.tangem.core.ui.components.appbar.ExpandableSearchView import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme @@ -32,7 +33,6 @@ import com.tangem.feature.swap.models.SwapSelectTokenStateHolder import com.tangem.feature.swap.models.TokenBalanceData import com.tangem.feature.swap.models.TokenToSelectState import com.tangem.feature.swap.presentation.R -import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -251,13 +251,10 @@ private fun TokenItem( modifier = Modifier.padding(start = TangemTheme.dimens.spacing8), ) { Text( - text = if (token.addedTokenBalanceData.isBalanceHidden && - !token.addedTokenBalanceData.amountEquivalent.isNullOrEmpty() - ) { - StringsSigns.STARS - } else { - token.addedTokenBalanceData.amountEquivalent.orEmpty() - }, + text = token.addedTokenBalanceData.amountEquivalent.orEmpty().orMaskWithStars( + maskWithStars = token.addedTokenBalanceData.isBalanceHidden && + !token.addedTokenBalanceData.amountEquivalent.isNullOrEmpty(), + ), style = TangemTheme.typography.subtitle1, color = if (token.available) { TangemTheme.colors.text.primary1 @@ -267,13 +264,10 @@ private fun TokenItem( ) SpacerW2() Text( - text = if (token.addedTokenBalanceData.isBalanceHidden && - !token.addedTokenBalanceData.amount.isNullOrEmpty() - ) { - StringsSigns.STARS - } else { - token.addedTokenBalanceData.amount.orEmpty() - }, + text = token.addedTokenBalanceData.amount.orEmpty().orMaskWithStars( + maskWithStars = token.addedTokenBalanceData.isBalanceHidden && + !token.addedTokenBalanceData.amount.isNullOrEmpty(), + ), style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, ) diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt index c9e3891056..b5c056c643 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt @@ -18,8 +18,12 @@ import com.tangem.core.ui.components.inputrow.InputRowDefault import com.tangem.core.ui.components.inputrow.InputRowImage import com.tangem.core.ui.components.transactions.TransactionDoneTitle import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.core.ui.utils.toTimeFormat import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.models.SwapSuccessStateHolder import com.tangem.feature.swap.presentation.R @@ -60,7 +64,16 @@ private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: Pad .background(TangemTheme.colors.background.secondary) .padding(horizontal = TangemTheme.dimens.spacing16), ) { - TransactionDoneTitle(titleRes = R.string.swapping_success_view_title, date = state.timestamp) + TransactionDoneTitle( + title = resourceReference(R.string.common_in_progress), + subtitle = resourceReference( + R.string.send_date_format, + wrappedList( + state.timestamp.toTimeFormat(DateTimeFormatters.dateFormatter), + state.timestamp.toTimeFormat(), + ), + ), + ) SpacerH16() InputRowImage( title = TextReference.Res(R.string.swapping_from_title), diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index 9baf09e90a..e6866c7ba9 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -14,7 +14,7 @@ import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ripple import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -103,7 +103,7 @@ fun TransactionCard( .height(TangemTheme.dimens.size116) .width(TangemTheme.dimens.size102) .clickable( - indication = rememberRipple(bounded = false), + indication = ripple(bounded = false), interactionSource = remember { MutableInteractionSource() }, ) { onChangeTokenClick() }, ) @@ -164,7 +164,7 @@ fun TransactionCardEmpty( .height(TangemTheme.dimens.size116) .width(TangemTheme.dimens.size102) .clickable( - indication = rememberRipple(bounded = false), + indication = ripple(bounded = false), interactionSource = remember { MutableInteractionSource() }, ) { onChangeTokenClick() }, ) diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt index e0061c0c1b..1958445de2 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt @@ -25,7 +25,6 @@ import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.Network -import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.swap.analytics.SwapEvents import com.tangem.feature.swap.domain.BlockchainInteractor @@ -65,7 +64,7 @@ typealias SuccessLoadedSwapData = Map @Suppress("LargeClass", "LongParameterList") @HiltViewModel internal class SwapViewModel @Inject constructor( - private val swapInteractor: SwapInteractor, + private val swapInteractorFactory: SwapInteractor.Factory, private val blockchainInteractor: BlockchainInteractor, private val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, @@ -79,7 +78,13 @@ internal class SwapViewModel @Inject constructor( private val initialCryptoCurrency: CryptoCurrency = savedStateHandle.get(AppRoute.Swap.CURRENCY_BUNDLE_KEY) ?.unbundle(CryptoCurrency.serializer()) - ?: error("no expected parameter CryptoCurrency found`") + ?: error("no expected parameter CryptoCurrency found") + + private val userWalletId: UserWalletId = savedStateHandle.get(AppRoute.Swap.USER_WALLET_ID_KEY) + ?.unbundle(UserWalletId.serializer()) + ?: error("no expected parameter UserWalletId found") + + private val swapInteractor = swapInteractorFactory.create(userWalletId) private lateinit var initialCryptoCurrencyStatus: CryptoCurrencyStatus @@ -126,15 +131,13 @@ internal class SwapViewModel @Inject constructor( init { viewModelScope.launch(dispatchers.io) { - swapInteractor.getSelectedWallet()?.let { - val cryptoCurrencyStatus = - getCryptoCurrencyStatusUseCase(it.walletId, initialCryptoCurrency.id).getOrNull() - if (cryptoCurrencyStatus == null) { - uiState = stateBuilder.addAlert(uiState = uiState, onClick = swapRouter::back) - } else { - initialCryptoCurrencyStatus = cryptoCurrencyStatus - initTokens() - } + val cryptoCurrencyStatus = + getCryptoCurrencyStatusUseCase(userWalletId, initialCryptoCurrency.id).getOrNull() + if (cryptoCurrencyStatus == null) { + uiState = stateBuilder.addAlert(uiState = uiState, onClick = swapRouter::back) + } else { + initialCryptoCurrencyStatus = cryptoCurrencyStatus + initTokens() } } } @@ -191,7 +194,6 @@ internal class SwapViewModel @Inject constructor( ), ) - val userWalletId = swapInteractor.getSelectedWallet()?.walletId ?: return@launch (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { subscribeToCoinBalanceUpdates( userWalletId = userWalletId, @@ -439,13 +441,14 @@ internal class SwapViewModel @Inject constructor( val consideredProviders = state.consideredProvidersStates() return if (consideredProviders.isNotEmpty()) { + val successLoadedData = consideredProviders.getLastLoadedSuccessStates() + val bestQuotesProvider = findBestQuoteProvider(successLoadedData) val currentSelected = dataState.selectedProvider if (currentSelected != null && consideredProviders.keys.contains(currentSelected)) { - currentSelected + // logic for always choose best if already selected provider + bestQuotesProvider ?: currentSelected } else { - val successLoadedData = consideredProviders.getLastLoadedSuccessStates() val recommendedProvider = successLoadedData.keys.firstOrNull { it.isRecommended } - val bestQuotesProvider = findBestQuoteProvider(successLoadedData) triggerPromoProviderEvent(recommendedProvider, bestQuotesProvider) recommendedProvider ?: bestQuotesProvider ?: consideredProviders.keys.first() } @@ -706,8 +709,6 @@ internal class SwapViewModel @Inject constructor( analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(tokenChosen = true, token = it)) } - val userWalletId = swapInteractor.getSelectedWallet()?.walletId - if (foundToken != null) { val fromToken: CryptoCurrencyStatus val toToken: CryptoCurrencyStatus @@ -716,7 +717,7 @@ internal class SwapViewModel @Inject constructor( toToken = initialCryptoCurrencyStatus val newToken = fromToken.currency as? CryptoCurrency.Coin - if (userWalletId != null && newToken != null) { + if (newToken != null) { subscribeToCoinBalanceUpdates( userWalletId = userWalletId, coin = newToken, @@ -728,7 +729,7 @@ internal class SwapViewModel @Inject constructor( toToken = foundToken.currencyStatus val newToken = toToken.currency as? CryptoCurrency.Coin - if (userWalletId != null && newToken != null) { + if (newToken != null) { subscribeToCoinBalanceUpdates( userWalletId = userWalletId, coin = newToken, @@ -962,12 +963,10 @@ internal class SwapViewModel @Inject constructor( analyticsEventHandler.send(SwapEvents.ProviderClicked) val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates() val pricesLowerBest = getPricesLowerBest(providerId, states) - val unavailableProviders = getUnavailableProvidersFor(dataState.lastLoadedSwapStates) uiState = stateBuilder.showSelectProviderBottomSheet( uiState = uiState, selectedProviderId = providerId, pricesLowerBest = pricesLowerBest, - unavailableProviders = unavailableProviders, providersStates = dataState.lastLoadedSwapStates, ) { uiState = stateBuilder.dismissBottomSheet(uiState) } }, @@ -986,12 +985,10 @@ internal class SwapViewModel @Inject constructor( } }, onBuyClick = { currency -> - swapInteractor.getSelectedWallet()?.let { userWallet -> - swapRouter.openTokenDetails( - userWalletId = userWallet.walletId, - currency = currency, - ) - } + swapRouter.openTokenDetails( + userWalletId = userWalletId, + currency = currency, + ) }, onRetryClick = { startLoadingQuotesFromLastState() @@ -1119,15 +1116,6 @@ internal class SwapViewModel @Inject constructor( return groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }?.providers ?: emptyList() } - private fun getAllProviders(): List { - return dataState.tokensDataState?.allProviders ?: emptyList() - } - - private fun getUnavailableProvidersFor(state: Map): List { - val availableProviders = state.keys.map { it.providerId } - return getAllProviders().filterNot { availableProviders.contains(it.providerId) } - } - private fun Map.getLastLoadedSuccessStates(): SuccessLoadedSwapData { return this.filter { it.value is SwapState.QuotesLoadedState } .mapValues { it.value as SwapState.QuotesLoadedState } @@ -1172,20 +1160,18 @@ internal class SwapViewModel @Inject constructor( } private fun updateWalletBalance() { - swapInteractor.getSelectedWallet()?.let { userWallet -> - dataState.fromCryptoCurrency?.currency?.network?.let { network -> - viewModelScope.launch { - withContext(NonCancellable) { - updateForBalance(userWallet, network) - } + dataState.fromCryptoCurrency?.currency?.network?.let { network -> + viewModelScope.launch { + withContext(NonCancellable) { + updateForBalance(userWalletId, network) } } } } - private suspend fun updateForBalance(userWallet: UserWallet, network: Network) { + private suspend fun updateForBalance(userWalletId: UserWalletId, network: Network) { updateDelayedCurrencyStatusUseCase( - userWalletId = userWallet.walletId, + userWalletId = userWalletId, network = network, delayMillis = UPDATE_BALANCE_DELAY_MILLIS, refresh = true, diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index 73f39eae8c..32efd6bb49 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -55,6 +55,8 @@ dependencies { implementation(projects.core.deepLinks) implementation(projects.core.deepLinks.global) implementation(projects.core.featuretoggles) + implementation(projects.core.decompose) + implementation(projects.common.ui) implementation(projects.libs.crypto) @@ -77,6 +79,7 @@ dependencies { implementation(projects.domain.balanceHiding.models) implementation(projects.domain.transaction) implementation(projects.domain.staking) + implementation(projects.domain.markets.models) /** Temp dependency to swap domain */ implementation(projects.features.swap.domain) @@ -87,5 +90,6 @@ dependencies { implementation(projects.features.tokendetails.api) implementation(projects.features.send.api) implementation(projects.features.staking.api) + implementation(projects.features.markets.api) } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/TokenDetailsFragment.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/TokenDetailsFragment.kt index 25e7d71ad8..cb4222f44b 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/TokenDetailsFragment.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/TokenDetailsFragment.kt @@ -1,17 +1,29 @@ package com.tangem.feature.tokendetails.presentation +import android.os.Bundle import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.defaultComponentContext +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.bundle.unbundle +import com.tangem.common.routing.utils.asRouter +import com.tangem.core.decompose.context.DefaultAppComponentContext +import com.tangem.core.decompose.di.DecomposeComponent import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.components.NavigationBar3ButtonsScrim import com.tangem.core.ui.screen.ComposeFragment +import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreen import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsViewModel +import com.tangem.features.markets.MarketsFeatureToggles +import com.tangem.features.markets.token.block.TokenMarketBlockComponent import com.tangem.features.tokendetails.navigation.TokenDetailsRouter +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -24,17 +36,69 @@ internal class TokenDetailsFragment : ComposeFragment() { @Inject lateinit var tokenDetailsRouter: TokenDetailsRouter + @Inject + internal lateinit var marketsFeatureToggles: MarketsFeatureToggles + + @Inject + internal lateinit var coroutineDispatcherProvider: CoroutineDispatcherProvider + + @Inject + internal lateinit var componentBuilder: DecomposeComponent.Builder + + @Inject + internal lateinit var tokenMarketBlockComponentFactory: TokenMarketBlockComponent.Factory + + @Inject + internal lateinit var appRouter: AppRouter + + private var tokenMarketBlockComponent: TokenMarketBlockComponent? = null + private val internalTokenDetailsRouter: InnerTokenDetailsRouter get() = requireNotNull(tokenDetailsRouter as? InnerTokenDetailsRouter) { "internalTokenDetailsRouter should be instance of InnerTokenDetailsRouter" } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (marketsFeatureToggles.isFeatureEnabled) { + val cryptoCurrency: CryptoCurrency = arguments + ?.getBundle(AppRoute.CurrencyDetails.CRYPTO_CURRENCY_KEY) + ?.unbundle(CryptoCurrency.serializer()) + ?: error("Token Details screen can't open without `CryptoCurrency`") + + val param = cryptoCurrency.toParam() ?: return + + val appContext = DefaultAppComponentContext( + componentContext = defaultComponentContext(requireActivity().onBackPressedDispatcher), + messageHandler = uiDependencies.eventMessageHandler, + dispatchers = coroutineDispatcherProvider, + hiltComponentBuilder = componentBuilder, + replaceRouter = appRouter.asRouter(), + ) + + tokenMarketBlockComponent = tokenMarketBlockComponentFactory.create( + appComponentContext = appContext, + params = param, + ) + } + } + + private fun CryptoCurrency.toParam(): TokenMarketBlockComponent.Params? { + id.rawCurrencyId ?: return null // token price is not available + + return TokenMarketBlockComponent.Params(cryptoCurrency = this) + } + @Composable override fun ScreenContent(modifier: Modifier) { val viewModel = hiltViewModel() viewModel.router = this@TokenDetailsFragment.internalTokenDetailsRouter LocalLifecycleOwner.current.lifecycle.addObserver(viewModel) NavigationBar3ButtonsScrim() - TokenDetailsScreen(state = viewModel.uiState.collectAsStateWithLifecycle().value) + TokenDetailsScreen( + state = viewModel.uiState.collectAsStateWithLifecycle().value, + tokenMarketBlockComponent = tokenMarketBlockComponent, + ) } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt index e7625b4013..c75f89ca04 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt @@ -12,10 +12,10 @@ 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.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.tokendetails.presentation.tokendetails.state.* import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsActionButton -import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsPullToRefreshConfig import com.tangem.features.tokendetails.impl.R import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow @@ -143,9 +143,14 @@ internal object TokenDetailsPreviewData { val stakingErrorBlock = StakingBlockUM.Error(iconState) val stakingAvailableBlock = StakingBlockUM.StakeAvailable( - interestRate = "7.38", - periodInDays = 4, - tokenSymbol = "XLM", + titleText = resourceReference( + id = R.string.token_details_staking_block_title, + formatArgs = wrappedList("3.27%"), + ), + subtitleText = resourceReference( + id = R.string.staking_notification_earn_rewards_text_period_day, + formatArgs = wrappedList("Solana"), + ), iconState = iconState, onStakeClicked = {}, ) @@ -159,7 +164,7 @@ internal object TokenDetailsPreviewData { onStakeClicked = {}, ) - private val pullToRefreshConfig = TokenDetailsPullToRefreshConfig( + private val pullToRefreshConfig = PullToRefreshConfig( isRefreshing = false, onRefresh = {}, ) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/StakingBlockUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/StakingBlockUM.kt index 91d76db70d..b29d476a32 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/StakingBlockUM.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/StakingBlockUM.kt @@ -21,9 +21,8 @@ internal sealed interface StakingBlockUM { data class StakeAvailable( val iconState: IconState, - val interestRate: String, - val periodInDays: Int, - val tokenSymbol: String, + val titleText: TextReference, + val subtitleText: TextReference, val onStakeClicked: () -> Unit, ) : StakingBlockUM } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt index f6b3d59719..ffc5d6f5c1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt @@ -6,9 +6,9 @@ import com.tangem.core.ui.components.transactions.state.TransactionState import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification -import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsPullToRefreshConfig import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentList @@ -23,7 +23,7 @@ internal data class TokenDetailsState( val swapTxs: PersistentList, val txHistoryState: TxHistoryState, val dialogConfig: TokenDetailsDialogConfig?, - val pullToRefreshConfig: TokenDetailsPullToRefreshConfig, + val pullToRefreshConfig: PullToRefreshConfig, val bottomSheetConfig: TangemBottomSheetConfig?, val isBalanceHidden: Boolean, val isMarketPriceAvailable: Boolean, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsPullToRefreshConfig.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsPullToRefreshConfig.kt deleted file mode 100644 index 5015995638..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsPullToRefreshConfig.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.tangem.feature.tokendetails.presentation.tokendetails.state.components - -data class TokenDetailsPullToRefreshConfig(val isRefreshing: Boolean, val onRefresh: () -> Unit) \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt index 6aa3923cd9..2419e894d1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt @@ -6,25 +6,26 @@ import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter import com.tangem.core.ui.components.transactions.state.TxHistoryState +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.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.stakekit.RewardBlockType import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.feature.tokendetails.presentation.tokendetails.state.* -import com.tangem.feature.tokendetails.presentation.tokendetails.state.StakingBlockUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsTxHistoryTransactionStateConverter import com.tangem.feature.tokendetails.presentation.tokendetails.state.utils.getBalance +import com.tangem.feature.tokendetails.presentation.tokendetails.state.utils.getStringResourceId import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents import com.tangem.features.staking.api.featuretoggles.StakingFeatureToggles import com.tangem.features.tokendetails.impl.R +import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import com.tangem.utils.isNullOrZero @@ -93,7 +94,7 @@ internal class TokenDetailsLoadedBalanceConverter( currentState: TokenDetailsBalanceBlockState, status: CryptoCurrencyStatus, ): TokenDetailsBalanceBlockState { - val stakingCryptoAmount = (status.value.yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance() + val stakingCryptoAmount = (status.value.yieldBalance as? YieldBalance.Data)?.getTotalWithRewardsStakingBalance() val stakingFiatAmount = stakingCryptoAmount?.let { status.value.fiatRate?.multiply(it) } val isBalanceSelectorEnabled = stakingFeatureToggles.isStakingEnabled && !stakingCryptoAmount.isNullOrZero() return when (status.value) { @@ -156,6 +157,7 @@ internal class TokenDetailsLoadedBalanceConverter( val stakingFiatAmount = stakingCryptoAmount?.let { status.value.fiatRate?.multiply(it) } getStakedState( + status = status, stakingCryptoAmount = stakingCryptoAmount, stakingFiatAmount = stakingFiatAmount, stakingRewardAmount = stakingRewardAmount, @@ -191,19 +193,26 @@ internal class TokenDetailsLoadedBalanceConverter( stakingEntryInfo: StakingEntryInfo, iconState: IconState, ): StakingBlockUM.StakeAvailable { + val apr = BigDecimalFormatter.formatPercent( + percent = stakingEntryInfo.apr, + useAbsoluteValue = true, + ) return StakingBlockUM.StakeAvailable( - interestRate = BigDecimalFormatter.formatPercent( - percent = stakingEntryInfo.interestRate, - useAbsoluteValue = true, + titleText = resourceReference( + id = R.string.token_details_staking_block_title, + formatArgs = wrappedList(apr), + ), + subtitleText = resourceReference( + id = stakingEntryInfo.rewardSchedule.getStringResourceId(), + formatArgs = wrappedList(stakingEntryInfo.tokenSymbol), ), - periodInDays = stakingEntryInfo.periodInDays, - tokenSymbol = stakingEntryInfo.tokenSymbol, iconState = iconState, onStakeClicked = clickIntents::onStakeBannerClick, ) } private fun getStakedState( + status: CryptoCurrencyStatus, stakingCryptoAmount: BigDecimal?, stakingFiatAmount: BigDecimal?, stakingRewardAmount: BigDecimal?, @@ -221,16 +230,7 @@ internal class TokenDetailsLoadedBalanceConverter( appCurrencyProvider().symbol, ), ), - rewardValue = resourceReference( - R.string.staking_details_rewards_to_claim, - wrappedList( - BigDecimalFormatter.formatFiatAmount( - stakingRewardAmount, - appCurrencyProvider().code, - appCurrencyProvider().symbol, - ), - ), - ), + rewardValue = getRewardText(status, stakingRewardAmount), onStakeClicked = clickIntents::onStakeBannerClick, ) } @@ -295,4 +295,28 @@ internal class TokenDetailsLoadedBalanceConverter( return BigDecimalFormatter.formatCryptoAmount(totalAmount, status.currency.symbol, status.currency.decimals) } + + private fun getRewardText(status: CryptoCurrencyStatus, stakingRewardAmount: BigDecimal?): TextReference { + val isSolana = isSolana(status.currency.network.id.value) + val rewardBlockType = when { + isSolana -> RewardBlockType.RewardUnavailable + stakingRewardAmount.isNullOrZero() -> RewardBlockType.NoRewards + else -> RewardBlockType.Rewards + } + + return when (rewardBlockType) { + RewardBlockType.Rewards -> resourceReference( + R.string.staking_details_rewards_to_claim, + wrappedList( + BigDecimalFormatter.formatFiatAmount( + stakingRewardAmount, + appCurrencyProvider().code, + appCurrencyProvider().symbol, + ), + ), + ) + RewardBlockType.NoRewards -> resourceReference(R.string.staking_details_no_rewards_to_claim) + RewardBlockType.RewardUnavailable -> TextReference.EMPTY + } + } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt index 8339f1f69b..4aeac3dd4b 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt @@ -7,6 +7,7 @@ import com.tangem.core.ui.event.consumedEvent 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.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.card.NetworkHasDerivationUseCase import com.tangem.domain.tokens.model.CryptoCurrency @@ -15,7 +16,6 @@ import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.tokendetails.presentation.tokendetails.state.* import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsActionButton -import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsPullToRefreshConfig import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents import com.tangem.features.tokendetails.featuretoggles.TokenDetailsFeatureToggles import com.tangem.features.tokendetails.impl.R @@ -137,8 +137,8 @@ internal class TokenDetailsSkeletonStateConverter( ) } - private fun createPullToRefresh(): TokenDetailsPullToRefreshConfig = TokenDetailsPullToRefreshConfig( + private fun createPullToRefresh(): PullToRefreshConfig = PullToRefreshConfig( isRefreshing = false, - onRefresh = clickIntents::onRefreshSwipe, + onRefresh = { clickIntents.onRefreshSwipe(it.value) }, ) } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt index a13a7b2a77..934b75a0d1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt @@ -2,6 +2,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory import androidx.paging.PagingData import arrow.core.Either +import com.tangem.common.ui.tokens.getUnavailabilityReasonText import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig @@ -12,7 +13,6 @@ import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.event.triggeredEvent 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.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.card.NetworkHasDerivationUseCase @@ -216,7 +216,7 @@ internal class TokenDetailsStateFactory( isShow = true, onDismissRequest = clickIntents::onDismissDialog, content = TokenDetailsDialogConfig.DialogContentConfig.DisabledButtonReasonDialogConfig( - text = getUnavailabilityReasonText(unavailabilityReason), + text = unavailabilityReason.getUnavailabilityReasonText(), onConfirmClick = clickIntents::onDismissDialog, ), ), @@ -408,66 +408,4 @@ internal class TokenDetailsStateFactory( }.toImmutableList(), ) } - - private fun getUnavailabilityReasonText(unavailabilityReason: ScenarioUnavailabilityReason): TextReference { - return when (unavailabilityReason) { - is ScenarioUnavailabilityReason.StakingUnavailable -> { - resourceReference( - id = R.string.token_button_unavailability_reason_staking_unavailable, - formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName), - ) - } - is ScenarioUnavailabilityReason.PendingTransaction -> { - when (unavailabilityReason.withdrawalScenario) { - ScenarioUnavailabilityReason.WithdrawalScenario.SEND -> resourceReference( - id = R.string.token_button_unavailability_reason_pending_transaction_send, - formatArgs = wrappedList(unavailabilityReason.networkName), - ) - ScenarioUnavailabilityReason.WithdrawalScenario.SELL -> resourceReference( - id = R.string.token_button_unavailability_reason_pending_transaction_sell, - formatArgs = wrappedList(unavailabilityReason.networkName), - ) - } - } - is ScenarioUnavailabilityReason.EmptyBalance -> { - when (unavailabilityReason.withdrawalScenario) { - ScenarioUnavailabilityReason.WithdrawalScenario.SEND -> resourceReference( - id = R.string.token_button_unavailability_reason_empty_balance_send, - ) - ScenarioUnavailabilityReason.WithdrawalScenario.SELL -> resourceReference( - id = R.string.token_button_unavailability_reason_empty_balance_sell, - ) - } - } - is ScenarioUnavailabilityReason.BuyUnavailable -> { - resourceReference( - id = R.string.token_button_unavailability_reason_buy_unavailable, - formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName), - ) - } - is ScenarioUnavailabilityReason.NotExchangeable -> { - resourceReference( - id = R.string.token_button_unavailability_reason_not_exchangeable, - formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName), - ) - } - is ScenarioUnavailabilityReason.NotSupportedBySellService -> { - resourceReference( - id = R.string.token_button_unavailability_reason_sell_unavailable, - formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName), - ) - } - ScenarioUnavailabilityReason.Unreachable -> { - resourceReference( - id = R.string.token_button_unavailability_generic_description, - ) - } - ScenarioUnavailabilityReason.UnassociatedAsset -> resourceReference( - id = R.string.warning_receive_blocked_hedera_token_association_required_message, - ) - ScenarioUnavailabilityReason.None -> { - throw IllegalArgumentException("The unavailability reason must be other than None") - } - } - } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenStakingStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenStakingStateConverter.kt index 3bc3e36023..b12e12eb53 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenStakingStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenStakingStateConverter.kt @@ -1,12 +1,16 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory import arrow.core.Either +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.staking.model.StakingEntryInfo import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.feature.tokendetails.presentation.tokendetails.state.StakingBlockUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.utils.getStringResourceId import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents +import com.tangem.features.tokendetails.impl.R import com.tangem.utils.Provider import com.tangem.utils.converter.Converter @@ -24,14 +28,20 @@ internal class TokenStakingStateConverter( ifLeft = { StakingBlockUM.Error(iconState = iconState) }, - ifRight = { + ifRight = { stakingEntryInfo -> + val apr = BigDecimalFormatter.formatPercent( + percent = stakingEntryInfo.apr, + useAbsoluteValue = true, + ) StakingBlockUM.StakeAvailable( - interestRate = BigDecimalFormatter.formatPercent( - percent = it.interestRate, - useAbsoluteValue = true, + titleText = resourceReference( + id = R.string.token_details_staking_block_title, + formatArgs = wrappedList(apr), + ), + subtitleText = resourceReference( + id = stakingEntryInfo.rewardSchedule.getStringResourceId(), + formatArgs = wrappedList(stakingEntryInfo.tokenSymbol), ), - periodInDays = it.periodInDays, - tokenSymbol = it.tokenSymbol, iconState = iconState, onStakeClicked = clickIntents::onStakeBannerClick, ) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryTransactionStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryTransactionStateConverter.kt index 3aacd1221a..5eea986141 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryTransactionStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryTransactionStateConverter.kt @@ -1,5 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory +import com.tangem.common.extensions.isZero import com.tangem.core.ui.components.transactions.state.TransactionState import com.tangem.core.ui.components.transactions.state.TransactionState.Content.Direction import com.tangem.core.ui.extensions.TextReference @@ -8,6 +9,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.utils.toTimeFormat import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.domain.txhistory.models.TxHistoryItem.* import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents import com.tangem.features.tokendetails.impl.R import com.tangem.utils.StringsSigns.MINUS @@ -42,34 +44,44 @@ internal class TokenDetailsTxHistoryTransactionStateConverter( ) } - private fun TxHistoryItem.extractIcon(): Int = if (status == TxHistoryItem.TransactionStatus.Failed) { + private fun TxHistoryItem.extractIcon(): Int = if (status == TransactionStatus.Failed) { R.drawable.ic_close_24 } else { when (type) { - is TxHistoryItem.TransactionType.Approve -> R.drawable.ic_doc_24 - is TxHistoryItem.TransactionType.Operation, - is TxHistoryItem.TransactionType.Swap, - is TxHistoryItem.TransactionType.Transfer, - is TxHistoryItem.TransactionType.UnknownOperation, + is TransactionType.Approve -> R.drawable.ic_doc_24 + is TransactionType.TronStakingTransactionType.Stake, + is TransactionType.TronStakingTransactionType.Vote, + -> R.drawable.ic_transaction_history_staking + is TransactionType.TronStakingTransactionType.Withdraw, + is TransactionType.TronStakingTransactionType.Unstake, + -> R.drawable.ic_transaction_history_unstaking + is TransactionType.Operation, + is TransactionType.Swap, + is TransactionType.Transfer, + is TransactionType.UnknownOperation, -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 } } private fun TxHistoryItem.extractTitle(): TextReference = when (val type = type) { - is TxHistoryItem.TransactionType.Approve -> resourceReference(R.string.common_approval) - is TxHistoryItem.TransactionType.Operation -> stringReference(type.name) - is TxHistoryItem.TransactionType.Swap -> resourceReference(R.string.common_swap) - is TxHistoryItem.TransactionType.Transfer -> resourceReference(R.string.common_transfer) - is TxHistoryItem.TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation) + is TransactionType.Approve -> resourceReference(R.string.common_approval) + is TransactionType.Operation -> stringReference(type.name) + is TransactionType.Swap -> resourceReference(R.string.common_swap) + is TransactionType.Transfer -> resourceReference(R.string.common_transfer) + is TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation) + is TransactionType.TronStakingTransactionType.Stake -> resourceReference(R.string.common_stake) + is TransactionType.TronStakingTransactionType.Unstake -> resourceReference(R.string.common_unstake) + is TransactionType.TronStakingTransactionType.Vote -> resourceReference(R.string.staking_vote) + is TransactionType.TronStakingTransactionType.Withdraw -> resourceReference(R.string.staking_withdraw) } private fun TxHistoryItem.extractSubtitle(): TextReference = when (val interactionAddress = interactionAddressType) { - is TxHistoryItem.InteractionAddressType.Contract -> resourceReference( + is InteractionAddressType.Contract -> resourceReference( id = R.string.transaction_history_contract_address, formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), ) - is TxHistoryItem.InteractionAddressType.Multiple -> resourceReference( + is InteractionAddressType.Multiple -> resourceReference( id = if (isOutgoing) { R.string.transaction_history_transaction_to_address } else { @@ -77,7 +89,7 @@ internal class TokenDetailsTxHistoryTransactionStateConverter( }, formatArgs = wrappedList(resourceReference(R.string.transaction_history_multiple_addresses)), ) - is TxHistoryItem.InteractionAddressType.User -> resourceReference( + is InteractionAddressType.User -> resourceReference( id = if (isOutgoing) { R.string.transaction_history_transaction_to_address } else { @@ -85,19 +97,28 @@ internal class TokenDetailsTxHistoryTransactionStateConverter( }, formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), ) + is InteractionAddressType.Staking -> resourceReference( + id = R.string.common_staking, + ) } private fun TxHistoryItem.extractDirection() = if (isOutgoing) Direction.OUTGOING else Direction.INCOMING - private fun TxHistoryItem.TransactionStatus.tiUiStatus() = when (this) { - TxHistoryItem.TransactionStatus.Confirmed -> TransactionState.Content.Status.Confirmed - TxHistoryItem.TransactionStatus.Failed -> TransactionState.Content.Status.Failed - TxHistoryItem.TransactionStatus.Unconfirmed -> TransactionState.Content.Status.Unconfirmed + private fun TransactionStatus.tiUiStatus() = when (this) { + TransactionStatus.Confirmed -> TransactionState.Content.Status.Confirmed + TransactionStatus.Failed -> TransactionState.Content.Status.Failed + TransactionStatus.Unconfirmed -> TransactionState.Content.Status.Unconfirmed } private fun TxHistoryItem.getAmount(): String { - val prefix = when (status) { - TxHistoryItem.TransactionStatus.Failed -> "" + if (type == TransactionType.TronStakingTransactionType.Vote || + type == TransactionType.TronStakingTransactionType.Withdraw + ) { + return "" + } + val prefix = when { + status == TransactionStatus.Failed -> "" + this.amount.isZero() -> "" else -> if (isOutgoing) MINUS else PLUS } return prefix + amount.toFormattedCurrencyString(currency = symbol, decimals = decimals) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/BalanceUtils.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/BalanceUtils.kt index faa0144943..fe714ff2ca 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/BalanceUtils.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/BalanceUtils.kt @@ -3,7 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.utils import com.tangem.feature.tokendetails.presentation.tokendetails.state.BalanceType import java.math.BigDecimal -fun BigDecimal.getBalance(selectedBalanceType: BalanceType, stakingAmount: BigDecimal?): BigDecimal { +internal fun BigDecimal.getBalance(selectedBalanceType: BalanceType, stakingAmount: BigDecimal?): BigDecimal { return if (selectedBalanceType == BalanceType.ALL && stakingAmount != null) { this.plus(stakingAmount) } else { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/RewardScheduleUtils.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/RewardScheduleUtils.kt new file mode 100644 index 0000000000..1219166e7b --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/RewardScheduleUtils.kt @@ -0,0 +1,26 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.utils + +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.features.tokendetails.impl.R + +internal fun Yield.Metadata.RewardSchedule.getStringResourceId(): Int { + return when (this) { + Yield.Metadata.RewardSchedule.BLOCK, + Yield.Metadata.RewardSchedule.DAY, + Yield.Metadata.RewardSchedule.ERA, + Yield.Metadata.RewardSchedule.EPOCH, + -> R.string.staking_notification_earn_rewards_text_daily + + Yield.Metadata.RewardSchedule.HOUR, + -> R.string.staking_notification_earn_rewards_text_hourly + + Yield.Metadata.RewardSchedule.WEEK, + -> R.string.staking_notification_earn_rewards_text_weekly + + Yield.Metadata.RewardSchedule.MONTH, + -> R.string.staking_notification_earn_rewards_text_monthly + + else + -> R.string.staking_notification_earn_rewards_text_daily + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 378b55e62d..0cda424f1a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -38,6 +38,7 @@ import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPreviewData @@ -52,12 +53,13 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.e import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.ExchangeStatusBottomSheetConfig import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.swapTransactionsItems import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlock +import com.tangem.features.markets.token.block.TokenMarketBlockComponent // TODO: Split to blocks [REDACTED_JIRA] @Suppress("LongMethod") @OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) @Composable -internal fun TokenDetailsScreen(state: TokenDetailsState) { +internal fun TokenDetailsScreen(state: TokenDetailsState, tokenMarketBlockComponent: TokenMarketBlockComponent?) { BackHandler(onBack = state.topAppBarConfig.onBackClick) val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } @@ -79,7 +81,7 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) { ) { scaffoldPaddings -> val pullRefreshState = rememberPullRefreshState( refreshing = state.pullToRefreshConfig.isRefreshing, - onRefresh = state.pullToRefreshConfig.onRefresh, + onRefresh = { state.pullToRefreshConfig.onRefresh(PullToRefreshConfig.ShowRefreshState()) }, ) val txHistoryItems = if (state.txHistoryState is TxHistoryState.Content) { @@ -139,12 +141,27 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) { } }, ) - if (state.isMarketPriceAvailable) { - item( - key = MarketPriceBlockState::class.java, - contentType = MarketPriceBlockState::class.java, - content = { MarketPriceBlock(modifier = itemModifier, state = state.marketPriceBlockState) }, - ) + + when { + tokenMarketBlockComponent != null -> { + item( + key = TokenMarketBlockComponent::class.java, + contentType = TokenMarketBlockComponent::class.java, + content = { tokenMarketBlockComponent.Content(modifier = itemModifier) }, + ) + } + state.isMarketPriceAvailable -> { + item( + key = MarketPriceBlockState::class.java, + contentType = MarketPriceBlockState::class.java, + content = { + MarketPriceBlock( + modifier = itemModifier, + state = state.marketPriceBlockState, + ) + }, + ) + } } if (state.isStakingBlockShown) { @@ -153,8 +170,9 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) { contentType = StakingBlockUM::class.java, content = { TokenStakingBlock( - modifier = itemModifier, state = state.stakingBlocksState, + isBalanceHidden = state.isBalanceHidden, + modifier = itemModifier, ) }, ) @@ -219,7 +237,10 @@ private fun TokenDetailsScreenPreview( @PreviewParameter(TokenDetailsScreenParameterProvider::class) state: TokenDetailsState, ) { TangemThemePreview { - TokenDetailsScreen(state) + TokenDetailsScreen( + state = state, + tokenMarketBlockComponent = null, + ) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt index b814e9e554..74ab07f465 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.buttons.HorizontalActionChips import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -22,7 +23,6 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPre import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsActionButton import com.tangem.features.tokendetails.impl.R -import com.tangem.utils.StringsSigns.STARS import kotlinx.collections.immutable.toImmutableList @Composable @@ -94,13 +94,13 @@ private fun FiatBalance( ) is TokenDetailsBalanceBlockState.Content -> Text( modifier = modifier, - text = if (isBalanceHidden) STARS else state.displayFiatBalance, + text = state.displayFiatBalance.orMaskWithStars(isBalanceHidden), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, ) is TokenDetailsBalanceBlockState.Error -> Text( modifier = modifier, - text = if (isBalanceHidden) STARS else BigDecimalFormatter.EMPTY_BALANCE_SIGN, + text = BigDecimalFormatter.EMPTY_BALANCE_SIGN.orMaskWithStars(isBalanceHidden), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, ) @@ -122,13 +122,13 @@ private fun CryptoBalance( ) is TokenDetailsBalanceBlockState.Content -> Text( modifier = modifier, - text = if (isBalanceHidden) STARS else state.displayCryptoBalance, + text = state.displayCryptoBalance.orMaskWithStars(isBalanceHidden), style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, ) is TokenDetailsBalanceBlockState.Error -> Text( modifier = modifier, - text = if (isBalanceHidden) STARS else BigDecimalFormatter.EMPTY_BALANCE_SIGN, + text = BigDecimalFormatter.EMPTY_BALANCE_SIGN.orMaskWithStars(isBalanceHidden), style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, ) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsDialogs.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsDialogs.kt index ed54c9459b..5a4f590fe7 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsDialogs.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsDialogs.kt @@ -2,7 +2,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components import androidx.compose.runtime.Composable import com.tangem.core.ui.components.BasicDialog -import com.tangem.core.ui.components.DialogButton +import com.tangem.core.ui.components.DialogButtonUM import com.tangem.core.ui.extensions.resolveReference import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig @@ -19,7 +19,7 @@ internal fun TokenDetailsDialogs(state: TokenDetailsState) { private fun TokenDetailsDialog(config: TokenDetailsDialogConfig) { BasicDialog( message = config.content.message.resolveReference(), - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = config.content.confirmButtonConfig.text.resolveReference(), warning = config.content.confirmButtonConfig.warning, onClick = config.content.confirmButtonConfig.onClick, @@ -27,7 +27,7 @@ private fun TokenDetailsDialog(config: TokenDetailsDialogConfig) { onDismissDialog = config.onDismissRequest, title = config.content.title?.resolveReference(), dismissButton = config.content.cancelButtonConfig?.let { cancelButtonConfig -> - DialogButton( + DialogButtonUM( title = cancelButtonConfig.text.resolveReference(), warning = cancelButtonConfig.warning, onClick = cancelButtonConfig.onClick, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/StakingBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/StakingBalanceBlock.kt index c1ce6e3abd..1107eba6ae 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/StakingBalanceBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/StakingBalanceBlock.kt @@ -5,9 +5,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -18,6 +18,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +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.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -27,7 +29,11 @@ import com.tangem.features.tokendetails.impl.R import com.tangem.utils.StringsSigns @Composable -internal fun StakingBalanceBlock(state: StakingBlockUM.Staked, modifier: Modifier = Modifier) { +internal fun StakingBalanceBlock( + state: StakingBlockUM.Staked, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier @@ -36,7 +42,7 @@ internal fun StakingBalanceBlock(state: StakingBlockUM.Staked, modifier: Modifie .background(TangemTheme.colors.background.primary) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(), + indication = ripple(), onClick = state.onStakeClicked, ) .padding(TangemTheme.dimens.spacing12), @@ -53,7 +59,7 @@ internal fun StakingBalanceBlock(state: StakingBlockUM.Staked, modifier: Modifie ) Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { Text( - text = state.fiatValue.resolveReference(), + text = state.fiatValue.orMaskWithStars(isBalanceHidden).resolveReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.primary1, ) @@ -63,16 +69,18 @@ internal fun StakingBalanceBlock(state: StakingBlockUM.Staked, modifier: Modifie color = TangemTheme.colors.text.primary1, ) Text( - text = state.cryptoValue.resolveReference(), + text = state.cryptoValue.orMaskWithStars(isBalanceHidden).resolveReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.tertiary, ) } - Text( - text = state.rewardValue.resolveReference(), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) + if (state.rewardValue != TextReference.EMPTY) { + Text( + text = state.rewardValue.orMaskWithStars(isBalanceHidden).resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } } Icon( painter = painterResource(id = R.drawable.ic_chevron_right_24), @@ -92,6 +100,7 @@ private fun StakingBalanceBlock_Preview( TangemThemePreview { StakingBalanceBlock( state = data, + isBalanceHidden = false, modifier = Modifier.padding(TangemTheme.dimens.spacing16), ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlock.kt index c2d7940cbc..8abb138685 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlock.kt @@ -17,6 +17,8 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.SpacerW8 +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.getGreyScaleColorFilter @@ -31,11 +33,12 @@ import com.tangem.features.tokendetails.impl.R /** * Token staking block * - * @param state component state - * @param modifier modifier + * @param state component state + * @param isBalanceHidden whether to hide balance + * @param modifier modifier */ @Composable -internal fun TokenStakingBlock(state: StakingBlockUM, modifier: Modifier = Modifier) { +internal fun TokenStakingBlock(state: StakingBlockUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { AnimatedContent( targetState = state, contentAlignment = Alignment.CenterStart, @@ -49,6 +52,7 @@ internal fun TokenStakingBlock(state: StakingBlockUM, modifier: Modifier = Modif ) is StakingBlockUM.Staked -> StakingBalanceBlock( state = it, + isBalanceHidden = isBalanceHidden, modifier = modifier, ) is StakingBlockUM.StakeAvailable -> StakingAvailableContent( @@ -86,10 +90,7 @@ private fun StakingAvailableContent(state: StakingBlockUM.StakeAvailable, modifi SpacerW8() Column { Text( - text = stringResource( - R.string.token_details_staking_block_title, - state.interestRate, - ), + text = state.titleText.resolveReference(), color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.subtitle2, ) @@ -97,11 +98,7 @@ private fun StakingAvailableContent(state: StakingBlockUM.StakeAvailable, modifi Spacer(modifier = Modifier.size(TangemTheme.dimens.size4)) Text( - text = stringResource( - R.string.token_details_staking_block_subtitle, - state.tokenSymbol, - state.periodInDays, - ), + text = state.subtitleText.resolveReference(), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.body2, ) @@ -161,8 +158,9 @@ private fun StakingLoading(iconState: IconState, modifier: Modifier = Modifier) } SecondaryButton( modifier = Modifier.fillMaxWidth(), - text = "Loading", // TODO staking - onClick = { /* [REDACTED_TODO_COMMENT] */ }, + showProgress = true, + text = TextReference.EMPTY.resolveReference(), + onClick = { /* no-op */ }, ) } } @@ -176,7 +174,10 @@ private fun Preview_TokenStakingBlock( state: StakingBlockUM, ) { TangemThemePreview { - TokenStakingBlock(state = state) + TokenStakingBlock( + state = state, + isBalanceHidden = false, + ) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt index 67920c922a..aa63ffd144 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt @@ -13,10 +13,7 @@ import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.swap.domain.api.SwapRepository -import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType -import com.tangem.feature.swap.domain.models.domain.ExchangeStatus -import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel -import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel +import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter @@ -109,7 +106,7 @@ internal class ExchangeStatusFactory( suspend fun updateSwapTxStatuses(swapTxList: PersistentList) = withContext(dispatchers.io) { swapTxList.map { tx -> async { - val statusModel = getExchangeStatus(tx.txId) + val statusModel = getExchangeStatus(tx.txId, tx.provider) val isRefundTerminalStatus = statusModel?.refundNetwork == null && statusModel?.refundContractAddress == null && tx.provider.type != ExchangeProviderType.DEX_BRIDGE @@ -130,26 +127,26 @@ internal class ExchangeStatusFactory( .toPersistentList() } - private suspend fun getExchangeStatus(txId: String): ExchangeStatusModel? { + private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? { return swapRepository.getExchangeStatus(txId) .fold( ifLeft = { null }, ifRight = { statusModel -> - sendStatusUpdateAnalytics(statusModel) + sendStatusUpdateAnalytics(statusModel, provider) swapTransactionRepository.storeTransactionState(txId, statusModel) statusModel }, ) } - private suspend fun sendStatusUpdateAnalytics(statusModel: ExchangeStatusModel) { + private suspend fun sendStatusUpdateAnalytics(statusModel: ExchangeStatusModel, provider: SwapProvider) { val txId = statusModel.txId ?: return val status = toAnalyticStatus(statusModel.status) ?: return val savedStatus = swapTransactionStatusStore.getTransactionStatus(txId) if (savedStatus != status) { analyticsEventsHandlerProvider().send( - TokenExchangeAnalyticsEvent.CexTxStatusChanged(cryptoCurrency.symbol, status.value), + TokenExchangeAnalyticsEvent.CexTxStatusChanged(cryptoCurrency.symbol, status.value, provider.name), ) swapTransactionStatusStore.setTransactionStatus(txId, status) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt index 833a3a3f0b..ec142bf0ca 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt @@ -29,7 +29,7 @@ interface TokenDetailsClickIntents { fun onHideConfirmed() - fun onRefreshSwipe() + fun onRefreshSwipe(isRefreshing: Boolean) fun onBuyCoinClick(cryptoCurrency: CryptoCurrency) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt index d6c7da21d3..5d65adaf1c 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt @@ -6,6 +6,7 @@ import androidx.paging.cachedIn import arrow.core.getOrElse import com.tangem.blockchain.common.address.AddressType import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.common.routing.bundle.unbundle import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -126,6 +127,7 @@ internal class TokenDetailsViewModel @Inject constructor( tokenDetailsFeatureToggles: TokenDetailsFeatureToggles, deepLinksRegistry: DeepLinksRegistry, savedStateHandle: SavedStateHandle, + private val appRouter: AppRouter, ) : ViewModel(), DefaultLifecycleObserver, TokenDetailsClickIntents { private val userWalletId: UserWalletId = savedStateHandle.get(AppRoute.CurrencyDetails.USER_WALLET_ID_KEY) @@ -391,8 +393,8 @@ internal class TokenDetailsViewModel @Inject constructor( private fun updateStakingInfo() { viewModelScope.launch { val stakingAvailability = getStakingAvailabilityUseCase( - cryptoCurrencyId = cryptoCurrency.id, - symbol = cryptoCurrency.symbol, + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, ).getOrElse { StakingAvailability.Unavailable } internalUiState.value = stateFactory.getStateWithUpdatedStakingAvailability(stakingAvailability) @@ -464,6 +466,7 @@ internal class TokenDetailsViewModel @Inject constructor( } override fun onStakeBannerClick() { + analyticsEventsHandler.send(TokenScreenAnalyticsEvent.StakingClicked(cryptoCurrency.symbol)) openStaking() } @@ -616,7 +619,7 @@ internal class TokenDetailsViewModel @Inject constructor( if (handleUnavailabilityReason(unavailabilityReason)) return - reduxStateHolder.dispatch(TradeCryptoAction.Swap(cryptoCurrency)) + appRouter.push(AppRoute.Swap(currency = cryptoCurrency, userWalletId = userWalletId)) } override fun onDismissDialog() { @@ -709,7 +712,7 @@ internal class TokenDetailsViewModel @Inject constructor( ) } - override fun onRefreshSwipe() { + override fun onRefreshSwipe(isRefreshing: Boolean) { analyticsEventsHandler.send(TokenScreenAnalyticsEvent.Refreshed(cryptoCurrency.symbol)) internalUiState.value = stateFactory.getRefreshingState() diff --git a/features/wallet-settings/impl/build.gradle.kts b/features/wallet-settings/impl/build.gradle.kts index 14885a740b..e472817d13 100644 --- a/features/wallet-settings/impl/build.gradle.kts +++ b/features/wallet-settings/impl/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { /* Project - API */ implementation(projects.features.walletSettings.api) + implementation(projects.features.manageTokens.api) /* Project - Core */ implementation(projects.core.decompose) 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 4dba365556..c0f134ef22 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 @@ -13,9 +13,7 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { private val previewState = WalletSettingsUM( popBack = {}, - items = ItemsBuilder( - router = DummyRouter(), - ).buildItems( + items = ItemsBuilder(router = DummyRouter()).buildItems( userWalletId = UserWalletId("011"), userWalletName = "My Wallet", isReferralAvailable = true, @@ -23,6 +21,7 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { renameWallet = {}, forgetWallet = {}, onLinkMoreCardsClick = {}, + isManageTokensAvailable = true, ), ) 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 a9e5b42932..ba19734477 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 @@ -13,7 +13,7 @@ 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.components.BasicDialog -import com.tangem.core.ui.components.DialogButton +import com.tangem.core.ui.components.DialogButtonUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.ContentMessage import com.tangem.core.ui.message.SnackbarMessage @@ -32,6 +32,7 @@ import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.entity.WalletSettingsUM import com.tangem.feature.walletsettings.impl.R import com.tangem.feature.walletsettings.utils.ItemsBuilder +import com.tangem.features.managetokens.ManageTokensToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf @@ -53,6 +54,7 @@ internal class WalletSettingsModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsContextProxy: AnalyticsContextProxy, private val reduxStateHolder: ReduxStateHolder, + private val manageTokensToggles: ManageTokensToggles, ) : Model() { val params: WalletSettingsComponent.Params = paramsContainer.require() @@ -86,6 +88,7 @@ internal class WalletSettingsModel @Inject constructor( userWalletName = userWallet.name, isReferralAvailable = userWallet.cardTypesResolver.isTangemWallet(), isLinkMoreCardsAvailable = userWallet.scanResponse.card.backupStatus == CardDTO.BackupStatus.NoBackup, + isManageTokensAvailable = userWallet.isMultiCurrency && manageTokensToggles.isFeatureEnabled, renameWallet = { openRenameWalletDialog(userWallet, dialogNavigation) }, forgetWallet = { messageSender.send( @@ -93,7 +96,7 @@ internal class WalletSettingsModel @Inject constructor( BasicDialog( message = stringResource(R.string.user_wallet_list_delete_prompt), onDismissDialog = onDismiss, - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = stringResource(R.string.common_delete), warning = true, onClick = { @@ -101,7 +104,7 @@ internal class WalletSettingsModel @Inject constructor( onDismiss() }, ), - dismissButton = DialogButton( + dismissButton = DialogButtonUM( title = stringResource(R.string.common_cancel), onClick = onDismiss, ), diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/RenameWalletDialog.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/RenameWalletDialog.kt index 5ff04fe2e7..8cbce3fc72 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/RenameWalletDialog.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/RenameWalletDialog.kt @@ -6,8 +6,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.components.AdditionalTextInputDialogParams -import com.tangem.core.ui.components.DialogButton +import com.tangem.core.ui.components.AdditionalTextInputDialogUM +import com.tangem.core.ui.components.DialogButtonUM import com.tangem.core.ui.components.TextInputDialog import com.tangem.core.ui.res.TangemThemePreview import com.tangem.feature.walletsettings.component.preview.PreviewRenameWalletComponent @@ -21,18 +21,18 @@ internal fun RenameWalletDialog(model: RenameWalletUM, onDismiss: () -> Unit) { TextInputDialog( title = stringResource(id = R.string.user_wallet_list_rename_popup_title), fieldValue = value, - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = stringResource(id = R.string.common_ok), enabled = model.isConfirmEnabled, onClick = model.onConfirm, ), - dismissButton = DialogButton( + dismissButton = DialogButtonUM( title = stringResource(id = R.string.common_cancel), onClick = onDismiss, ), onDismissDialog = onDismiss, onValueChange = model.updateValue, - textFieldParams = AdditionalTextInputDialogParams( + textFieldParams = AdditionalTextInputDialogUM( label = stringResource(id = R.string.user_wallet_list_rename_popup_placeholder), ), ) diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt index 29223948a2..e2d06d71c8 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -23,6 +24,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.LocalSnackbarHostState import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TestTags import com.tangem.feature.walletsettings.component.preview.PreviewWalletSettingsComponent import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.entity.WalletSettingsUM @@ -55,7 +57,7 @@ internal fun WalletSettingsScreen( }, content = { paddingValues -> Content( - modifier = Modifier.padding(paddingValues), + modifier = Modifier.padding(paddingValues).testTag(TestTags.WALLET_SETTINGS_SCREEN), state = state, ) @@ -89,6 +91,7 @@ private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { val itemModifier = Modifier .padding(horizontal = TangemTheme.dimens.spacing16) .fillMaxWidth() + .testTag(TestTags.WALLET_SETTINGS_SCREEN_ITEM) when (item) { is WalletSettingsItemUM.WithItems -> ItemsBlock( 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 941f1c53f2..896d1bbf0c 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 @@ -25,12 +25,19 @@ internal class ItemsBuilder @Inject constructor( userWalletName: String, isLinkMoreCardsAvailable: Boolean, isReferralAvailable: Boolean, + isManageTokensAvailable: Boolean, forgetWallet: () -> Unit, renameWallet: () -> Unit, onLinkMoreCardsClick: () -> Unit, ): PersistentList = persistentListOf( buildNameItem(userWalletName, renameWallet), - buildCardItem(userWalletId, isLinkMoreCardsAvailable, isReferralAvailable, onLinkMoreCardsClick), + buildCardItem( + userWalletId = userWalletId, + isLinkMoreCardsAvailable = isLinkMoreCardsAvailable, + isReferralAvailable = isReferralAvailable, + isManageTokensAvailable = isManageTokensAvailable, + onLinkMoreCardsClick = onLinkMoreCardsClick, + ), buildForgetItem(forgetWallet), ) @@ -45,11 +52,20 @@ internal class ItemsBuilder @Inject constructor( userWalletId: UserWalletId, isLinkMoreCardsAvailable: Boolean, isReferralAvailable: Boolean, + isManageTokensAvailable: Boolean, onLinkMoreCardsClick: () -> Unit, ) = WalletSettingsItemUM.WithItems( id = "card", description = resourceReference(R.string.settings_card_settings_footer), blocks = buildList { + if (isManageTokensAvailable) { + BlockUM( + text = resourceReference(R.string.add_tokens_title), + iconRes = R.drawable.ic_tether_24, + onClick = { router.push(AppRoute.ManageTokens(userWalletId)) }, + ).let(::add) + } + if (isLinkMoreCardsAvailable) { BlockUM( text = resourceReference(R.string.details_row_title_create_backup), diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index 9d6ff75448..db8ba49b05 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -78,7 +78,8 @@ dependencies { implementation(projects.domain.balanceHiding.models) implementation(projects.domain.analytics) implementation(projects.domain.visa) - implementation(projects.domain.staking) + implementation(projects.domain.staking.models) + implementation(projects.domain.markets.models) //TODO: Create api/impl modules for onboarding [REDACTED_JIRA] implementation(projects.features.onboarding) @@ -92,4 +93,11 @@ dependencies { implementation(projects.features.details.api) implementation(projects.features.pushNotifications.api) implementation(projects.features.markets.api) + + /** Common modules */ + implementation(projects.common.ui) + + /** Test libraries */ + implementation(deps.test.junit) + implementation(deps.test.truth) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/WalletFragment.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/WalletFragment.kt index 868de285aa..74b81ff310 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/WalletFragment.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/WalletFragment.kt @@ -5,13 +5,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.arkivanov.decompose.defaultComponentContext +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.utils.asRouter import com.tangem.core.decompose.context.DefaultAppComponentContext import com.tangem.core.decompose.di.DecomposeComponent import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.screen.ComposeFragment import com.tangem.feature.wallet.presentation.router.InnerWalletRouter import com.tangem.features.markets.MarketsFeatureToggles -import com.tangem.features.markets.component.MarketsEntryComponent +import com.tangem.features.markets.entry.MarketsEntryComponent import com.tangem.features.wallet.navigation.WalletRouter import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.android.AndroidEntryPoint @@ -44,6 +46,9 @@ internal class WalletFragment : ComposeFragment() { @Inject internal lateinit var marketsFeatureToggles: MarketsFeatureToggles + @Inject + internal lateinit var appRouter: AppRouter + private var marketsEntryComponent: MarketsEntryComponent? = null private val _walletRouter: InnerWalletRouter @@ -60,6 +65,7 @@ internal class WalletFragment : ComposeFragment() { messageHandler = uiDependencies.eventMessageHandler, dispatchers = coroutineDispatcherProvider, hiltComponentBuilder = componentBuilder, + replaceRouter = appRouter.asRouter(), ) marketsEntryComponent = marketsEntryComponentFactory.create(appContext) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt index 0ad7e674a7..16cf6e8e9e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt @@ -2,13 +2,12 @@ package com.tangem.feature.wallet.presentation.common import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.res.TangemColorPalette import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.wallet.impl.R -import com.tangem.feature.wallet.presentation.common.state.TokenItemState import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensListState import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensState @@ -66,122 +65,22 @@ internal object WalletPreviewData { ) } - val coinIconState - get() = CurrencyIconState.CoinIcon( - url = null, - fallbackResId = R.drawable.img_polygon_22, - isGrayscale = false, - showCustomBadge = false, - ) - - private val tokenIconState - get() = CurrencyIconState.TokenIcon( - url = null, - topBadgeIconResId = R.drawable.img_polygon_22, - fallbackTint = TangemColorPalette.Black, - fallbackBackground = TangemColorPalette.Meadow, - isGrayscale = false, - showCustomBadge = false, - ) - - private val customTokenIconState - get() = CurrencyIconState.CustomTokenIcon( - tint = TangemColorPalette.Black, - background = TangemColorPalette.Meadow, - topBadgeIconResId = R.drawable.img_polygon_22, - isGrayscale = false, - ) - - val tokenItemVisibleState by lazy { - TokenItemState.Content( - id = UUID.randomUUID().toString(), - iconState = coinIconState, - titleState = TokenItemState.TitleState.Content(text = "Polygon", hasPending = true), - fiatAmountState = TokenItemState.FiatAmountState.Content(text = "321 $", hasStaked = true), - cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "5,412 MATIC"), - cryptoPriceState = TokenItemState.CryptoPriceState.Unknown, - onItemClick = {}, - onItemLongClick = {}, - ) - } - - val testnetTokenItemVisibleState by lazy { - tokenItemVisibleState.copy( - titleState = TokenItemState.TitleState.Content(text = "Polygon testnet"), - iconState = tokenIconState.copy(isGrayscale = true), - ) - } - - val tokenItemHiddenState by lazy { - TokenItemState.Content( - id = UUID.randomUUID().toString(), - iconState = tokenIconState, - titleState = TokenItemState.TitleState.Content(text = "Polygon"), - fiatAmountState = TokenItemState.FiatAmountState.Content(text = "321 $", hasStaked = false), - cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "5,412 MATIC"), - cryptoPriceState = TokenItemState.CryptoPriceState.Content( - price = "312 USD", - priceChangePercent = "2.0%", - type = PriceChangeType.UP, - ), - onItemClick = {}, - onItemLongClick = {}, - ) - } - - val tokenItemDragState by lazy { + private val tokenItemDragState by lazy { TokenItemState.Draggable( id = UUID.randomUUID().toString(), - iconState = tokenIconState, + iconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_polygon_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + showCustomBadge = false, + ), titleState = TokenItemState.TitleState.Content(text = "Polygon"), cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "3 172,14 $"), ) } - val tokenItemUnreachableState by lazy { - TokenItemState.Unreachable( - id = UUID.randomUUID().toString(), - iconState = tokenIconState, - titleState = TokenItemState.TitleState.Content(text = "Polygon"), - onItemClick = {}, - onItemLongClick = {}, - ) - } - - val tokenItemNoAddressState by lazy { - TokenItemState.NoAddress( - id = UUID.randomUUID().toString(), - iconState = tokenIconState, - titleState = TokenItemState.TitleState.Content(text = "Polygon"), - onItemLongClick = {}, - ) - } - - val customTokenItemVisibleState by lazy { - tokenItemVisibleState.copy( - titleState = TokenItemState.TitleState.Content(text = "Polygon"), - iconState = customTokenIconState.copy( - tint = TangemColorPalette.White, - background = TangemColorPalette.Black, - ), - ) - } - - val customTestnetTokenItemVisibleState by lazy { - tokenItemVisibleState.copy( - titleState = TokenItemState.TitleState.Content(text = "Polygon"), - iconState = customTokenIconState.copy(isGrayscale = true), - ) - } - - val loadingTokenItemState by lazy { - TokenItemState.Loading( - id = "Loading#1", - iconState = customTokenIconState.copy(isGrayscale = true), - titleState = TokenItemState.TitleState.Content(text = "Polygon"), - ) - } - private const val networksSize = 10 private const val tokensSize = 3 private val draggableItems by lazy { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/NetworkGroupItem.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/NetworkGroupItem.kt deleted file mode 100644 index 77f6e6160b..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/NetworkGroupItem.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.tangem.feature.wallet.presentation.common.component - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.wallet.impl.R -import org.burnoutcrew.reorderable.ReorderableLazyListState -import org.burnoutcrew.reorderable.detectReorder - -@Composable -internal fun NetworkGroupItem(networkName: String, modifier: Modifier = Modifier) { - InternalNetworkGroupItem(modifier = modifier, networkName = networkName) -} - -@Composable -internal fun DraggableNetworkGroupItem( - networkName: String, - modifier: Modifier = Modifier, - reorderableTokenListState: ReorderableLazyListState? = null, -) { - InternalNetworkGroupItem( - modifier = modifier, - networkName = networkName, - endIcon = { - Box( - modifier = Modifier - .size(TangemTheme.dimens.size32) - .let { - if (reorderableTokenListState != null) { - it.detectReorder(reorderableTokenListState) - } else { - it - } - }, - contentAlignment = Alignment.Center, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_group_drop_24), - tint = TangemTheme.colors.icon.informative, - contentDescription = null, - ) - } - }, - ) -} - -@Composable -private fun InternalNetworkGroupItem( - networkName: String, - modifier: Modifier = Modifier, - endIcon: @Composable RowScope.() -> Unit = {}, -) { - Column(modifier = modifier) { - Row( - modifier = Modifier - .background(TangemTheme.colors.background.primary) - .padding(horizontal = TangemTheme.dimens.spacing12) - .fillMaxWidth() - .heightIn(min = TangemTheme.dimens.size40), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(id = R.string.wallet_network_group_title, networkName), - modifier = Modifier.padding(top = TangemTheme.dimens.spacing12, bottom = TangemTheme.dimens.spacing8), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) - endIcon() - } - } -} - -// region Preview -@Composable -private fun NetworkGroupItemSample(isDraggable: Boolean) { - if (isDraggable) { - DraggableNetworkGroupItem(networkName = "Ethereum") - } else { - NetworkGroupItem(networkName = "Ethereum") - } -} - -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun NetworkGroupItemPreview(@PreviewParameter(NetworkGroupProvider::class) isDraggable: Boolean) { - TangemThemePreview { - NetworkGroupItemSample(isDraggable) - } -} - -private class NetworkGroupProvider : CollectionPreviewParameterProvider( - collection = listOf(true, false), -) -// endregion Preview \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/NetworkTitleItem.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/NetworkTitleItem.kt new file mode 100644 index 0000000000..5bd9e5e129 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/NetworkTitleItem.kt @@ -0,0 +1,103 @@ +package com.tangem.feature.wallet.presentation.common.component + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.size +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.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +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.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.rows.NetworkTitle +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.feature.wallet.impl.R +import org.burnoutcrew.reorderable.ReorderableLazyListState +import org.burnoutcrew.reorderable.detectReorder +import org.burnoutcrew.reorderable.rememberReorderableLazyListState + +@Composable +internal fun NetworkTitleItem(networkName: String, modifier: Modifier = Modifier) { + BaseNetworkTitleItem(networkName = networkName, modifier = modifier) +} + +@Composable +internal fun DraggableNetworkTitleItem( + networkName: String, + reorderableTokenListState: ReorderableLazyListState, + modifier: Modifier = Modifier, +) { + BaseNetworkTitleItem( + networkName = networkName, + modifier = modifier, + action = { DraggableIcon(reorderableTokenListState = reorderableTokenListState) }, + ) +} + +@Composable +private fun BaseNetworkTitleItem( + networkName: String, + modifier: Modifier = Modifier, + action: (@Composable BoxScope.() -> Unit)? = null, +) { + NetworkTitle( + title = { + Text( + text = stringResource(id = R.string.wallet_network_group_title, networkName), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + }, + modifier = modifier, + action = action, + ) +} + +@Composable +private fun DraggableIcon(reorderableTokenListState: ReorderableLazyListState) { + Box( + modifier = Modifier + .size(TangemTheme.dimens.size32) + .detectReorder(reorderableTokenListState), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = rememberVectorPainter( + image = ImageVector.vectorResource(id = R.drawable.ic_group_drop_24), + ), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun NetworkTitleItemPreview(@PreviewParameter(NetworkTitleItemProvider::class) isDraggable: Boolean) { + TangemThemePreview { + if (isDraggable) { + DraggableNetworkTitleItem( + networkName = "Ethereum", + reorderableTokenListState = rememberReorderableLazyListState(onMove = { _, _ -> }), + modifier = Modifier.background(color = TangemTheme.colors.background.primary), + ) + } else { + NetworkTitleItem( + networkName = "Ethereum", + modifier = Modifier.background(color = TangemTheme.colors.background.primary), + ) + } + } +} + +private object NetworkTitleItemProvider : CollectionPreviewParameterProvider(collection = listOf(true, false)) \ No newline at end of file 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 635afd0fc9..5bd058642b 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 @@ -2,14 +2,15 @@ package com.tangem.feature.wallet.presentation.common.preview import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.common.WalletPreviewData.topBarConfig -import com.tangem.feature.wallet.presentation.common.state.TokenItemState import com.tangem.feature.wallet.presentation.wallet.state.model.* import kotlinx.collections.immutable.persistentListOf @@ -20,7 +21,7 @@ internal object WalletScreenPreviewData { titleState = TokenItemState.TitleState.Content(text = "Bitcoin"), fiatAmountState = TokenItemState.FiatAmountState.Content(text = "12 368,14 \$"), cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "0,35853044 BTC"), - cryptoPriceState = TokenItemState.CryptoPriceState.Content( + subtitleState = TokenItemState.SubtitleState.CryptoPriceContent( price = "34 496,75 \$", priceChangePercent = "0,43 %", type = PriceChangeType.DOWN, @@ -46,7 +47,7 @@ internal object WalletScreenPreviewData { titleState = TokenItemState.TitleState.Content(text = "Ethereum"), fiatAmountState = TokenItemState.FiatAmountState.Content(text = "3 340,79 \$"), cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "1,856660295 ETH"), - cryptoPriceState = TokenItemState.CryptoPriceState.Content( + subtitleState = TokenItemState.SubtitleState.CryptoPriceContent( price = "1 799,41 \$", priceChangePercent = "5,16 %", type = PriceChangeType.UP, @@ -68,7 +69,7 @@ internal object WalletScreenPreviewData { titleState = TokenItemState.TitleState.Content(text = "Shiba Inu"), fiatAmountState = TokenItemState.FiatAmountState.Content(text = "48,64 \$"), cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "6 200 220,00 SHIB"), - cryptoPriceState = TokenItemState.CryptoPriceState.Content( + subtitleState = TokenItemState.SubtitleState.CryptoPriceContent( price = "0.01 \$", priceChangePercent = "1,34 %", type = PriceChangeType.DOWN, @@ -112,7 +113,7 @@ internal object WalletScreenPreviewData { } private val multiWalletState by lazy { WalletState.MultiCurrency.Content( - pullToRefreshConfig = WalletPullToRefreshConfig( + pullToRefreshConfig = PullToRefreshConfig( isRefreshing = false, onRefresh = {}, ), @@ -158,5 +159,7 @@ internal object WalletScreenPreviewData { onWalletChange = {}, event = consumedEvent(), isHidingMode = false, + showMarketsOnboarding = false, + onDismissMarketsOnboarding = {}, ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/state/TokenItemState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/state/TokenItemState.kt deleted file mode 100644 index 2ece6053fc..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/state/TokenItemState.kt +++ /dev/null @@ -1,167 +0,0 @@ -package com.tangem.feature.wallet.presentation.common.state - -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.marketprice.PriceChangeType - -/** Token item state */ -@Immutable -internal sealed class TokenItemState { - - abstract val id: String - - abstract val iconState: CurrencyIconState - - abstract val titleState: TitleState - - abstract val fiatAmountState: FiatAmountState? - - abstract val cryptoAmountState: CryptoAmountState? - - abstract val cryptoPriceState: CryptoPriceState? - - /** Loading token state */ - data class Loading( - override val id: String, - override val iconState: CurrencyIconState, - override val titleState: TitleState.Content, - ) : TokenItemState() { - override val fiatAmountState: FiatAmountState = FiatAmountState.Loading - override val cryptoAmountState: CryptoAmountState = CryptoAmountState.Loading - override val cryptoPriceState: CryptoPriceState = CryptoPriceState.Loading - } - - /** Locked token state */ - data class Locked(override val id: String) : TokenItemState() { - override val iconState: CurrencyIconState = CurrencyIconState.Locked - override val titleState: TitleState = TitleState.Locked - override val fiatAmountState: FiatAmountState = FiatAmountState.Locked - override val cryptoAmountState: CryptoAmountState = CryptoAmountState.Locked - override val cryptoPriceState: CryptoPriceState = CryptoPriceState.Locked - } - - /** - * Content token state - * - * @property id unique id - * @property iconState token icon state - * @property titleState token name - * @property onItemClick callback which will be called when an item is clicked - * @property onItemLongClick callback which will be called when an item is long clicked - */ - data class Content( - override val id: String, - override val iconState: CurrencyIconState, - override val titleState: TitleState, - override val fiatAmountState: FiatAmountState, - override val cryptoAmountState: CryptoAmountState.Content, - override val cryptoPriceState: CryptoPriceState, - val onItemClick: () -> Unit, - val onItemLongClick: () -> Unit, - ) : TokenItemState() - - /** - * Draggable token state - * - * @property id unique id - * @property iconState token icon state - * @property titleState token name - */ - data class Draggable( - override val id: String, - override val iconState: CurrencyIconState, - override val titleState: TitleState, - override val cryptoAmountState: CryptoAmountState, - ) : TokenItemState() { - override val fiatAmountState: FiatAmountState? = null - override val cryptoPriceState: CryptoPriceState? = null - } - - /** - * Unreachable token state - * - * @property id token id - * @property iconState token icon state - * @property titleState token name - * @property onItemClick callback which will be called when an item is clicked - * @property onItemLongClick callback which will be called when an item is long clicked - */ - data class Unreachable( - override val id: String, - override val iconState: CurrencyIconState, - override val titleState: TitleState, - val onItemClick: () -> Unit, - val onItemLongClick: () -> Unit, - ) : TokenItemState() { - override val fiatAmountState: FiatAmountState? = null - override val cryptoAmountState: CryptoAmountState? = null - override val cryptoPriceState: CryptoPriceState? = null - } - - /** - * No derivation address state - * - * @property id token id - * @property iconState token icon state - * @property titleState token name - * @property onItemLongClick callback which will be called when an item is long clicked - */ - data class NoAddress( - override val id: String, - override val iconState: CurrencyIconState, - override val titleState: TitleState, - val onItemLongClick: () -> Unit, - ) : TokenItemState() { - override val fiatAmountState: FiatAmountState? = null - override val cryptoAmountState: CryptoAmountState? = null - override val cryptoPriceState: CryptoPriceState? = null - } - - @Immutable - sealed class TitleState { - - data class Content(val text: String, val hasPending: Boolean = false) : TitleState() - - object Loading : TitleState() - - object Locked : TitleState() - } - - @Immutable - sealed class FiatAmountState { - data class Content( - val text: String, - val hasStaked: Boolean = false, - ) : FiatAmountState() - - object Loading : FiatAmountState() - - object Locked : FiatAmountState() - } - - @Immutable - sealed class CryptoAmountState { - data class Content(val text: String) : CryptoAmountState() - - object Unreachable : CryptoAmountState() - - object Loading : CryptoAmountState() - - object Locked : CryptoAmountState() - } - - sealed class CryptoPriceState { - - data class Content( - val price: String, - val priceChangePercent: String?, - val type: PriceChangeType?, - ) : CryptoPriceState() - - object Unknown : CryptoPriceState() - - object Loading : CryptoPriceState() - - object Locked : CryptoPriceState() - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensScreen.kt index 6c3aea984f..17dca9d4fe 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.AppBarDefaults import androidx.compose.material3.FabPosition import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -32,19 +31,19 @@ import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.components.buttons.actions.RoundedActionButton +import com.tangem.core.ui.components.token.TokenItem import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.reordarable.ReorderableItem import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.common.WalletPreviewData -import com.tangem.feature.wallet.presentation.common.component.DraggableNetworkGroupItem -import com.tangem.feature.wallet.presentation.common.component.TokenItem +import com.tangem.feature.wallet.presentation.common.component.DraggableNetworkTitleItem import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensListState import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensState -import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.ReorderableLazyListState import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.reorderable @@ -174,14 +173,16 @@ private fun LazyItemScope.DraggableItem( ) { isItemDragging -> isDragging = isItemDragging + val modifierWithBackground = itemModifier.background(color = TangemTheme.colors.background.primary) + when (item) { - is DraggableItem.GroupHeader -> DraggableNetworkGroupItem( - modifier = itemModifier, + is DraggableItem.GroupHeader -> DraggableNetworkTitleItem( + modifier = modifierWithBackground, networkName = item.networkName, reorderableTokenListState = reorderableState, ) is DraggableItem.Token -> TokenItem( - modifier = itemModifier, + modifier = modifierWithBackground, state = item.tokenItemState, reorderableTokenListState = reorderableState, isBalanceHidden = isBalanceHidden, @@ -212,7 +213,7 @@ private fun TopBar( } } val elevation by animateDpAsState( - targetValue = if (isElevationEnabled) AppBarDefaults.TopAppBarElevation else TangemTheme.dimens.elevation0, + targetValue = if (isElevationEnabled) TangemTheme.dimens.elevation4 else TangemTheme.dimens.elevation0, label = "top_bar_shadow_elevation", ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/model/DraggableItem.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/model/DraggableItem.kt index f7c411a377..37387e16c8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/model/DraggableItem.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/model/DraggableItem.kt @@ -1,7 +1,7 @@ package com.tangem.feature.wallet.presentation.organizetokens.model import androidx.compose.runtime.Immutable -import com.tangem.feature.wallet.presentation.common.state.TokenItemState +import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem.RoundingMode /** diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/converter/items/CryptoCurrencyToDraggableItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/converter/items/CryptoCurrencyToDraggableItemConverter.kt index 93f8267b5e..73eaaeb43b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/converter/items/CryptoCurrencyToDraggableItemConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/converter/items/CryptoCurrencyToDraggableItemConverter.kt @@ -1,10 +1,10 @@ package com.tangem.feature.wallet.presentation.organizetokens.utils.converter.items import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.feature.wallet.presentation.common.state.TokenItemState import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem import com.tangem.feature.wallet.presentation.organizetokens.utils.common.getGroupHeaderId import com.tangem.feature.wallet.presentation.organizetokens.utils.common.getTokenItemId 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 a70acf53cd..421f33f996 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 @@ -25,7 +25,7 @@ import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensScree import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensViewModel import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletViewModel -import com.tangem.features.markets.component.MarketsEntryComponent +import com.tangem.features.markets.entry.MarketsEntryComponent import kotlin.properties.Delegates /** Default implementation of wallet feature router */ @@ -139,8 +139,8 @@ internal class DefaultWalletRouter( return router.stack.lastOrNull() is AppRoute.Wallet } - override fun openManageTokensScreen() { - router.push(AppRoute.ManageTokens(readOnlyContent = false)) + override fun openManageTokensScreen(userWalletId: UserWalletId) { + router.push(AppRoute.ManageTokens(userWalletId = userWalletId)) } override fun openScanFailedDialog(onTryAgain: () -> Unit) { 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 ee4229c970..40ebf62fed 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 @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.features.markets.component.MarketsEntryComponent +import com.tangem.features.markets.entry.MarketsEntryComponent import com.tangem.features.wallet.navigation.WalletRouter /** @@ -54,7 +54,7 @@ internal interface InnerWalletRouter : WalletRouter { fun isWalletLastScreen(): Boolean /** Open manage tokens screen */ - fun openManageTokensScreen() + fun openManageTokensScreen(userWalletId: UserWalletId) /** Open scan failed dialog */ fun openScanFailedDialog(onTryAgain: () -> Unit) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt index de6ddf8b1b..c0021f5d7e 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 @@ -43,7 +43,7 @@ sealed class WalletScreenAnalyticsEvent { event = "Token Balance", params = mapOf( AnalyticsParam.STATE to balance.value, - AnalyticsParam.TOKEN to token, + AnalyticsParam.TOKEN_PARAM to token, ), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/NoteImage.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/NoteImage.kt new file mode 100644 index 0000000000..44d5b5b268 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/NoteImage.kt @@ -0,0 +1,31 @@ +package com.tangem.feature.wallet.presentation.wallet.domain + +import androidx.annotation.DrawableRes +import com.tangem.blockchain.common.Blockchain +import com.tangem.feature.wallet.impl.R + +/** + * Note image model + * + * @property blockchain blockchain + * @property imageResId image res id + * +[REDACTED_AUTHOR] + */ +internal enum class NoteImage( + val blockchain: Blockchain, + @DrawableRes val imageResId: Int, +) { + + Bitcoin(blockchain = Blockchain.Bitcoin, imageResId = R.drawable.ill_note_btc_120_106), + + Ethereum(blockchain = Blockchain.Ethereum, imageResId = R.drawable.ill_note_ethereum_120_106), + + Binance(blockchain = Blockchain.BSC, imageResId = R.drawable.ill_note_binance_120_106), + + Dogecoin(blockchain = Blockchain.Dogecoin, imageResId = R.drawable.ill_note_doge_120_106), + + Cardano(blockchain = Blockchain.Cardano, imageResId = R.drawable.ill_note_cardano_120_106), + + XRP(blockchain = Blockchain.XRP, imageResId = R.drawable.ill_note_xrp_120_106), +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/UserWalletExt.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/UserWalletExt.kt deleted file mode 100644 index a78ded4bf0..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/UserWalletExt.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.domain - -import com.tangem.domain.models.scan.CardDTO -import com.tangem.domain.wallets.models.UserWallet - -fun UserWallet.getCardsCount(): Int? { - return if (isMultiCurrency) { - when (val status = scanResponse.card.backupStatus) { - is CardDTO.BackupStatus.Active -> status.cardCount + 1 - is CardDTO.BackupStatus.NoBackup, - is CardDTO.BackupStatus.CardLinked, - -> 1 - null -> 1 // Multi-currency wallet without backup function. Example, 4.12 - } - } else { - null - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt new file mode 100644 index 0000000000..82d07f7000 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt @@ -0,0 +1,161 @@ +package com.tangem.feature.wallet.presentation.wallet.domain + +import androidx.annotation.DrawableRes +import com.tangem.feature.wallet.impl.R + +/** + * Wallet2 cobrand image. + * To integrate a new cobrand, just implement new enum object and that all. + * + * @property cards2ResId image resource id for wallet set with 2 cards + * @property cards3ResId image resource id for wallet set with 3 cards + * @property batchIds set of unique batch ids for this cobrand + * +[REDACTED_AUTHOR] + */ +internal enum class Wallet2CobrandImage( + @DrawableRes val cards2ResId: Int, + @DrawableRes val cards3ResId: Int, + val batchIds: Set, +) { + + Avrora( + cards2ResId = R.drawable.ill_avrora_card2_120_106, + cards3ResId = R.drawable.ill_avrora_card3_120_106, + batchIds = setOf("AF18"), + ), + + BabyDoge( + cards2ResId = R.drawable.ill_baby_doge_card2_120_106, + cards3ResId = R.drawable.ill_baby_doge_card3_120_106, + batchIds = setOf("AF51"), + ), + + Bad( + cards2ResId = R.drawable.ill_bad_card2_120_106, + cards3ResId = R.drawable.ill_bad_card3_120_106, + batchIds = setOf("AF09"), + ), + + BitcoinPizzaDay( + cards2ResId = R.drawable.ill_pizza_day_card2_120_106, + cards3ResId = R.drawable.ill_pizza_day_card3_120_106, + batchIds = setOf("AF33"), + ), + + CoinMetrica( + cards2ResId = R.drawable.ill_coin_metrica_card2_120_106, + cards3ResId = R.drawable.ill_coin_metrica_card3_120_106, + batchIds = setOf("AF27"), + ), + + COQ( + cards2ResId = R.drawable.ill_coq_card2_120_106, + cards3ResId = R.drawable.ill_coq_card3_120_106, + batchIds = setOf("AF28"), + ), + + CryptoSeth( + cards2ResId = R.drawable.ill_crypto_seth_card2_120_106, + cards3ResId = R.drawable.ill_crypto_seth_card3_120_106, + batchIds = setOf("AF32"), + ), + + Grim( + cards2ResId = R.drawable.ill_grim_card2_120_106, + cards3ResId = R.drawable.ill_grim_card3_120_106, + batchIds = setOf("AF13"), + ), + + Jr( + cards2ResId = R.drawable.ill_jr_card2_120_106, + cards3ResId = R.drawable.ill_jr_card3_120_106, + batchIds = setOf("AF14"), + ), + + Kaspa( + cards2ResId = R.drawable.ill_kaspa_card2_120_106, + cards3ResId = R.drawable.ill_kaspa_card3_120_106, + batchIds = setOf("AF08"), + ), + + Kaspa2( + cards2ResId = R.drawable.ill_kaspa2_card2_120_106, + cards3ResId = R.drawable.ill_kaspa2_card3_120_106, + batchIds = setOf("AF25"), + ), + + KaspaReseller( + cards2ResId = R.drawable.ill_kaspa_reseller_card2_120_106, + cards3ResId = R.drawable.ill_kaspa_reseller_card3_120_106, + batchIds = setOf("AF31"), + ), + + KishuInu( + cards2ResId = R.drawable.ill_kishu_inu_card2_120_106, + cards3ResId = R.drawable.ill_kishu_inu_card3_120_106, + batchIds = setOf("AF52"), + ), + + NewWorldElite( + cards2ResId = R.drawable.ill_nwe_card2_120_106, + cards3ResId = R.drawable.ill_nwe_card3_120_106, + batchIds = setOf("AF26"), + ), + + // for multicolored cards use image of 3 cards in all cases + Pastel( + cards2ResId = R.drawable.ill_pastel_cards3_120_106, + cards3ResId = R.drawable.ill_pastel_cards3_120_106, + batchIds = setOf("AF43", "AF44", "AF45"), + ), + + RedPanda( + cards2ResId = R.drawable.ill_red_panda_card2_120_106, + cards3ResId = R.drawable.ill_red_panda_card3_120_106, + batchIds = setOf("AF34"), + ), + + SatoshiFriends( + cards2ResId = R.drawable.ill_satoshi_card2_120_106, + cards3ResId = R.drawable.ill_satoshi_card3_120_106, + batchIds = setOf("AF19"), + ), + + Trillant( + cards2ResId = R.drawable.ill_trillant_card2_120_106, + cards3ResId = R.drawable.ill_trillant_card3_120_106, + batchIds = setOf("AF16"), + ), + + Tron( + cards2ResId = R.drawable.ill_tron_card2_120_106, + cards3ResId = R.drawable.ill_tron_card3_120_106, + batchIds = setOf("AF07"), + ), + + VeChain( + cards2ResId = R.drawable.ill_vechain_card2_120_106, + cards3ResId = R.drawable.ill_vechain_card3_120_106, + batchIds = setOf("AF29"), + ), + + // for multicolored cards use image of 3 cards in all cases + Vivid( + cards2ResId = R.drawable.ill_vivid_cards3_120_106, + cards3ResId = R.drawable.ill_vivid_cards3_120_106, + batchIds = setOf("AF40", "AF41", "AF42"), + ), + + VoltInu( + cards2ResId = R.drawable.ill_volt_inu_card2_120_106, + cards3ResId = R.drawable.ill_volt_inu_card3_120_106, + batchIds = setOf("AF35"), + ), + + WhiteTangem( + cards2ResId = R.drawable.ill_white_card2_120_106, + cards3ResId = R.drawable.ill_white_card3_120_106, + batchIds = setOf("AF15"), + ), +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletAdditionalInfoFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletAdditionalInfoFactory.kt index 1f34e460f2..3de82a88d3 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletAdditionalInfoFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletAdditionalInfoFactory.kt @@ -6,6 +6,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.common.util.getCardsCount import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletImageResolver.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletImageResolver.kt index 722f7de8f5..711cdaac37 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletImageResolver.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletImageResolver.kt @@ -1,8 +1,8 @@ package com.tangem.feature.wallet.presentation.wallet.domain import androidx.annotation.DrawableRes -import com.tangem.blockchain.common.Blockchain import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.common.util.getCardsCount import com.tangem.domain.demo.DemoConfig import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.wallet.impl.R @@ -12,7 +12,6 @@ import com.tangem.feature.wallet.impl.R * [REDACTED_AUTHOR] */ -// TODO: make flexible to integrate cobrands ([REDACTED_JIRA]) internal object WalletImageResolver { private const val WALLET_WITHOUT_BACKUP_COUNT = 1 @@ -24,42 +23,42 @@ internal object WalletImageResolver { @DrawableRes fun resolve(userWallet: UserWallet): Int? { val cardTypesResolver = userWallet.scanResponse.cardTypesResolver + + val cobrandImage = Wallet2CobrandImage.entries.firstOrNull { + it.batchIds.contains(userWallet.scanResponse.card.batchId) + } + + val noteImage by lazy { + val noteBlockchain = cardTypesResolver.getBlockchain() + + NoteImage.entries.firstOrNull { it.blockchain == noteBlockchain } + } + return when { cardTypesResolver.isDevKit() -> R.drawable.ill_dev_120_106 - cardTypesResolver.isWhiteWallet2() -> userWallet.resolveWhiteWallet2() - cardTypesResolver.isAvroraWallet() -> userWallet.resolveAvroraWallet() - cardTypesResolver.isTraillantWallet() -> userWallet.resolveTraillantWallet() - cardTypesResolver.isTronWallet() -> userWallet.resolveTronWallet() - cardTypesResolver.isKaspaWallet() -> userWallet.resolveKaspaWallet() - cardTypesResolver.isKaspa2Wallet() -> userWallet.resolveKaspa2Wallet() - cardTypesResolver.isKaspaResellerWallet() -> userWallet.resolveKaspaResellerWallet() - cardTypesResolver.isBadWallet() -> userWallet.resolveBadWallet() - cardTypesResolver.isJrWallet() -> userWallet.resolveJrWallet() - cardTypesResolver.isGrimWallet() -> userWallet.resolveGrimWallet() - cardTypesResolver.isSatoshiFriendsWallet() -> userWallet.resolveSatoshiWallet() - cardTypesResolver.isBitcoinPizzaDayWallet() -> userWallet.resolveBitcoinPizzaDayWallet() - cardTypesResolver.isVeChainWallet() -> userWallet.resolveVeChainWallet() - cardTypesResolver.isNewWorldEliteWallet() -> userWallet.resolveNewWorldEliteWallet() - cardTypesResolver.isRedPandaWallet() -> userWallet.resolveRedPandaWallet() - cardTypesResolver.isCryptoSethWallet() -> userWallet.resolveCryptoSethWallet() - cardTypesResolver.isKishuInuWallet() -> userWallet.resolveKishuInuWallet() - cardTypesResolver.isBabyDogeWallet() -> userWallet.resolveBabyDogeWallet() - cardTypesResolver.isCOQWallet() -> userWallet.resolveCOQWallet() - cardTypesResolver.isCoinMetricaWallet() -> userWallet.resolveCoinMetricaWallet() - cardTypesResolver.isVoltInuWallet() -> userWallet.resolveVoltInuWallet() - cardTypesResolver.isVividWallet() -> userWallet.resolveVividWallet() - cardTypesResolver.isPastelWallet() -> userWallet.resolvePastelWallet() + cobrandImage != null -> userWallet.resolveWallet2Cobrand(image = cobrandImage) cardTypesResolver.isWallet2() -> userWallet.resolveWallet2() cardTypesResolver.isShibaWallet() -> userWallet.resolveShibaWallet() cardTypesResolver.isTangemWallet() -> userWallet.resolveWallet1() cardTypesResolver.isWhiteWallet() -> R.drawable.ill_wallet_old_white_120_106 cardTypesResolver.isTangemTwins() -> R.drawable.ill_twins_120_106 cardTypesResolver.isStart2Coin() -> R.drawable.ill_start2coin_120_106 - cardTypesResolver.isTangemNote() -> resolveNote(blockchain = cardTypesResolver.getBlockchain()) + cardTypesResolver.isTangemNote() -> noteImage?.imageResId else -> null } } + private fun UserWallet.resolveWallet2Cobrand(image: Wallet2CobrandImage): Int? { + return resolveWallet2(oneBackupResId = image.cards2ResId, twoBackupResId = image.cards3ResId) + } + + private fun UserWallet.resolveShibaWallet(): Int? { + return resolveWallet2( + oneBackupResId = R.drawable.ill_shiba_card2_120_106, + twoBackupResId = R.drawable.ill_shiba_card3_120_106, + ) + } + private fun UserWallet.resolveWallet2( @DrawableRes oneBackupResId: Int = R.drawable.ill_wallet2_cards2_120_106, @DrawableRes twoBackupResId: Int = R.drawable.ill_wallet2_cards3_120_106, @@ -75,176 +74,6 @@ internal object WalletImageResolver { } } - private fun UserWallet.resolveTronWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_tron_card2_120_106, - twoBackupResId = R.drawable.ill_tron_card3_120_106, - ) - } - - private fun UserWallet.resolveKaspaWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_kaspa_card2_120_106, - twoBackupResId = R.drawable.ill_kaspa_card3_120_106, - ) - } - - private fun UserWallet.resolveKaspa2Wallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_kaspa2_card2_120_106, - twoBackupResId = R.drawable.ill_kaspa2_card3_120_106, - ) - } - - private fun UserWallet.resolveKaspaResellerWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_kaspa_reseller_card2_120_106, - twoBackupResId = R.drawable.ill_kaspa_reseller_card3_120_106, - ) - } - - private fun UserWallet.resolveBadWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_bad_card2_120_106, - twoBackupResId = R.drawable.ill_bad_card3_120_106, - ) - } - - private fun UserWallet.resolveJrWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_jr_card2_120_106, - twoBackupResId = R.drawable.ill_jr_card3_120_106, - ) - } - - private fun UserWallet.resolveGrimWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_grim_card2_120_106, - twoBackupResId = R.drawable.ill_grim_card3_120_106, - ) - } - - private fun UserWallet.resolveSatoshiWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_satoshi_card2_120_106, - twoBackupResId = R.drawable.ill_satoshi_card3_120_106, - ) - } - - private fun UserWallet.resolveShibaWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_shiba_card2_120_106, - twoBackupResId = R.drawable.ill_shiba_card3_120_106, - ) - } - - private fun UserWallet.resolveWhiteWallet2(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_white_card2_120_106, - twoBackupResId = R.drawable.ill_white_card3_120_106, - ) - } - - private fun UserWallet.resolveAvroraWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_avrora_card2_120_106, - twoBackupResId = R.drawable.ill_avrora_card3_120_106, - ) - } - - private fun UserWallet.resolveTraillantWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_traillant_card2_120_106, - twoBackupResId = R.drawable.ill_traillant_card3_120_106, - ) - } - - private fun UserWallet.resolveBitcoinPizzaDayWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_pizza_day_card2_120_106, - twoBackupResId = R.drawable.ill_pizza_day_card3_120_106, - ) - } - - private fun UserWallet.resolveVeChainWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_vechain_card2_120_106, - twoBackupResId = R.drawable.ill_vechain_card3_120_106, - ) - } - - private fun UserWallet.resolveNewWorldEliteWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_nwe_card2_120_106, - twoBackupResId = R.drawable.ill_nwe_card3_120_106, - ) - } - - private fun UserWallet.resolveRedPandaWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_red_panda_card2_120_106, - twoBackupResId = R.drawable.ill_red_panda_card3_120_106, - ) - } - - private fun UserWallet.resolveCryptoSethWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_crypto_seth_card2_120_106, - twoBackupResId = R.drawable.ill_crypto_seth_card3_120_106, - ) - } - - private fun UserWallet.resolveKishuInuWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_kishu_inu_card2_120_106, - twoBackupResId = R.drawable.ill_kishu_inu_card3_120_106, - ) - } - - private fun UserWallet.resolveBabyDogeWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_baby_doge_card2_120_106, - twoBackupResId = R.drawable.ill_baby_doge_card3_120_106, - ) - } - - private fun UserWallet.resolveCOQWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_coq_card2_120_106, - twoBackupResId = R.drawable.ill_coq_card3_120_106, - ) - } - - private fun UserWallet.resolveCoinMetricaWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_coin_metrica_card2_120_106, - twoBackupResId = R.drawable.ill_coin_metrica_card3_120_106, - ) - } - - private fun UserWallet.resolveVoltInuWallet(): Int? { - return resolveWallet2( - oneBackupResId = R.drawable.ill_volt_inu_card2_120_106, - twoBackupResId = R.drawable.ill_volt_inu_card3_120_106, - ) - } - - private fun UserWallet.resolveVividWallet(): Int? { - // for multicolored cards use image of 3 cards in all cases - return resolveWallet2( - oneBackupResId = R.drawable.ill_vivid_cards3_120_106, - twoBackupResId = R.drawable.ill_vivid_cards3_120_106, - ) - } - - private fun UserWallet.resolvePastelWallet(): Int? { - // for multicolored cards use image of 3 cards in all cases - return resolveWallet2( - oneBackupResId = R.drawable.ill_pastel_cards3_120_106, - twoBackupResId = R.drawable.ill_pastel_cards3_120_106, - ) - } - private fun UserWallet.resolveWallet1(): Int? { return resolveWalletWithBackups { count -> when (count) { @@ -261,16 +90,4 @@ internal object WalletImageResolver { return if (count != null) resolve(count) else null } - - private fun resolveNote(blockchain: Blockchain): Int? { - return when (blockchain) { - Blockchain.Bitcoin -> R.drawable.ill_note_btc_120_106 - Blockchain.Ethereum -> R.drawable.ill_note_ethereum_120_106 - Blockchain.BSC -> R.drawable.ill_note_binance_120_106 - Blockchain.Dogecoin -> R.drawable.ill_note_doge_120_106 - Blockchain.Cardano -> R.drawable.ill_note_cardano_120_106 - Blockchain.XRP -> R.drawable.ill_note_xrp_120_106 - else -> null - } - } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoader.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoader.kt index 655c6255a3..f3829783da 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoader.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoader.kt @@ -1,7 +1,7 @@ package com.tangem.feature.wallet.presentation.wallet.loaders.implementors import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase -import com.tangem.domain.tokens.GetCardTokensListUseCase +import com.tangem.domain.tokens.GetNodlTokenListUseCase import com.tangem.domain.tokens.RunPolkadotAccountHealthCheckUseCase import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender @@ -23,7 +23,7 @@ internal class SingleWalletWithTokenContentLoader( private val walletWarningsAnalyticsSender: WalletWarningsAnalyticsSender, private val walletWithFundsChecker: WalletWithFundsChecker, private val getMultiWalletWarningsFactory: GetMultiWalletWarningsFactory, - private val getCardTokensListUseCase: GetCardTokensListUseCase, + private val getNodlTokenListUseCase: GetNodlTokenListUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val runPolkadotAccountHealthCheckUseCase: RunPolkadotAccountHealthCheckUseCase, ) : WalletContentLoader(id = userWallet.walletId) { @@ -36,7 +36,7 @@ internal class SingleWalletWithTokenContentLoader( clickIntents = clickIntents, tokenListAnalyticsSender = tokenListAnalyticsSender, walletWithFundsChecker = walletWithFundsChecker, - getCardTokensListUseCase = getCardTokensListUseCase, + getNodlTokenListUseCase = getNodlTokenListUseCase, getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, runPolkadotAccountHealthCheckUseCase = runPolkadotAccountHealthCheckUseCase, ), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoaderFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoaderFactory.kt index e00b6fe74c..5d820846c7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoaderFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoaderFactory.kt @@ -1,7 +1,7 @@ package com.tangem.feature.wallet.presentation.wallet.loaders.implementors import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase -import com.tangem.domain.tokens.GetCardTokensListUseCase +import com.tangem.domain.tokens.GetNodlTokenListUseCase import com.tangem.domain.tokens.RunPolkadotAccountHealthCheckUseCase import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender @@ -19,7 +19,7 @@ internal class SingleWalletWithTokenContentLoaderFactory @Inject constructor( private val tokenListAnalyticsSender: TokenListAnalyticsSender, private val walletWithFundsChecker: WalletWithFundsChecker, private val getMultiWalletWarningsFactory: GetMultiWalletWarningsFactory, - private val getCardTokensListUseCase: GetCardTokensListUseCase, + private val getNodlTokenListUseCase: GetNodlTokenListUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val walletWarningsAnalyticsSender: WalletWarningsAnalyticsSender, private val runPolkadotAccountHealthCheckUseCase: RunPolkadotAccountHealthCheckUseCase, @@ -33,7 +33,7 @@ internal class SingleWalletWithTokenContentLoaderFactory @Inject constructor( tokenListAnalyticsSender = tokenListAnalyticsSender, walletWithFundsChecker = walletWithFundsChecker, getMultiWalletWarningsFactory = getMultiWalletWarningsFactory, - getCardTokensListUseCase = getCardTokensListUseCase, + getNodlTokenListUseCase = getNodlTokenListUseCase, getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, walletWarningsAnalyticsSender = walletWarningsAnalyticsSender, runPolkadotAccountHealthCheckUseCase = runPolkadotAccountHealthCheckUseCase, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt index a54ca605c0..6dc50ed38f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt @@ -94,6 +94,8 @@ internal class WalletStateController @Inject constructor() { onWalletChange = {}, event = consumedEvent(), isHidingMode = false, + showMarketsOnboarding = false, + onDismissMarketsOnboarding = {}, ) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletCardState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletCardState.kt index d2ffe9d0d1..69b9b48fb1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletCardState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletCardState.kt @@ -4,7 +4,6 @@ import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns.DASH_SIGN /** Wallet card state */ @@ -124,7 +123,6 @@ internal sealed interface WalletCardState { } companion object { - val HIDDEN_BALANCE_TEXT by lazy { TextReference.Str(value = StringsSigns.STARS) } val EMPTY_BALANCE_TEXT by lazy { TextReference.Str(value = DASH_SIGN) } } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletPullToRefreshConfig.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletPullToRefreshConfig.kt deleted file mode 100644 index 8bf0e4ea63..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletPullToRefreshConfig.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.model - -/** - * Wallet screen top bar config - * - * @property isRefreshing state is indicator visible - * @property onRefresh lambda be invoked when pulled to refresh - */ -data class WalletPullToRefreshConfig(val isRefreshing: Boolean, val onRefresh: (ShowRefreshState) -> Unit) { - - @JvmInline - value class ShowRefreshState( - val value: Boolean, - ) -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt index 160e0d11da..ce541eaaa0 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt @@ -13,4 +13,6 @@ internal data class WalletScreenState( val onWalletChange: (Int) -> Unit, val event: StateEvent, val isHidingMode: Boolean, + val showMarketsOnboarding: Boolean, + val onDismissMarketsOnboarding: () -> Unit, ) \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt index 81c9017dee..0f48485b21 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.transactions.state.TxHistoryState +import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig import com.tangem.feature.wallet.presentation.wallet.state.model.holder.LockedTxHistoryStateHolder import com.tangem.feature.wallet.presentation.wallet.state.model.holder.LockedWalletStateHolder import com.tangem.feature.wallet.presentation.wallet.state.model.holder.TxHistoryStateHolder @@ -22,7 +23,7 @@ internal sealed interface WalletState : WalletStateHolder { abstract val manageTokensButtonConfig: ManageTokensButtonConfig? data class Content( - override val pullToRefreshConfig: WalletPullToRefreshConfig, + override val pullToRefreshConfig: PullToRefreshConfig, override val walletCardState: WalletCardState, override val warnings: ImmutableList, override val bottomSheetConfig: TangemBottomSheetConfig?, @@ -52,7 +53,7 @@ internal sealed interface WalletState : WalletStateHolder { abstract val marketPriceBlockState: MarketPriceBlockState? data class Content( - override val pullToRefreshConfig: WalletPullToRefreshConfig, + override val pullToRefreshConfig: PullToRefreshConfig, override val walletCardState: WalletCardState, override val warnings: ImmutableList, override val bottomSheetConfig: TangemBottomSheetConfig?, @@ -84,7 +85,7 @@ internal sealed interface WalletState : WalletStateHolder { abstract val balancesAndLimitBlockState: BalancesAndLimitsBlockState? data class Content( - override val pullToRefreshConfig: WalletPullToRefreshConfig, + override val pullToRefreshConfig: PullToRefreshConfig, override val walletCardState: WalletCardState, override val warnings: ImmutableList, override val bottomSheetConfig: TangemBottomSheetConfig?, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt index 62f9ca68a1..c582f8d8d8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt @@ -1,9 +1,9 @@ package com.tangem.feature.wallet.presentation.wallet.state.model import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference import com.tangem.feature.wallet.impl.R -import com.tangem.feature.wallet.presentation.common.state.TokenItemState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/holder/LockedWalletStateHolder.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/holder/LockedWalletStateHolder.kt index 96bef56446..518723c898 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/holder/LockedWalletStateHolder.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/holder/LockedWalletStateHolder.kt @@ -1,15 +1,15 @@ package com.tangem.feature.wallet.presentation.wallet.state.model.holder import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletPullToRefreshConfig import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf internal interface WalletStateHolder { - val pullToRefreshConfig: WalletPullToRefreshConfig + val pullToRefreshConfig: PullToRefreshConfig val walletCardState: WalletCardState val warnings: ImmutableList val bottomSheetConfig: TangemBottomSheetConfig? @@ -21,8 +21,8 @@ internal class LockedWalletStateHolder( onUnlockNotificationClick: () -> Unit, ) : WalletStateHolder { - override val pullToRefreshConfig: WalletPullToRefreshConfig - get() = WalletPullToRefreshConfig(isRefreshing = false, onRefresh = {}) + override val pullToRefreshConfig: PullToRefreshConfig + get() = PullToRefreshConfig(isRefreshing = false, onRefresh = {}) override val warnings: ImmutableList = persistentListOf( WalletNotification.UnlockWallets(onUnlockNotificationClick), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt index 0ead18d878..24b9bb0afd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt @@ -34,6 +34,7 @@ internal class InitializeWalletsTransformer( } .toImmutableList(), onWalletChange = clickIntents::onWalletChange, + onDismissMarketsOnboarding = clickIntents::onDismissMarketsOnboarding, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetBalancesAndLimitsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetBalancesAndLimitsTransformer.kt index d42d27f165..d4f7d3db34 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetBalancesAndLimitsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetBalancesAndLimitsTransformer.kt @@ -4,9 +4,9 @@ import arrow.core.Either import arrow.core.getOrElse import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.common.util.getCardsCount import com.tangem.domain.visa.model.VisaCurrency import com.tangem.domain.wallets.models.UserWallet -import com.tangem.feature.wallet.presentation.wallet.domain.getCardsCount import com.tangem.feature.wallet.presentation.wallet.state.model.BalancesAndLimitsBlockState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt index 58ceb2ac53..68e485e6eb 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers +import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.wallet.presentation.wallet.state.model.* import kotlinx.collections.immutable.PersistentList @@ -38,7 +39,7 @@ internal class SetRefreshStateTransformer( } } - private fun WalletPullToRefreshConfig.toUpdatedState(isRefreshing: Boolean): WalletPullToRefreshConfig { + private fun PullToRefreshConfig.toUpdatedState(isRefreshing: Boolean): PullToRefreshConfig { return copy(isRefreshing = isRefreshing) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt index fd70fb3cfa..a13bd46297 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt @@ -2,10 +2,10 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.common.util.getCardsCount import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory -import com.tangem.feature.wallet.presentation.wallet.domain.getCardsCount import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UpdateWalletCardsCountTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UpdateWalletCardsCountTransformer.kt index 1994185f73..59531e1ece 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UpdateWalletCardsCountTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UpdateWalletCardsCountTransformer.kt @@ -1,9 +1,9 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers +import com.tangem.domain.common.util.getCardsCount import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver -import com.tangem.feature.wallet.presentation.wallet.domain.getCardsCount import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import timber.log.Timber diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletCardStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletCardStateConverter.kt index 6a914a5c2a..0ff9942daf 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletCardStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletCardStateConverter.kt @@ -2,10 +2,10 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.common.util.getCardsCount import com.tangem.domain.tokens.model.TotalFiatBalance import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory -import com.tangem.feature.wallet.presentation.wallet.domain.getCardsCount import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.utils.converter.Converter 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 d924dabe10..0ff004ae48 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 @@ -62,7 +62,7 @@ internal class MultiWalletCurrencyActionsConverter( is TokenActionsState.ActionState.Stake -> { title = resourceReference(R.string.common_stake) icon = R.drawable.ic_staking_24 - action = { clickIntents.onStakeClick(cryptoCurrencyStatus) } + action = { clickIntents.onStakeClick(cryptoCurrencyStatus, actionsState.yield) } } is TokenActionsState.ActionState.Sell -> { title = resourceReference(R.string.common_sell) @@ -77,7 +77,13 @@ internal class MultiWalletCurrencyActionsConverter( is TokenActionsState.ActionState.Swap -> { title = resourceReference(R.string.swapping_swap_action) icon = R.drawable.ic_exchange_horizontal_24 - action = { clickIntents.onSwapClick(cryptoCurrencyStatus, ScenarioUnavailabilityReason.None) } + action = { + clickIntents.onSwapClick( + cryptoCurrencyStatus = cryptoCurrencyStatus, + userWalletId = userWallet.walletId, + unavailabilityReason = ScenarioUnavailabilityReason.None, + ) + } } is TokenActionsState.ActionState.CopyAddress -> { title = resourceReference(R.string.common_copy_address) @@ -89,6 +95,11 @@ internal class MultiWalletCurrencyActionsConverter( icon = R.drawable.ic_hide_24 action = { clickIntents.onHideTokensClick(cryptoCurrencyStatus) } } + is TokenActionsState.ActionState.Analytics -> { + title = resourceReference(R.string.common_analytics) + icon = R.drawable.ic_analytics_24 + action = { clickIntents.onAnalyticsClick(cryptoCurrencyStatus) } + } } return TokenActionButtonConfig( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletCardStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletCardStateConverter.kt index 775957b478..8ea62cf3fe 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletCardStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletCardStateConverter.kt @@ -2,10 +2,10 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.common.util.getCardsCount import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory -import com.tangem.feature.wallet.presentation.wallet.domain.getCardsCount import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.utils.converter.Converter diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt deleted file mode 100644 index 9abebae38f..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter - -import com.tangem.common.extensions.isZero -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter -import com.tangem.core.ui.components.marketprice.PriceChangeType -import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter -import com.tangem.core.ui.utils.BigDecimalFormatter -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.staking.model.stakekit.YieldBalance -import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.feature.wallet.presentation.common.state.TokenItemState -import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntents -import com.tangem.utils.Provider -import com.tangem.utils.StringsSigns.DASH_SIGN -import com.tangem.utils.converter.Converter -import com.tangem.utils.extensions.orZero -import java.math.BigDecimal - -internal class TokenItemStateConverter( - private val appCurrencyProvider: Provider, - private val clickIntents: WalletClickIntents, -) : Converter { - - private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) - - override fun convert(value: CryptoCurrencyStatus): TokenItemState { - return when (value.value) { - is CryptoCurrencyStatus.Loading -> value.mapToLoadingState() - is CryptoCurrencyStatus.Loaded, - is CryptoCurrencyStatus.Custom, - is CryptoCurrencyStatus.NoQuote, - is CryptoCurrencyStatus.NoAccount, - -> value.mapToTokenItemState() - is CryptoCurrencyStatus.MissedDerivation -> value.mapToNoAddressTokenItemState() - is CryptoCurrencyStatus.Unreachable, - is CryptoCurrencyStatus.NoAmount, - -> value.mapToUnreachableTokenItemState() - } - } - - private fun CryptoCurrencyStatus.mapToLoadingState(): TokenItemState.Loading { - return TokenItemState.Loading( - id = currency.id.value, - iconState = iconStateConverter.convert(value = this), - titleState = TokenItemState.TitleState.Content(text = currency.name), - ) - } - - private fun CryptoCurrencyStatus.mapToTokenItemState(): TokenItemState.Content { - return TokenItemState.Content( - id = currency.id.value, - iconState = iconStateConverter.convert(value = this), - titleState = TokenItemState.TitleState.Content( - text = currency.name, - hasPending = value.hasCurrentNetworkTransactions, - ), - fiatAmountState = TokenItemState.FiatAmountState.Content( - text = getFormattedFiatAmount(), - hasStaked = !getStakedBalance().isZero(), - ), - cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = getFormattedAmount()), - cryptoPriceState = getCryptoPriceState(), - onItemClick = { clickIntents.onTokenItemClick(this) }, - onItemLongClick = { clickIntents.onTokenItemLongClick(cryptoCurrencyStatus = this) }, - ) - } - - private fun CryptoCurrencyStatus.getFormattedAmount(): String { - val amount = value.amount?.plus(getStakedBalance()) ?: return DASH_SIGN - - return BigDecimalFormatter.formatCryptoAmount(amount, currency.symbol, currency.decimals) - } - - private fun CryptoCurrencyStatus.getFormattedFiatAmount(): String { - val fiatYieldBalance = value.fiatRate?.times(getStakedBalance()).orZero() - val fiatAmount = value.fiatAmount?.plus(fiatYieldBalance) ?: return DASH_SIGN - val appCurrency = appCurrencyProvider() - - return BigDecimalFormatter.formatFiatAmount(fiatAmount, appCurrency.code, appCurrency.symbol) - } - - private fun CryptoCurrencyStatus.getStakedBalance() = - (value.yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance().orZero() - - private fun CryptoCurrencyStatus.mapToUnreachableTokenItemState() = TokenItemState.Unreachable( - id = currency.id.value, - iconState = iconStateConverter.convert(value = this), - titleState = TokenItemState.TitleState.Content(text = currency.name), - onItemClick = { clickIntents.onTokenItemClick(this) }, - onItemLongClick = { clickIntents.onTokenItemLongClick(cryptoCurrencyStatus = this) }, - ) - - private fun CryptoCurrencyStatus.mapToNoAddressTokenItemState() = TokenItemState.NoAddress( - id = currency.id.value, - iconState = iconStateConverter.convert(this), - titleState = TokenItemState.TitleState.Content(text = currency.name), - onItemLongClick = { clickIntents.onTokenItemLongClick(cryptoCurrencyStatus = this) }, - ) - - private fun CryptoCurrencyStatus.getCryptoPriceState(): TokenItemState.CryptoPriceState { - val fiatRate = value.fiatRate - val priceChange = value.priceChange - - return if (fiatRate != null && priceChange != null) { - TokenItemState.CryptoPriceState.Content( - price = fiatRate.getFormattedCryptoPrice(), - priceChangePercent = BigDecimalFormatter.formatPercent( - percent = priceChange, - useAbsoluteValue = true, - ), - type = priceChange.getPriceChangeType(), - ) - } else { - TokenItemState.CryptoPriceState.Unknown - } - } - - private fun BigDecimal.getFormattedCryptoPrice(): String { - val appCurrency = appCurrencyProvider() - return BigDecimalFormatter.formatFiatAmountUncapped( - fiatAmount = this, - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ) - } - - private fun BigDecimal.getPriceChangeType(): PriceChangeType { - return PriceChangeConverter.fromBigDecimal(value = this) - } -} \ No newline at end of file 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 ccb6834bde..4ae09b322f 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 @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter +import com.tangem.common.ui.tokens.TokenItemStateConverter import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.common.util.cardTypesResolver @@ -11,7 +12,6 @@ import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState.TokensListItemState import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntents -import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.mutate @@ -19,15 +19,16 @@ import kotlinx.collections.immutable.persistentListOf import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState.OrganizeTokensButtonConfig as WalletOrganizeTokensButtonConfig internal class TokenListStateConverter( + appCurrency: AppCurrency, private val tokenList: TokenList, private val selectedWallet: UserWallet, - private val appCurrency: AppCurrency, private val clickIntents: WalletClickIntents, ) : Converter { private val tokenStatusConverter = TokenItemStateConverter( - appCurrencyProvider = Provider { appCurrency }, - clickIntents = clickIntents, + appCurrency = appCurrency, + onItemClick = clickIntents::onTokenItemClick, + onItemLongClick = clickIntents::onTokenItemLongClick, ) override fun convert(value: WalletTokensListState): WalletTokensListState { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TxHistoryItemStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TxHistoryItemStateConverter.kt index 45a7931776..f37407d469 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TxHistoryItemStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TxHistoryItemStateConverter.kt @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter +import com.tangem.common.extensions.isZero import com.tangem.core.ui.components.transactions.state.TransactionState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference @@ -7,6 +8,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.utils.toTimeFormat import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.domain.txhistory.models.TxHistoryItem.* import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntents import com.tangem.utils.StringsSigns.MINUS @@ -41,34 +43,44 @@ internal class TxHistoryItemStateConverter( ) } - private fun TxHistoryItem.extractIcon(): Int = if (status == TxHistoryItem.TransactionStatus.Failed) { + private fun TxHistoryItem.extractIcon(): Int = if (status == TransactionStatus.Failed) { R.drawable.ic_close_24 } else { when (type) { - is TxHistoryItem.TransactionType.Approve -> R.drawable.ic_doc_24 - is TxHistoryItem.TransactionType.Operation, - is TxHistoryItem.TransactionType.Swap, - is TxHistoryItem.TransactionType.Transfer, - is TxHistoryItem.TransactionType.UnknownOperation, + is TransactionType.Approve -> R.drawable.ic_doc_24 + is TransactionType.TronStakingTransactionType.Stake, + is TransactionType.TronStakingTransactionType.Vote, + -> R.drawable.ic_transaction_history_staking + is TransactionType.TronStakingTransactionType.Withdraw, + is TransactionType.TronStakingTransactionType.Unstake, + -> R.drawable.ic_transaction_history_unstaking + is TransactionType.Operation, + is TransactionType.Swap, + is TransactionType.Transfer, + is TransactionType.UnknownOperation, -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 } } private fun TxHistoryItem.extractTitle(): TextReference = when (val type = type) { - is TxHistoryItem.TransactionType.Approve -> resourceReference(R.string.common_approval) - is TxHistoryItem.TransactionType.Operation -> stringReference(type.name) - is TxHistoryItem.TransactionType.Swap -> resourceReference(R.string.common_swap) - is TxHistoryItem.TransactionType.Transfer -> resourceReference(R.string.common_transfer) - is TxHistoryItem.TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation) + is TransactionType.Approve -> resourceReference(R.string.common_approval) + is TransactionType.Operation -> stringReference(type.name) + is TransactionType.Swap -> resourceReference(R.string.common_swap) + is TransactionType.Transfer -> resourceReference(R.string.common_transfer) + is TransactionType.TronStakingTransactionType.Stake -> resourceReference(R.string.common_stake) + is TransactionType.TronStakingTransactionType.Unstake -> resourceReference(R.string.common_unstake) + is TransactionType.TronStakingTransactionType.Vote -> resourceReference(R.string.staking_vote) + is TransactionType.TronStakingTransactionType.Withdraw -> resourceReference(R.string.staking_withdraw) + is TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation) } private fun TxHistoryItem.extractSubtitle(): TextReference = when (val interactionAddress = interactionAddressType) { - is TxHistoryItem.InteractionAddressType.Contract -> resourceReference( + is InteractionAddressType.Contract -> resourceReference( id = R.string.transaction_history_contract_address, formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), ) - is TxHistoryItem.InteractionAddressType.Multiple -> resourceReference( + is InteractionAddressType.Multiple -> resourceReference( id = if (isOutgoing) { R.string.transaction_history_transaction_to_address } else { @@ -76,7 +88,7 @@ internal class TxHistoryItemStateConverter( }, formatArgs = wrappedList(resourceReference(R.string.transaction_history_multiple_addresses)), ) - is TxHistoryItem.InteractionAddressType.User -> resourceReference( + is InteractionAddressType.User -> resourceReference( id = if (isOutgoing) { R.string.transaction_history_transaction_to_address } else { @@ -84,20 +96,29 @@ internal class TxHistoryItemStateConverter( }, formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), ) + is InteractionAddressType.Staking -> resourceReference( + id = R.string.common_staking, + ) } private fun TxHistoryItem.extractDirection() = if (isOutgoing) TransactionState.Content.Direction.OUTGOING else TransactionState.Content.Direction.INCOMING - private fun TxHistoryItem.TransactionStatus.tiUiStatus() = when (this) { - TxHistoryItem.TransactionStatus.Confirmed -> TransactionState.Content.Status.Confirmed - TxHistoryItem.TransactionStatus.Failed -> TransactionState.Content.Status.Failed - TxHistoryItem.TransactionStatus.Unconfirmed -> TransactionState.Content.Status.Unconfirmed + private fun TransactionStatus.tiUiStatus() = when (this) { + TransactionStatus.Confirmed -> TransactionState.Content.Status.Confirmed + TransactionStatus.Failed -> TransactionState.Content.Status.Failed + TransactionStatus.Unconfirmed -> TransactionState.Content.Status.Unconfirmed } private fun TxHistoryItem.getAmount(): String { - val prefix = when (status) { - TxHistoryItem.TransactionStatus.Failed -> "" + if (type == TransactionType.TronStakingTransactionType.Vote || + type == TransactionType.TronStakingTransactionType.Withdraw + ) { + return "" + } + val prefix = when { + status == TransactionStatus.Failed -> "" + this.amount.isZero() -> "" else -> if (isOutgoing) MINUS else PLUS } return prefix + amount.toFormattedCurrencyString(currency = symbol, decimals = decimals) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt index feeb07e488..924cd9e0c8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt @@ -2,6 +2,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.utils import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.transactions.state.TxHistoryState +import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory @@ -71,8 +72,8 @@ internal class WalletLoadingStateFactory(private val clickIntents: WalletClickIn ) } - private fun createPullToRefreshConfig(): WalletPullToRefreshConfig { - return WalletPullToRefreshConfig(onRefresh = { clickIntents.onRefreshSwipe(it.value) }, isRefreshing = false) + private fun createPullToRefreshConfig(): PullToRefreshConfig { + return PullToRefreshConfig(onRefresh = { clickIntents.onRefreshSwipe(it.value) }, isRefreshing = false) } private fun UserWallet.toLoadingWalletCardState(): WalletCardState { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenListSubscriber.kt index 886cc1495e..c2c561afd8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenListSubscriber.kt @@ -3,10 +3,10 @@ package com.tangem.feature.wallet.presentation.wallet.subscribers import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.core.lce.LceFlow import com.tangem.domain.core.utils.toLce -import com.tangem.domain.tokens.GetCardTokensListUseCase +import com.tangem.domain.tokens.GetNodlTokenListUseCase +import com.tangem.domain.tokens.RunPolkadotAccountHealthCheckUseCase import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.TokenList -import com.tangem.domain.tokens.RunPolkadotAccountHealthCheckUseCase import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker @@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.map @Suppress("LongParameterList") internal class SingleWalletWithTokenListSubscriber( private val userWallet: UserWallet, - private val getCardTokensListUseCase: GetCardTokensListUseCase, + private val getNodlTokenListUseCase: GetNodlTokenListUseCase, stateHolder: WalletStateController, clickIntents: WalletClickIntents, tokenListAnalyticsSender: TokenListAnalyticsSender, @@ -34,6 +34,6 @@ internal class SingleWalletWithTokenListSubscriber( runPolkadotAccountHealthCheckUseCase = runPolkadotAccountHealthCheckUseCase, ) { - override fun tokenListFlow(): LceFlow = getCardTokensListUseCase(userWallet.walletId) + override fun tokenListFlow(): LceFlow = getNodlTokenListUseCase(userWallet.walletId) .map { it.toLce() } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletAlert.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletAlert.kt index 48e959ad54..ca9ee260f3 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletAlert.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletAlert.kt @@ -3,9 +3,9 @@ package com.tangem.feature.wallet.presentation.wallet.ui import androidx.compose.runtime.* import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue -import com.tangem.core.ui.components.AdditionalTextInputDialogParams +import com.tangem.core.ui.components.AdditionalTextInputDialogUM import com.tangem.core.ui.components.BasicDialog -import com.tangem.core.ui.components.DialogButton +import com.tangem.core.ui.components.DialogButtonUM import com.tangem.core.ui.components.TextInputDialog import com.tangem.core.ui.extensions.resolveReference import com.tangem.feature.wallet.impl.R @@ -21,12 +21,12 @@ internal fun WalletAlert(state: WalletAlertState, onDismiss: () -> Unit) { @Composable private fun BasicAlert(state: WalletAlertState.Basic, onDismiss: () -> Unit) { - val confirmButton: DialogButton - val dismissButton: DialogButton? + val confirmButton: DialogButtonUM + val dismissButton: DialogButtonUM? val onActionClick = state.onConfirmClick if (onActionClick != null) { - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = state.confirmButtonText.resolveReference(), warning = state.isWarningConfirmButton, onClick = { @@ -34,12 +34,12 @@ private fun BasicAlert(state: WalletAlertState.Basic, onDismiss: () -> Unit) { onDismiss() }, ) - dismissButton = DialogButton( + dismissButton = DialogButtonUM( title = stringResource(id = R.string.common_cancel), onClick = onDismiss, ) } else { - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = state.confirmButtonText.resolveReference(), warning = state.isWarningConfirmButton, onClick = onDismiss, @@ -62,7 +62,7 @@ private fun TextInputAlert(state: WalletAlertState.TextInput, onDismiss: () -> U TextInputDialog( fieldValue = value, - confirmButton = DialogButton( + confirmButton = DialogButtonUM( title = state.confirmButtonText.resolveReference(), enabled = value.text.isNotEmpty() && value.text != state.text && @@ -75,8 +75,8 @@ private fun TextInputAlert(state: WalletAlertState.TextInput, onDismiss: () -> U onDismissDialog = onDismiss, onValueChange = { value = it }, title = state.title.resolveReference(), - dismissButton = DialogButton(title = stringResource(id = R.string.common_cancel), onClick = onDismiss), - textFieldParams = AdditionalTextInputDialogParams( + dismissButton = DialogButtonUM(title = stringResource(id = R.string.common_cancel), onClick = onDismiss), + textFieldParams = AdditionalTextInputDialogUM( label = state.label.resolveReference(), isError = state.errorTextProvider(value.text) != null, caption = state.errorTextProvider(value.text)?.resolveReference(), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 369c9f8215..fc8d3b6572 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -3,10 +3,10 @@ package com.tangem.feature.wallet.presentation.wallet.ui import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.TweenSpec -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.* import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideIn import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures @@ -19,27 +19,33 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.* +import androidx.compose.material3.BottomSheetDefaults import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.* import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.paging.compose.collectAsLazyPagingItems import com.google.accompanist.systemuicontroller.rememberSystemUiController -import com.tangem.core.ui.components.BottomFade -import com.tangem.core.ui.components.Keyboard -import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.* import com.tangem.core.ui.components.atoms.Hand import com.tangem.core.ui.components.atoms.handComposableComponentHeight import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig @@ -47,17 +53,21 @@ import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBot import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheet import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig -import com.tangem.core.ui.components.keyboardAsState +import com.tangem.core.ui.components.sheetscaffold.* import com.tangem.core.ui.components.snackbar.CopiedTextSnackbar import com.tangem.core.ui.components.snackbar.TangemSnackbar import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TestTags import com.tangem.core.ui.utils.WindowInsetsZero +import com.tangem.core.ui.utils.lineTo +import com.tangem.core.ui.utils.moveTo +import com.tangem.core.ui.utils.toPx import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.common.preview.WalletScreenPreviewData.walletScreenState import com.tangem.feature.wallet.presentation.wallet.state.model.* @@ -74,12 +84,12 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.VisaTxDe import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.balancesAndLimitsBlock import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.depositButton import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator -import com.tangem.features.markets.component.BottomSheetState -import com.tangem.features.markets.component.BottomSheetState.COLLAPSED -import com.tangem.features.markets.component.BottomSheetState.EXPANDED -import com.tangem.features.markets.component.MarketsEntryComponent +import com.tangem.features.markets.entry.BottomSheetState +import com.tangem.features.markets.entry.MarketsEntryComponent import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.math.roundToInt @Composable internal fun WalletScreen(state: WalletScreenState, marketsEntryComponent: MarketsEntryComponent?) { @@ -133,7 +143,9 @@ private fun WalletContent( var selectedWalletIndex by remember(state.selectedWalletIndex) { mutableIntStateOf(state.selectedWalletIndex) } val selectedWallet = state.wallets.getOrElse(selectedWalletIndex) { state.wallets[state.selectedWalletIndex] } - val scaffoldContent: @Composable () -> Unit = { + val listState = rememberLazyListState() + + val scaffoldContent: @Composable (PaddingValues?) -> Unit = { paddingValues -> val movableItemModifier = Modifier.changeWalletAnimator(walletsListState) val lazyTxHistoryItems = (selectedWallet as? TxHistoryStateHolder)?.let { walletState -> @@ -152,13 +164,20 @@ private fun WalletContent( val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + val marketHintAproxHeight = with(LocalDensity.current) { + TangemTheme.typography.caption2.lineHeight.toDp() * 2 + } + 40.dp + + val contentPadding = paddingValues?.let { + PaddingValues( + bottom = it.calculateBottomPadding() + marketHintAproxHeight + 52.dp, + ) + } ?: PaddingValues(bottom = TangemTheme.dimens.spacing92 + bottomBarHeight) + LazyColumn( - modifier = Modifier - .fillMaxSize() - .testTag(TestTags.WALLET_SCREEN), - contentPadding = PaddingValues( - bottom = TangemTheme.dimens.spacing92 + bottomBarHeight, - ), + modifier = Modifier.testTag(TestTags.MAIN_SCREEN), + state = listState, + contentPadding = contentPadding, horizontalAlignment = Alignment.CenterHorizontally, ) { item( @@ -224,12 +243,13 @@ private fun WalletContent( } if (marketsEntryComponent != null) { - val bottomSheetState = remember { mutableStateOf(COLLAPSED) } + val bottomSheetState = remember { mutableStateOf(BottomSheetState.COLLAPSED) } var headerSize by remember { mutableStateOf(0.dp) } BaseScaffoldWithMarkets( state = state, + listState = listState, selectedWallet = selectedWallet, snackbarHostState = snackbarHostState, bottomSheetHeaderHeightProvider = { headerSize }, @@ -249,7 +269,7 @@ private fun WalletContent( state = state, selectedWallet = selectedWallet, snackbarHostState = snackbarHostState, - content = scaffoldContent, + content = { scaffoldContent(null) }, ) } } @@ -297,7 +317,7 @@ private fun BaseScaffold( val pullRefreshState = rememberPullRefreshState( refreshing = selectedWallet.pullToRefreshConfig.isRefreshing, onRefresh = { - selectedWallet.pullToRefreshConfig.onRefresh(WalletPullToRefreshConfig.ShowRefreshState(true)) + selectedWallet.pullToRefreshConfig.onRefresh(PullToRefreshConfig.ShowRefreshState()) }, ) @@ -320,52 +340,48 @@ private fun BaseScaffold( ) } -@Suppress("LongParameterList", "LongMethod") +@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) @Composable private inline fun BaseScaffoldWithMarkets( state: WalletScreenState, + listState: LazyListState, selectedWallet: WalletState, snackbarHostState: SnackbarHostState, bottomSheetHeaderHeightProvider: () -> Dp, crossinline bottomSheetContent: @Composable () -> Unit, alertConfig: WalletAlertState?, noinline onBottomSheetStateChange: (BottomSheetState) -> Unit, - crossinline content: @Composable () -> Unit, + crossinline content: @Composable (PaddingValues) -> Unit, ) { - // show the bottom sheet if there is at least one multicurrency wallet - val showManageTokensBottomSheet = remember(state.wallets) { - state.wallets.any { it is WalletState.MultiCurrency } - } - val bottomSheetState = rememberSheetStateEnhanced( - initialValue = if (showManageTokensBottomSheet) SheetValue.PartiallyExpanded else SheetValue.Hidden, - confirmValueChange = remember(showManageTokensBottomSheet) { - { sheetValue -> - when { - sheetValue == SheetValue.Hidden && showManageTokensBottomSheet -> false - sheetValue != SheetValue.Hidden && !showManageTokensBottomSheet -> false - else -> true - } - } - }, - skipHiddenState = showManageTokensBottomSheet, - ) + val bottomSheetState = rememberTangemStandardBottomSheetState() - val keyboardShown = keyboardAsState() + val isKeyboardVisible by rememberIsKeyboardVisible() - val scaffoldState = rememberBottomSheetScaffoldState( + val scaffoldState = rememberTangemBottomSheetScaffoldState( bottomSheetState = bottomSheetState, snackbarHostState = snackbarHostState, ) - val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } - val statusBarHeight = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() } + val density = LocalDensity.current + val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(density = this).toDp() } + val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density = this).toDp() } val peekHeight = bottomSheetHeaderHeightProvider() + handComposableComponentHeight + bottomBarHeight val maxHeight = LocalWindowSize.current.height val coroutineScope = rememberCoroutineScope() val backgroundPrimary = TangemTheme.colors.background.primary + val showMarketsHint by remember { + derivedStateOf { + // Show hint only when there are items in the list + // and when there a no items to scroll + listState.layoutInfo.totalItemsCount > 0 && + !listState.canScrollBackward && !listState.canScrollForward || + listState.canScrollBackward && !listState.canScrollForward + } + } + CompositionLocalProvider( LocalMainBottomSheetColor provides remember { mutableStateOf(backgroundPrimary) }, ) { @@ -373,13 +389,11 @@ private inline fun BaseScaffoldWithMarkets( BottomSheetStateEffects( bottomSheetState = bottomSheetState, - showManageTokensBottomSheet = showManageTokensBottomSheet, alertConfig = alertConfig, - keyboardShown = keyboardShown, onBottomSheetStateChange = onBottomSheetStateChange, ) - BottomSheetScaffold( + TangemBottomSheetScaffold( snackbarHost = { WalletSnackbarHost( snackbarHostState = it, @@ -393,56 +407,203 @@ private inline fun BaseScaffoldWithMarkets( sheetContainerColor = backgroundColor.value, scaffoldState = scaffoldState, sheetPeekHeight = peekHeight, - sheetDragHandle = { - Hand(modifier = Modifier.background(color = backgroundColor.value)) - }, sheetTonalElevation = 8.dp, sheetShadowElevation = 8.dp, + sheetShape = TangemTheme.shapes.bottomSheetLarge, sheetContent = { - Box( - modifier = Modifier.sizeIn(maxHeight = maxHeight - statusBarHeight - handComposableComponentHeight), - ) { - bottomSheetContent() - } - // hide bottom sheet when back pressed BackHandler( - keyboardShown.value is Keyboard.Closed && - bottomSheetState.currentValue == SheetValue.Expanded, + isKeyboardVisible.not() && + bottomSheetState.currentValue == TangemSheetValue.Expanded, ) { coroutineScope.launch { bottomSheetState.partialExpand() } } + + Column( + modifier = Modifier.sizeIn(maxHeight = maxHeight - statusBarHeight), + ) { + Hand(Modifier.drawBehind { drawRect(backgroundColor.value) }) + bottomSheetContent() + } }, - content = { _ -> + content = { paddingValues -> val pullRefreshState = rememberPullRefreshState( refreshing = selectedWallet.pullToRefreshConfig.isRefreshing, onRefresh = { - selectedWallet.pullToRefreshConfig.onRefresh(WalletPullToRefreshConfig.ShowRefreshState(true)) + selectedWallet.pullToRefreshConfig.onRefresh(PullToRefreshConfig.ShowRefreshState()) }, ) - Column { - WalletTopBar(config = state.topBarConfig) - Box( - modifier = Modifier.pullRefresh(pullRefreshState), - ) { - content() + Box { + MarketsHint( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = peekHeight + 12.dp) + .fillMaxWidth(fraction = .4f), + isVisible = showMarketsHint, + ) - WalletPullToRefreshIndicator( - isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter), - ) + Column { + WalletTopBar(config = state.topBarConfig) + Box( + modifier = Modifier.pullRefresh(pullRefreshState), + ) { + content(paddingValues) + + WalletPullToRefreshIndicator( + isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } } - } - BottomSheetScrim( - color = BottomSheetDefaults.ScrimColor, - visible = bottomSheetState.targetValue == SheetValue.Expanded, - onDismissRequest = { coroutineScope.launch { bottomSheetState.partialExpand() } }, - ) + BottomSheetScrim( + color = BottomSheetDefaults.ScrimColor, + visible = bottomSheetState.targetValue == TangemSheetValue.Expanded || + state.showMarketsOnboarding, + onDismissRequest = { coroutineScope.launch { bottomSheetState.partialExpand() } }, + ) + + MarketsTooltip( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp) + .fillMaxWidth(fraction = 0.7f), + isVisible = state.showMarketsOnboarding, + availableHeight = maxHeight - statusBarHeight - bottomBarHeight, + bottomSheetState = bottomSheetState, + ) + } }, ) + + LaunchedEffect(state.showMarketsOnboarding, bottomSheetState.targetValue) { + if (state.showMarketsOnboarding && bottomSheetState.targetValue == TangemSheetValue.Expanded) { + state.onDismissMarketsOnboarding() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MarketsTooltip( + availableHeight: Dp, + bottomSheetState: TangemSheetState, + isVisible: Boolean, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val tooltipOffset by remember { + derivedStateOf { + val bottomSheetOffset = try { + // Can throw exception during the first composition + with(density) { bottomSheetState.requireOffset().toDp() } + } catch (e: Exception) { + 0.dp + } + + bottomSheetOffset - availableHeight + } + } + + var visible by remember { mutableStateOf(value = false) } + LaunchedEffect(isVisible) { + if (isVisible) { + delay(timeMillis = 300) + } + + visible = isVisible + } + + val slideOffset = 40.dp.toPx() + AnimatedVisibility( + modifier = modifier.offset { IntOffset(x = 0, y = tooltipOffset.roundToPx()) }, + visible = visible, + enter = slideIn( + animationSpec = spring( + stiffness = Spring.StiffnessLow, + visibilityThreshold = IntOffset.VisibilityThreshold, + ), + initialOffset = { _ -> IntOffset(y = -slideOffset.roundToInt(), x = 0) }, + ) + fadeIn(), + exit = fadeOut(), + ) { + MarketsTooltipContent() + } +} + +@Composable +internal fun MarketsHint(isVisible: Boolean, modifier: Modifier = Modifier) { + AnimatedVisibility( + modifier = modifier, + visible = isVisible, + enter = fadeIn(animationSpec = tween(durationMillis = 500)), + exit = fadeOut(), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(space = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.markets_hint), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + Icon( + modifier = Modifier.size(size = 24.dp), + painter = painterResource(id = R.drawable.ic_chevron_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } + } +} + +@Composable +private fun MarketsTooltipContent(modifier: Modifier = Modifier) { + val backgroundColor = TangemTheme.colors.background.primary + val cornerRadius = CornerRadius(x = 14.dp.toPx()) + val tipDpSize = DpSize(width = 20.dp, height = 8.dp) + + Column( + modifier = modifier + .padding(bottom = tipDpSize.height) + .drawBehind { + val rect = size.toRect() + val tipSize = tipDpSize.toSize() + val tipRect = Rect( + offset = Offset( + x = rect.center.x - tipSize.center.x, + y = rect.bottom, + ), + size = tipSize, + ) + drawRoundRect(color = backgroundColor, cornerRadius = cornerRadius) + + val tipPath = Path().apply { + moveTo(tipRect.topLeft) + lineTo(tipRect.bottomCenter) + lineTo(tipRect.topRight) + } + drawPath(color = backgroundColor, path = tipPath) + } + .padding(all = 12.dp), + verticalArrangement = Arrangement.spacedBy(space = 4.dp), + horizontalAlignment = Alignment.Start, + ) { + Text( + text = stringResource(id = R.string.markets_tooltip_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = stringResource(id = R.string.markets_tooltip_message), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + ) } } @@ -450,7 +611,7 @@ private inline fun BaseScaffoldWithMarkets( private fun BottomSheetScrim(color: Color, visible: Boolean, onDismissRequest: () -> Unit) { val alpha by animateFloatAsState( targetValue = if (visible) 1f else 0f, - animationSpec = TweenSpec(), + animationSpec = tween(), label = "scrim", ) val dismissSheet = if (visible) { @@ -473,70 +634,46 @@ private fun BottomSheetScrim(color: Color, visible: Boolean, onDismissRequest: ( } } -@OptIn(ExperimentalMaterial3Api::class) @Suppress("CyclomaticComplexMethod", "MagicNumber", "LongMethod") @Composable private fun BottomSheetStateEffects( - bottomSheetState: SheetState, - showManageTokensBottomSheet: Boolean, + bottomSheetState: TangemSheetState, alertConfig: WalletAlertState?, - keyboardShown: State, onBottomSheetStateChange: (BottomSheetState) -> Unit, ) { - // Bottom sheet during initialization internally expand partially after its content was remeasured, - // therefore initialValue = SheetValue.Hidden in rememberStandardBottomSheetState doesn't work as expected - // so we have to manually restrict expansion in this case - LaunchedEffect(bottomSheetState.targetValue, bottomSheetState.currentValue) { - if (!showManageTokensBottomSheet && - (bottomSheetState.targetValue != SheetValue.Hidden || bottomSheetState.currentValue != SheetValue.Hidden) - ) { - bottomSheetState.hide() - } - } - // react to changes in wallet list - LaunchedEffect(showManageTokensBottomSheet) { - when { - showManageTokensBottomSheet && bottomSheetState.currentValue != SheetValue.PartiallyExpanded -> { - bottomSheetState.partialExpand() - } - !showManageTokensBottomSheet && bottomSheetState.targetValue != SheetValue.Hidden -> { - bottomSheetState.hide() - } - } - } - val systemUiController = rememberSystemUiController() val navigationBarColor = TangemTheme.colors.background.primary LaunchedEffect(key1 = bottomSheetState.targetValue, navigationBarColor) { when (bottomSheetState.targetValue) { - SheetValue.Hidden, - SheetValue.Expanded, + TangemSheetValue.Hidden, + TangemSheetValue.Expanded, -> systemUiController.setNavigationBarColor( color = Color.Transparent, darkIcons = navigationBarColor.luminance() > 0.5f, navigationBarContrastEnforced = true, ) - SheetValue.PartiallyExpanded, + TangemSheetValue.PartiallyExpanded, -> systemUiController.setNavigationBarColor(navigationBarColor) } } - DisposableEffect(showManageTokensBottomSheet) { + // make navigation bar transparent when leaving the screen + DisposableEffect(Unit) { onDispose { - if (showManageTokensBottomSheet) { - systemUiController.setNavigationBarColor( - color = Color.Transparent, - darkIcons = navigationBarColor.luminance() > 0.5f, - navigationBarContrastEnforced = false, - ) - } + systemUiController.setNavigationBarColor( + color = Color.Transparent, + darkIcons = navigationBarColor.luminance() > 0.5f, + navigationBarContrastEnforced = false, + ) } } // expand bottom sheet when keyboard appears - LaunchedEffect(keyboardShown.value is Keyboard.Opened) { - if (keyboardShown.value is Keyboard.Opened && alertConfig == null) { + val isKeyboardVisible by rememberIsKeyboardVisible() + + LaunchedEffect(isKeyboardVisible) { + if (isKeyboardVisible && alertConfig == null) { bottomSheetState.expand() } } @@ -545,8 +682,8 @@ private fun BottomSheetStateEffects( // hide keyboard when bottom sheet is about to be hidden LaunchedEffect(Unit) { snapshotFlow { - bottomSheetState.currentValue == SheetValue.Expanded && - bottomSheetState.targetValue == SheetValue.PartiallyExpanded + bottomSheetState.currentValue == TangemSheetValue.Expanded && + bottomSheetState.targetValue == TangemSheetValue.PartiallyExpanded }.collect { sheetHasBeenHidden -> if (sheetHasBeenHidden) { keyboardController?.hide() @@ -554,42 +691,18 @@ private fun BottomSheetStateEffects( } } - val isSheetHidden = bottomSheetState.targetValue == SheetValue.PartiallyExpanded + val isSheetHidden = bottomSheetState.targetValue == TangemSheetValue.PartiallyExpanded LaunchedEffect(isSheetHidden) { onBottomSheetStateChange( if (isSheetHidden) { - COLLAPSED + BottomSheetState.COLLAPSED } else { - EXPANDED + BottomSheetState.EXPANDED }, ) } } -/** - * Use a standard method when this is fixed https://issuetracker.google.com/issues/314796718 - * Current material3 version: 1.2.0 - */ -@Composable -@ExperimentalMaterial3Api -private fun rememberSheetStateEnhanced( - skipPartiallyExpanded: Boolean = false, - confirmValueChange: (SheetValue) -> Boolean = { true }, - initialValue: SheetValue = SheetValue.Hidden, - skipHiddenState: Boolean = false, -): SheetState { - val density = LocalDensity.current - return remember(initialValue, skipPartiallyExpanded, confirmValueChange, skipHiddenState) { - SheetState( - skipPartiallyExpanded = skipPartiallyExpanded, - density = density, - initialValue = initialValue, - confirmValueChange = confirmValueChange, - skipHiddenState = skipHiddenState, - ) - } -} - @Composable private fun WalletSnackbarHost( snackbarHostState: SnackbarHostState, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBottomSheet.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBottomSheet.kt index 72233c34b7..23d7179e34 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBottomSheet.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBottomSheet.kt @@ -56,19 +56,24 @@ private fun BottomSheetContent(config: WalletBottomSheetConfig) { }, ) - Text( - text = config.title.resolveReference(), - color = TangemTheme.colors.text.primary1, - textAlign = TextAlign.Center, - style = TangemTheme.typography.h2, - ) + Column( + verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = config.title.resolveReference(), + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + style = TangemTheme.typography.h2, + ) - Text( - text = config.subtitle.resolveReference(), - color = TangemTheme.colors.text.secondary, - textAlign = TextAlign.Center, - style = TangemTheme.typography.body2, - ) + Text( + text = config.subtitle.resolveReference(), + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + style = TangemTheme.typography.body2, + ) + } Column(verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing10)) { val buttonModifier = Modifier.fillMaxWidth() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt index 07aaba8fe5..6ceb82e75c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt @@ -46,6 +46,7 @@ import com.tangem.core.ui.components.FontSizeRange import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.ResizableText 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.res.TangemDimens import com.tangem.core.ui.res.TangemTheme @@ -53,7 +54,6 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.common.WalletPreviewData import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState -import com.tangem.utils.StringsSigns private const val HALF_OF_ITEM_WIDTH = 0.5 @@ -104,11 +104,9 @@ internal fun WalletCard(state: WalletCardState, isBalanceHidden: Boolean, modifi val additionalText by remember(state.additionalInfo, isBalanceHidden) { mutableStateOf( - if (state.additionalInfo?.hideable == true && isBalanceHidden) { - WalletCardState.HIDDEN_BALANCE_TEXT - } else { - state.additionalInfo?.content - }, + state.additionalInfo?.content?.orMaskWithStars( + maskWithStars = state.additionalInfo?.hideable == true && isBalanceHidden, + ), ) } AdditionalInfo( @@ -291,7 +289,7 @@ private fun Balance(state: WalletCardState, isBalanceHidden: Boolean, modifier: when (walletCardState) { is WalletCardState.Content -> { ResizableText( - text = if (isBalanceHidden) StringsSigns.STARS else walletCardState.balance, + text = walletCardState.balance.orMaskWithStars(isBalanceHidden), fontSizeRange = FontSizeRange(min = 16.sp, max = TangemTheme.typography.h2.fontSize), modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32), color = TangemTheme.colors.text.primary1, @@ -301,7 +299,7 @@ private fun Balance(state: WalletCardState, isBalanceHidden: Boolean, modifier: ) } is WalletCardState.Error -> NonContentBalanceText( - text = if (isBalanceHidden) WalletCardState.HIDDEN_BALANCE_TEXT else WalletCardState.EMPTY_BALANCE_TEXT, + text = WalletCardState.EMPTY_BALANCE_TEXT.orMaskWithStars(isBalanceHidden), ) is WalletCardState.Loading -> { RectangleShimmer(modifier = Modifier.nonContentBalanceSize(TangemTheme.dimens)) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt index c8e9a69c9d..ebb2fdcfa9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt @@ -3,10 +3,14 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.common import android.content.res.Configuration import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TestTags.MAIN_SCREEN_MORE_BUTTON +import com.tangem.core.ui.test.TestTags.MAIN_SCREEN_TOP_BAR import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.common.WalletPreviewData import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTopBarConfig @@ -24,7 +28,7 @@ internal fun WalletTopBar(config: WalletTopBarConfig) { Icon(painter = painterResource(id = R.drawable.img_tangem_logo_90_24), contentDescription = null) }, actions = { - IconButton(onClick = config.onDetailsClick) { + IconButton(onClick = config.onDetailsClick, modifier = Modifier.testTag(MAIN_SCREEN_MORE_BUTTON)) { Icon(painter = painterResource(id = R.drawable.ic_more_vertical_24), contentDescription = null) } }, @@ -34,6 +38,7 @@ internal fun WalletTopBar(config: WalletTopBarConfig) { actionIconContentColor = TangemTheme.colors.icon.primary1, ), scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + modifier = Modifier.testTag(MAIN_SCREEN_TOP_BAR), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContentItem.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContentItem.kt index 9d39d7fcbc..6c3ca94c12 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContentItem.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContentItem.kt @@ -1,10 +1,12 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency +import androidx.compose.foundation.background import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.token.TokenItem import com.tangem.core.ui.extensions.resolveReference -import com.tangem.feature.wallet.presentation.common.component.NetworkGroupItem -import com.tangem.feature.wallet.presentation.common.component.TokenItem +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.wallet.presentation.common.component.NetworkTitleItem import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState.TokensListItemState /** @@ -21,12 +23,18 @@ internal fun MultiCurrencyContentItem( isBalanceHidden: Boolean, modifier: Modifier = Modifier, ) { + val modifierWithBackground = modifier.background(color = TangemTheme.colors.background.primary) + when (state) { is TokensListItemState.NetworkGroupTitle -> { - NetworkGroupItem(networkName = state.name.resolveReference(), modifier = modifier) + NetworkTitleItem(networkName = state.name.resolveReference(), modifier = modifierWithBackground) } is TokensListItemState.Token -> { - TokenItem(state = state.state, isBalanceHidden = isBalanceHidden, modifier = modifier) + TokenItem( + state = state.state, + isBalanceHidden = isBalanceHidden, + modifier = modifierWithBackground, + ) } } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt index e4fc8b0bff..9e6aa0e4ce 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt @@ -7,10 +7,7 @@ import androidx.lifecycle.viewModelScope import arrow.core.getOrElse import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.settings.CanUseBiometryUseCase -import com.tangem.domain.settings.IsWalletsScrollPreviewEnabled -import com.tangem.domain.settings.ShouldAskPermissionUseCase -import com.tangem.domain.settings.ShouldShowSaveWalletScreenUseCase +import com.tangem.domain.settings.* import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase @@ -30,6 +27,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.transformers.* import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntents +import com.tangem.features.markets.MarketsFeatureToggles import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.features.pushnotifications.api.utils.getPushPermissionOrNull @@ -58,6 +56,7 @@ internal class WalletViewModel @Inject constructor( private val getSelectedWalletUseCase: GetSelectedWalletUseCase, private val getWalletsUseCase: GetWalletsUseCase, private val shouldShowSaveWalletScreenUseCase: ShouldShowSaveWalletScreenUseCase, + private val shouldShowMarketsTooltipUseCase: ShouldShowMarketsTooltipUseCase, private val canUseBiometryUseCase: CanUseBiometryUseCase, private val isWalletsScrollPreviewEnabled: IsWalletsScrollPreviewEnabled, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, @@ -69,6 +68,7 @@ internal class WalletViewModel @Inject constructor( private val refreshMultiCurrencyWalletQuotesUseCase: RefreshMultiCurrencyWalletQuotesUseCase, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, private val pushNotificationsFeatureToggles: PushNotificationsFeatureToggles, + private val marketsFeatureToggles: MarketsFeatureToggles, analyticsEventsHandler: AnalyticsEventHandler, ) : ViewModel() { @@ -83,6 +83,7 @@ internal class WalletViewModel @Inject constructor( analyticsEventsHandler.send(WalletScreenAnalyticsEvent.MainScreen.ScreenOpened) suggestToEnableBiometrics() + suggestToOpenMarkets() maybeMigrateNames() subscribeToUserWalletsUpdates() @@ -121,6 +122,20 @@ internal class WalletViewModel @Inject constructor( } } + private fun suggestToOpenMarkets() { + viewModelScope.launch { + withContext(dispatchers.io) { delay(timeMillis = 1_800) } + + if (marketsFeatureToggles.isFeatureEnabled && shouldShowMarketsTooltipUseCase()) { + stateHolder.update { + it.copy(showMarketsOnboarding = true) + } + } + + shouldShowMarketsTooltipUseCase(isShown = true) + } + } + private suspend fun isShowSaveWalletScreenEnabled(): Boolean { return router.isWalletLastScreen() && shouldShowSaveWalletScreenUseCase() && canUseBiometryUseCase() } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolver.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolver.kt index 26d3d0c945..835a480b48 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolver.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolver.kt @@ -1,10 +1,10 @@ package com.tangem.feature.wallet.presentation.wallet.viewmodels import arrow.core.getOrElse +import com.tangem.domain.common.util.getCardsCount import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase -import com.tangem.feature.wallet.presentation.wallet.domain.getCardsCount import com.tangem.feature.wallet.presentation.wallet.state.model.NOT_INITIALIZED_WALLET_INDEX import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletContentClickIntents.kt index 5c192d9a44..ba469eaf52 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletContentClickIntents.kt @@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents import arrow.core.getOrElse import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.redux.ReduxStateHolder +import com.tangem.domain.settings.ShouldShowMarketsTooltipUseCase import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase import com.tangem.domain.tokens.TokensAction @@ -35,6 +36,8 @@ internal interface WalletContentClickIntents { fun onOrganizeTokensClick() + fun onDismissMarketsOnboarding() + fun onTokenItemClick(currencyStatus: CryptoCurrencyStatus) fun onTokenItemLongClick(cryptoCurrencyStatus: CryptoCurrencyStatus) @@ -52,6 +55,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase, private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val shouldShowMarketsTooltipUseCase: ShouldShowMarketsTooltipUseCase, private val analyticsEventHandler: AnalyticsEventHandler, private val dispatchers: CoroutineDispatcherProvider, private val reduxStateHolder: ReduxStateHolder, @@ -90,7 +94,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( override fun onManageTokensClick() { analyticsEventHandler.send(PortfolioEvent.ButtonManageTokens) reduxStateHolder.dispatch(action = TokensAction.SetArgs.ManageAccess) - router.openManageTokensScreen() + router.openManageTokensScreen(userWalletId = stateHolder.getSelectedWalletId()) } override fun onOrganizeTokensClick() { @@ -98,6 +102,13 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( router.openOrganizeTokensScreen(userWalletId = stateHolder.getSelectedWalletId()) } + override fun onDismissMarketsOnboarding() { + stateHolder.update { it.copy(showMarketsOnboarding = false) } + viewModelScope.launch { + shouldShowMarketsTooltipUseCase(isShown = true) + } + } + override fun onTokenItemClick(currencyStatus: CryptoCurrencyStatus) { analyticsEventHandler.send(PortfolioEvent.TokenTapped) router.openTokenDetails(stateHolder.getSelectedWalletId(), currencyStatus) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt index 98590b9a89..3b57403e42 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt @@ -1,7 +1,9 @@ package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents -import arrow.core.getOrElse import com.tangem.blockchain.common.address.AddressType +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.ui.tokens.getUnavailabilityReasonText import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent @@ -12,15 +14,15 @@ import com.tangem.core.ui.components.bottomsheets.tokenreceive.mapToAddressModel import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.WrappedList import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.redux.ReduxStateHolder -import com.tangem.domain.staking.GetYieldUseCase +import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.tokens.* import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.model.CryptoCurrency @@ -47,6 +49,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch +import java.math.BigDecimal import javax.inject.Inject interface WalletCurrencyActionsClickIntents { @@ -57,11 +60,15 @@ interface WalletCurrencyActionsClickIntents { fun onBuyClick(cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason) - fun onSwapClick(cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason) + fun onSwapClick( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWalletId: UserWalletId, + unavailabilityReason: ScenarioUnavailabilityReason, + ) fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus) - fun onStakeClick(cryptoCurrencyStatus: CryptoCurrencyStatus) + fun onStakeClick(cryptoCurrencyStatus: CryptoCurrencyStatus, yield: Yield?) fun onCopyAddressLongClick(cryptoCurrencyStatus: CryptoCurrencyStatus): TextReference? @@ -72,6 +79,8 @@ interface WalletCurrencyActionsClickIntents { fun onPerformHideToken(cryptoCurrencyStatus: CryptoCurrencyStatus) fun onExploreClick() + + fun onAnalyticsClick(cryptoCurrencyStatus: CryptoCurrencyStatus) } @Suppress("LongParameterList", "LargeClass") @@ -94,7 +103,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private val reduxStateHolder: ReduxStateHolder, private val vibratorHapticManager: VibratorHapticManager, private val clipboardManager: ClipboardManager, - private val getYieldUseCase: GetYieldUseCase, + private val appRouter: AppRouter, ) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents { override fun onSendClick( @@ -223,19 +232,16 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( ) viewModelScope.launch(dispatchers.main) { - walletManagersFacade.getAddress( + walletManagersFacade.getDefaultAddress( userWalletId = stateHolder.getSelectedWalletId(), network = cryptoCurrencyStatus.currency.network, - ) - .find { it.type == AddressType.Default } - ?.value - ?.let { - stateHolder.update(CloseBottomSheetTransformer(userWalletId = stateHolder.getSelectedWalletId())) + )?.let { + stateHolder.update(CloseBottomSheetTransformer(userWalletId = stateHolder.getSelectedWalletId())) - walletEventSender.send( - event = WalletEvent.CopyAddress(address = it), - ) - } + walletEventSender.send( + event = WalletEvent.CopyAddress(address = it), + ) + } } } @@ -355,6 +361,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( override fun onSwapClick( cryptoCurrencyStatus: CryptoCurrencyStatus, + userWalletId: UserWalletId, unavailabilityReason: ScenarioUnavailabilityReason, ) { analyticsEventHandler.send( @@ -363,32 +370,61 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( if (handleUnavailabilityReason(unavailabilityReason)) return - reduxStateHolder.dispatch(TradeCryptoAction.Swap(cryptoCurrencyStatus.currency)) + appRouter.push( + AppRoute.Swap( + currency = cryptoCurrencyStatus.currency, + userWalletId = userWalletId, + ), + ) } override fun onExploreClick() { showErrorIfDemoModeOrElse(action = ::openExplorer) } - override fun onStakeClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + override fun onAnalyticsClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + viewModelScope.launch { + val rawId = cryptoCurrencyStatus.currency.id.rawCurrencyId ?: return@launch + + val tokenMarketParams = TokenMarketParams( + id = rawId, + name = cryptoCurrencyStatus.currency.name, + symbol = cryptoCurrencyStatus.currency.symbol, + tokenQuotes = TokenMarketParams.Quotes( + currentPrice = cryptoCurrencyStatus.value.fiatRate ?: BigDecimal.ZERO, + h24Percent = cryptoCurrencyStatus.value.priceChange, + weekPercent = null, + monthPercent = null, + ), + imageUrl = cryptoCurrencyStatus.currency.iconUrl, + ) + appRouter.push( + AppRoute.MarketsTokenDetails( + token = tokenMarketParams, + appCurrency = getSelectedAppCurrencyUseCase.unwrap(), + showPortfolio = false, + analyticsParams = AppRoute.MarketsTokenDetails.AnalyticsParams( + blockchain = cryptoCurrencyStatus.currency.network.name, + source = "Main", + ), + ), + ) + } + } + + override fun onStakeClick(cryptoCurrencyStatus: CryptoCurrencyStatus, yield: Yield?) { val userWallet = getSelectedWalletSyncUseCase.unwrap() ?: return stateHolder.update(CloseBottomSheetTransformer(userWalletId = userWallet.walletId)) viewModelScope.launch { val userWalletId = stateHolder.getSelectedWalletId() val cryptoCurrency = cryptoCurrencyStatus.currency - val yield = getYieldUseCase.invoke( - cryptoCurrencyId = cryptoCurrency.id, - symbol = cryptoCurrency.symbol, - ).getOrElse { - error("Staking is unavailable for ${cryptoCurrency.name}") - } - reduxStateHolder.dispatch( - TradeCryptoAction.Stake( + appRouter.push( + AppRoute.Staking( userWalletId = userWalletId, cryptoCurrencyId = cryptoCurrency.id, - yield = yield, + yield = yield ?: return@launch, ), ) } @@ -477,14 +513,12 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private fun handleUnavailabilityReason(unavailabilityReason: ScenarioUnavailabilityReason): Boolean { if (unavailabilityReason == ScenarioUnavailabilityReason.None) return false - val unavailabilityReasonText = getUnavailabilityReasonText(unavailabilityReason) - viewModelScope.launch(dispatchers.main) { walletEventSender.send( event = WalletEvent.ShowAlert( state = WalletAlertState.DefaultAlert( title = null, - message = unavailabilityReasonText, + message = unavailabilityReason.getUnavailabilityReasonText(), onConfirmClick = null, ), ), @@ -493,66 +527,4 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( return true } - - private fun getUnavailabilityReasonText(unavailabilityReason: ScenarioUnavailabilityReason): TextReference { - return when (unavailabilityReason) { - is ScenarioUnavailabilityReason.StakingUnavailable -> { - resourceReference( - id = R.string.token_button_unavailability_reason_staking_unavailable, - formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName), - ) - } - is ScenarioUnavailabilityReason.PendingTransaction -> { - when (unavailabilityReason.withdrawalScenario) { - ScenarioUnavailabilityReason.WithdrawalScenario.SEND -> resourceReference( - id = R.string.token_button_unavailability_reason_pending_transaction_send, - formatArgs = wrappedList(unavailabilityReason.networkName), - ) - ScenarioUnavailabilityReason.WithdrawalScenario.SELL -> resourceReference( - id = R.string.token_button_unavailability_reason_pending_transaction_sell, - formatArgs = wrappedList(unavailabilityReason.networkName), - ) - } - } - is ScenarioUnavailabilityReason.EmptyBalance -> { - when (unavailabilityReason.withdrawalScenario) { - ScenarioUnavailabilityReason.WithdrawalScenario.SEND -> resourceReference( - id = R.string.token_button_unavailability_reason_empty_balance_send, - ) - ScenarioUnavailabilityReason.WithdrawalScenario.SELL -> resourceReference( - id = R.string.token_button_unavailability_reason_empty_balance_sell, - ) - } - } - is ScenarioUnavailabilityReason.BuyUnavailable -> { - resourceReference( - id = R.string.token_button_unavailability_reason_buy_unavailable, - formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName), - ) - } - is ScenarioUnavailabilityReason.NotExchangeable -> { - resourceReference( - id = R.string.token_button_unavailability_reason_not_exchangeable, - formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName), - ) - } - is ScenarioUnavailabilityReason.NotSupportedBySellService -> { - resourceReference( - id = R.string.token_button_unavailability_reason_sell_unavailable, - formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName), - ) - } - ScenarioUnavailabilityReason.Unreachable -> { - resourceReference( - id = R.string.token_button_unavailability_generic_description, - ) - } - ScenarioUnavailabilityReason.UnassociatedAsset -> resourceReference( - id = R.string.warning_receive_blocked_hedera_token_association_required_message, - ) - ScenarioUnavailabilityReason.None -> { - throw IllegalArgumentException("The unavailability reason must be other than None") - } - } - } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletWarningsClickIntents.kt index 0e557c8e46..6798d988ff 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletWarningsClickIntents.kt @@ -126,7 +126,10 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( userWalletId = userWallet.walletId, currencies = missedAddressCurrencies, ) - .onRight { fetchTokenListUseCase(userWalletId = userWallet.walletId) } + .onRight { + // Refresh must be set to true to ensure that yield balances are updated + fetchTokenListUseCase(userWalletId = userWallet.walletId, refresh = true) + } .onLeft { Timber.e("Failed to derive public keys: $it") } } } diff --git a/features/wallet/impl/src/main/res/drawable/ill_traillant_card2_120_106.webp b/features/wallet/impl/src/main/res/drawable/ill_trillant_card2_120_106.webp similarity index 100% rename from features/wallet/impl/src/main/res/drawable/ill_traillant_card2_120_106.webp rename to features/wallet/impl/src/main/res/drawable/ill_trillant_card2_120_106.webp diff --git a/features/wallet/impl/src/main/res/drawable/ill_traillant_card3_120_106.webp b/features/wallet/impl/src/main/res/drawable/ill_trillant_card3_120_106.webp similarity index 100% rename from features/wallet/impl/src/main/res/drawable/ill_traillant_card3_120_106.webp rename to features/wallet/impl/src/main/res/drawable/ill_trillant_card3_120_106.webp diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/NoteImageTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/NoteImageTest.kt new file mode 100644 index 0000000000..c6da034569 --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/NoteImageTest.kt @@ -0,0 +1,26 @@ +package com.tangem.feature.wallet.presentation.wallet.domain + +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class NoteImageTest { + + @Test + fun check() { + // check uniqueness + Truth.assertThat(NoteImage.entries.map { it.blockchain }).containsNoDuplicates() + Truth.assertThat(NoteImage.entries.map { it.imageResId }).containsNoDuplicates() + + // check blockchain matching + Truth.assertThat(NoteImage.Bitcoin.blockchain).isEqualTo(Blockchain.Bitcoin) + Truth.assertThat(NoteImage.Ethereum.blockchain).isEqualTo(Blockchain.Ethereum) + Truth.assertThat(NoteImage.Binance.blockchain).isEqualTo(Blockchain.BSC) + Truth.assertThat(NoteImage.Dogecoin.blockchain).isEqualTo(Blockchain.Dogecoin) + Truth.assertThat(NoteImage.Cardano.blockchain).isEqualTo(Blockchain.Cardano) + Truth.assertThat(NoteImage.XRP.blockchain).isEqualTo(Blockchain.XRP) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImageTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImageTest.kt new file mode 100644 index 0000000000..13d88a97fd --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImageTest.kt @@ -0,0 +1,22 @@ +package com.tangem.feature.wallet.presentation.wallet.domain + +import com.google.common.truth.Truth +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class Wallet2CobrandImageTest { + + @Test + fun checkUniqueness() { + val excluded = listOf(Wallet2CobrandImage.Pastel, Wallet2CobrandImage.Vivid) + (Wallet2CobrandImage.entries - excluded).forEach { + Truth.assertThat(it.cards2ResId != it.cards3ResId).isTrue() + } + + Truth.assertThat(Wallet2CobrandImage.entries.map { it.cards2ResId }).containsNoDuplicates() + Truth.assertThat(Wallet2CobrandImage.entries.map { it.cards3ResId }).containsNoDuplicates() + Truth.assertThat(Wallet2CobrandImage.entries.flatMap { it.batchIds }).containsNoDuplicates() + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index b2ef2745d2..c4b0b8ac32 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,6 +13,5 @@ org.gradle.jvmargs = -Xmx6144m -XX:MaxMetaspaceSize=768m -XX: org.gradle.parallel = true org.gradle.daemon = true android.useAndroidX = true -android.enableJetifier = true org.gradle.unsafe.configuration-cache = true android.nonTransitiveRClass = false diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 5a8e078bec..c009175e70 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -26,10 +26,10 @@ androidxWindowManager = "1.3.0" # region Compose compose-compiler = "1.5.9" -compose-runtime = "1.6.1" -compose-foundation = "1.6.1" -compose-material = "1.6.4" -compose-material3 = "1.2.0" +compose-runtime = "1.7.1" +compose-foundation = "1.7.1" +compose-material = "1.7.1" +compose-material3 = "1.3.0" compose-constraint = "1.0.1" compose-navigation = "2.7.7" compose-accompanist = "0.30.1" @@ -85,12 +85,13 @@ leakcanary = "2.13" decompose = "2.2.2" room = "2.6.1" markdown = "0.7.2" +markdownComposeView = "0.5.4" # endregion Other libraries # region Tangem -tangemBlockchainSdk = "release-app_5.14-772" +tangemBlockchainSdk = "develop-775" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "release-app_5.14-379" +tangemCardSdk = "develop-381" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem16" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ @@ -270,4 +271,5 @@ room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } markdown = { module = "org.jetbrains:markdown", version.ref = "markdown" } +markdown-composeview = { module = "com.github.jeziellago:compose-markdown", version.ref = "markdownComposeView" } # endregion Other diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt index 8fb67b9107..2172977e82 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt @@ -45,6 +45,10 @@ internal class DefaultBlockchainSDKFactory( private val mainScope = CoroutineScope(dispatchers.main) private val walletManagerFactory: Flow = createWalletManagerFactory() + // TODO: [REDACTED_JIRA] + // private val walletManagerFactory: Flow by lazy(LazyThreadSafetyMode.NONE) { + // createWalletManagerFactory() + // } override suspend fun init() { coroutineScope { diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt index e7224f8434..4b8a887b99 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt @@ -34,7 +34,7 @@ internal class WalletManagerFactoryCreator @Inject constructor( blockchainProviderTypes = blockchainProviderTypes, accountCreator = accountCreator, featureToggles = BlockchainFeatureToggles( - isCardanoTokenSupport = blockchainSDKFeatureToggles.isCardanoTokensSupportEnabled, + isEthereumEIP1559Enabled = blockchainSDKFeatureToggles.isEthereumEIP1559Enabled, ), blockchainDataStorage = blockchainDataStorage, loggers = listOf(blockchainSDKLogger), diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/compatibility/L2Networks.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/compatibility/L2Networks.kt new file mode 100644 index 0000000000..963d14bf12 --- /dev/null +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/compatibility/L2Networks.kt @@ -0,0 +1,69 @@ +package com.tangem.blockchainsdk.compatibility + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toCoinId +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.datasource.api.markets.models.response.TokenMarketInfoResponse +import com.tangem.datasource.api.tangemTech.models.CoinsResponse +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse + +val l2BlockchainsList = listOf( + Blockchain.Optimism, + Blockchain.Arbitrum, + Blockchain.ZkSyncEra, + Blockchain.Manta, + Blockchain.PolygonZkEVM, + Blockchain.Aurora, + Blockchain.Base, + Blockchain.Blast, + Blockchain.Cyber, +) + +val l2BlockchainsCoinIds = l2BlockchainsList.map { it.toCoinId() } + +val ETHEREUM_COIN_ID = Blockchain.Ethereum.toCoinId() + +fun getL2CompatibilityTokenComparison(token: UserTokensResponse.Token, currencyId: String): Boolean { + return if (currencyId == ETHEREUM_COIN_ID) { + l2BlockchainsCoinIds.contains(token.id) || currencyId == token.id + } else { + token.id == currencyId + } +} + +fun List.applyL2Compatibility(coinId: String): List { + return if (coinId == ETHEREUM_COIN_ID) { + val l2Networks = l2BlockchainsList.map { + CoinsResponse.Coin.Network( + networkId = it.toNetworkId(), + ) + } + this + l2Networks + } else { + this + } +} + +fun TokenMarketInfoResponse.applyL2Compatibility(coinId: String): TokenMarketInfoResponse { + val networks = this.networks ?: return this + return if (coinId == ETHEREUM_COIN_ID) { + val l2Networks = l2BlockchainsList.map { + TokenMarketInfoResponse.Network( + networkId = it.toNetworkId(), + contractAddress = null, + decimalCount = null, + ) + } + this.copy(networks = networks + l2Networks) + } else { + this + } +} + +fun getTokenIdIfL2Network(tokenId: String): String { + return if (l2BlockchainsCoinIds.contains(tokenId)) { + ETHEREUM_COIN_ID + } else { + tokenId + } +} \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/BlockchainSDKFeatureToggles.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/BlockchainSDKFeatureToggles.kt index fcdc2099b3..482df0cd5a 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/BlockchainSDKFeatureToggles.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/BlockchainSDKFeatureToggles.kt @@ -2,5 +2,5 @@ package com.tangem.blockchainsdk.featuretoggles internal interface BlockchainSDKFeatureToggles { - val isCardanoTokensSupportEnabled: Boolean + val isEthereumEIP1559Enabled: Boolean } \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/DefaultBlockchainSDKFeatureToggles.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/DefaultBlockchainSDKFeatureToggles.kt index ce7241d7d5..29df94634c 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/DefaultBlockchainSDKFeatureToggles.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/DefaultBlockchainSDKFeatureToggles.kt @@ -6,6 +6,6 @@ internal class DefaultBlockchainSDKFeatureToggles( private val featureTogglesManager: FeatureTogglesManager, ) : BlockchainSDKFeatureToggles { - override val isCardanoTokensSupportEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(name = "CARDANO_TOKENS_SUPPORT_ENABLED") + override val isEthereumEIP1559Enabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "IS_ETHEREUM_EIP_1559_ENABLED") } \ No newline at end of file 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 70bce8cb4a..cddafe6527 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 @@ -123,6 +123,9 @@ fun Blockchain.Companion.fromNetworkId(networkId: String): Blockchain? { "blast/test" -> Blockchain.BlastTestnet "cyber" -> Blockchain.Cyber "cyber/test" -> Blockchain.CyberTestnet + "sei-network" -> Blockchain.Sei + "sei-network/test" -> Blockchain.SeiTestnet + "internet-computer" -> Blockchain.InternetComputer else -> null } } @@ -247,6 +250,9 @@ fun Blockchain.toNetworkId(): String { Blockchain.BlastTestnet -> "blast/test" Blockchain.Cyber -> "cyber" Blockchain.CyberTestnet -> "cyber/test" + Blockchain.Sei -> "sei-network" + Blockchain.SeiTestnet -> "sei-network/test" + Blockchain.InternetComputer -> "internet-computer" } } @@ -329,6 +335,8 @@ fun Blockchain.toCoinId(): String { Blockchain.Filecoin -> "filecoin" Blockchain.Blast, Blockchain.BlastTestnet -> "blast-ethereum" Blockchain.Cyber, Blockchain.CyberTestnet -> "cyber-ethereum" + Blockchain.Sei, Blockchain.SeiTestnet -> "sei-network" + Blockchain.InternetComputer -> "internet-computer" } } @@ -359,4 +367,6 @@ private val excludedBlockchains = listOf( Blockchain.Unknown, Blockchain.Nexa, Blockchain.NexaTestnet, + Blockchain.Sei, + Blockchain.InternetComputer, ) \ No newline at end of file diff --git a/libs/crypto/build.gradle.kts b/libs/crypto/build.gradle.kts index 9a8ccaea31..1e443985c3 100644 --- a/libs/crypto/build.gradle.kts +++ b/libs/crypto/build.gradle.kts @@ -20,4 +20,7 @@ dependencies { /** Core */ implementation(projects.core.utils) + + /** Libs */ + implementation(projects.libs.blockchainSdk) } \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt index 30767aeb4b..1c340dabc3 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt @@ -2,6 +2,9 @@ package com.tangem.lib.crypto import com.tangem.blockchain.blockchains.xrp.XrpAddressService import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.compatibility.l2BlockchainsList +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.blockchainsdk.utils.isSupportedInApp import com.tangem.lib.crypto.converter.XrpTaggedAddressConverter import com.tangem.lib.crypto.models.XrpTaggedAddress @@ -33,12 +36,6 @@ object BlockchainUtils { return blockchain == Blockchain.Bitcoin || blockchain == Blockchain.BitcoinTestnet } - /** If current [networkId] is Dogecoin */ - fun isDogecoin(networkId: String): Boolean { - val blockchain = Blockchain.fromId(networkId) - return blockchain == Blockchain.Dogecoin - } - /** If current [networkId] is Tezos */ fun isTezos(networkId: String): Boolean { val blockchain = Blockchain.fromId(networkId) @@ -61,4 +58,69 @@ object BlockchainUtils { val blockchain = Blockchain.fromId(networkId) return blockchain == Blockchain.Polygon || blockchain == Blockchain.PolygonTestnet } + + fun isTron(networkId: String): Boolean { + val blockchain = Blockchain.fromId(networkId) + return blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet + } + + fun isSupportedNetworkId(networkId: String): Boolean { + return Blockchain.fromNetworkId(networkId)?.isSupportedInApp() ?: false + } + + fun isArbitrum(networkId: String): Boolean { + val blockchain = Blockchain.fromId(networkId) + return blockchain == Blockchain.Arbitrum + } + + fun isSolana(networkId: String): Boolean { + val blockchain = Blockchain.fromId(networkId) + return blockchain == Blockchain.Solana + } + + fun isPolkadot(networkId: String): Boolean { + val blockchain = Blockchain.fromId(networkId) + return blockchain == Blockchain.Polkadot || blockchain == Blockchain.PolkadotTestnet + } + + fun isCosmos(networkId: String): Boolean { + val blockchain = Blockchain.fromId(networkId) + return blockchain == Blockchain.Cosmos || blockchain == Blockchain.CosmosTestnet + } + + data class BlockchainInfo( + val blockchainId: String, + val name: String, + val protocolName: String, + ) + + fun getNetworkInfo(networkId: String): BlockchainInfo? { + val blockchain = Blockchain.fromNetworkId(networkId) ?: return null + + return BlockchainInfo( + blockchainId = blockchain.id, + name = getNetworkNameWithoutTestnet(blockchain), + protocolName = getNetworkStandardName(blockchain), + ) + } + + fun isL2Network(networkId: String): Boolean { + val blockchain = Blockchain.fromNetworkId(networkId) ?: return false + return l2BlockchainsList.contains(blockchain) + } + + private fun getNetworkStandardName(blockchain: Blockchain): String { + return when (blockchain) { + Blockchain.Ethereum, Blockchain.EthereumTestnet -> "ERC20" + Blockchain.BSC, Blockchain.BSCTestnet -> "BEP20" + Blockchain.Binance, Blockchain.BinanceTestnet -> "BEP2" + Blockchain.Tron, Blockchain.TronTestnet -> "TRC20" + Blockchain.TON -> "TON" + else -> "" + } + } + + private fun getNetworkNameWithoutTestnet(blockchain: Blockchain): String { + return blockchain.getNetworkName().replace(oldValue = " Testnet", newValue = "") + } } \ No newline at end of file diff --git a/lokalize.py b/lokalize.py new file mode 100644 index 0000000000..3906426d80 --- /dev/null +++ b/lokalize.py @@ -0,0 +1,87 @@ +# Steps to use: +# 1) Install Python (depends on OS) +# 2) Install Lokalise API for python: +# > pip3 install python-lokalise-api --break-system-packages +# 3) Obtain token here: https://app.lokalise.com/profile#apitokens +# 4) Create file local.properties (if not exists) and define variables there: +# > lokalise.project.id=[PROJECT_ID] +# > lokalise.token=[YOUR_TOKEN] +# 5) Use script when you need to download translations: +# > python3 lokalize.py +# In case you want to filter languages, just add --langs argument with specific values +# > python3 lokalize.py --langs 'en' 'ru' + +import os +import urllib.request +import zipfile +import lokalise +import configparser +import argparse +import sys +import shutil + +config = configparser.ConfigParser() + +parser = argparse.ArgumentParser() +parser.add_argument( + "--langs", + nargs="*", + type=str, + help="--langs 'en' 'ru'", + default=[], +) + +config_file_path = "local.properties" +section_default = "default" +project_id_key = "lokalise.project.id" +token_key = "lokalise.token" + +directory_prefix = "core/res/src/main/res/values-%LANG_ISO%/" + +if not os.path.isfile(config_file_path): + print("{config_file_path} not found!") +else: + with open(config_file_path) as stream: + config.read_string(f'[{section_default}]\n' + stream.read()) + api_token = config.get(section_default, token_key, fallback=None) + project_id = config.get(section_default, project_id_key, fallback=None) + if project_id is None: + print(f"Key '{project_id_key}' not found in file {config_file_path}") + elif api_token is None: + print(f"Key '{token_key}' not found in file {config_file_path}") + else: + print(f"Found project: {project_id_key}") + print(f"Found token: {api_token}") + + folder_path = "." + file_name = "lokalize.zip" + + client = lokalise.Client(api_token) + project = client.project(project_id) + + print('Found project: ' + project.name) + + print('Read args...') + args = parser.parse_args() + + print("Generating bundle...") + response = client.download_files(project_id, { + "format": "xml", + "original_filenames": True, + "filter_langs": args.langs, + "directory_prefix": directory_prefix, + "filter_data": ["translated"] + }) + + print("Downloading translation file...") + bundle_url = response['bundle_url'] + urllib.request.urlretrieve(bundle_url, file_name) + + print("Unzipping archive...") + with zipfile.ZipFile(file_name, 'r') as zip_ref: + zip_ref.extractall(folder_path) + + print("Removing archive...") + os.remove(file_name) + + print("All done!") diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/AppConfig.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/AppConfig.kt index ad8aa17560..3db7478662 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/AppConfig.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/AppConfig.kt @@ -4,6 +4,7 @@ internal object AppConfig { const val packageName = "com.tangem.wallet" const val versionCode = 1 const val versionName = "1.0.0-SNAPSHOT" + // const val versionName = "100.0.0-SNAPSHOT" //TODO: [REDACTED_JIRA] const val minSdkVersion = 24 const val targetSdkVersion = 34 const val compileSdkVersion = 34 diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt index 53a1c18637..27271c7cea 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt @@ -69,7 +69,7 @@ internal enum class BuildType( appIdSuffix = "internal", versionSuffix = "internal", configFields = listOf( - BuildConfigField.Environment(value = "dev"), + BuildConfigField.Environment(value = "prod"), BuildConfigField.TestActionEnabled(isEnabled = true), BuildConfigField.LogEnabled(isEnabled = true), BuildConfigField.TesterMenuAvailability(isEnabled = true), diff --git a/settings.gradle.kts b/settings.gradle.kts index 740ce7ce0c..1940d94012 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -95,8 +95,18 @@ dependencyResolutionManagement { includeGroupAndSubgroups("com.tangem.vico") } } + maven { + // setting any repository from tangem project allows maven search all packages in the project + url = uri("https://maven.pkg.github.com/tangem/ic4j-agent") + credentials { + username = properties.getProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR") + password = properties.getProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN") + } + content { + includeGroupAndSubgroups("com.tangem.ic4j") + } + } maven("https://jitpack.io") - maven("https://clients-nexus.sprinklr.com/") } versionCatalogs { diff --git a/version.properties b/version.properties index 16ef03c0ac..8c32b86fe1 100644 --- a/version.properties +++ b/version.properties @@ -1 +1 @@ -versionName=5.14.0 \ No newline at end of file +versionName=5.15.0 \ No newline at end of file