diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d8d88a7037..85b539e159 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,6 +18,7 @@ android { namespace = "com.tangem.wallet" testOptions { animationsDisabled = true + execution = "ANDROIDX_TEST_ORCHESTRATOR" } packaging { jniLibs { @@ -71,6 +72,10 @@ configurations.all { } } +configurations.androidTestImplementation { + exclude(module = "protobuf-lite") +} + dependencies { implementation(projects.domain.legacy) @@ -114,6 +119,8 @@ dependencies { implementation(projects.domain.notifications) implementation(projects.domain.notifications.models) implementation(projects.domain.notifications.toggles) + implementation(projects.domain.swap.models) + implementation(projects.domain.swap) implementation(projects.common) implementation(projects.common.routing) @@ -160,6 +167,7 @@ dependencies { implementation(projects.data.quotes) implementation(projects.data.blockaid) implementation(projects.data.notifications) + implementation(projects.data.swap) /** Features */ implementation(projects.features.referral.impl) @@ -170,6 +178,8 @@ dependencies { implementation(projects.features.swap.domain) implementation(projects.features.swap.domain.api) implementation(projects.features.swap.data) + implementation(projects.features.swapV2.api) + implementation(projects.features.swapV2.impl) implementation(projects.features.tester.api) implementation(projects.features.tester.impl) implementation(projects.features.wallet.api) @@ -210,8 +220,12 @@ dependencies { implementation(projects.features.walletconnect.impl) implementation(projects.features.usedesk.api) implementation(projects.features.usedesk.impl) - implementation(projects.features.feeSelector.api) - implementation(projects.features.feeSelector.impl) + implementation(projects.features.hotWallet.api) + implementation(projects.features.hotWallet.impl) + implementation(projects.features.kyc.api) + implementation(projects.features.kyc.impl) + implementation(projects.features.welcome.api) + implementation(projects.features.welcome.impl) /** AndroidX libraries */ implementation(deps.androidx.core.ktx) @@ -310,19 +324,13 @@ dependencies { testImplementation(deps.test.mockk) testImplementation(deps.test.truth) androidTestImplementation(deps.test.junit.android) - androidTestImplementation(deps.test.espresso){ - exclude(group = "com.google.protobuf", module = "protobuf-lite") //conflicting with firebasePerf - } + androidTestImplementation(deps.test.espresso) androidTestImplementation(deps.test.espresso.intents) - { - exclude(group = "com.google.protobuf", module = "protobuf-lite") //conflicting with firebasePerf - } + androidTestImplementation(deps.test.kaspresso) + androidTestImplementation(deps.test.kaspresso.compose) androidTestImplementation(deps.test.compose.junit) androidTestImplementation(deps.test.hamcrest) androidTestImplementation(deps.test.hilt) - androidTestImplementation(deps.test.ultron.android) - androidTestImplementation(deps.test.ultron.compose) - androidTestImplementation(deps.test.ultron.allure) kaptAndroidTest(deps.test.hilt.compiler) /** Chucker */ @@ -340,6 +348,8 @@ dependencies { implementation(deps.listenableFuture) implementation(deps.mlKit.barcodeScanning) + androidTestUtil(deps.test.orchestrator) + /** Leakcanary */ debugImplementation(deps.leakcanary) diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index b028458c9b..b5c7601cde 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -1,66 +1,68 @@ package com.tangem.common import android.Manifest +import android.content.Context +import android.util.Log +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.datastore.dataStoreFile +import androidx.test.espresso.intent.Intents +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule -import com.atiurin.ultron.core.compose.config.UltronComposeConfig -import com.atiurin.ultron.core.compose.createUltronComposeRule -import com.atiurin.ultron.core.compose.listeners.ComposDebugListener -import com.atiurin.ultron.core.config.UltronCommonConfig -import com.atiurin.ultron.core.config.UltronConfig -import com.atiurin.ultron.core.test.UltronTest +import com.kaspersky.components.composesupport.config.withComposeSupport +import com.kaspersky.kaspresso.kaspresso.Kaspresso +import com.kaspersky.kaspresso.testcases.api.testcase.TestCase import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.MainActivity import dagger.hilt.android.testing.HiltAndroidRule import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -import org.junit.BeforeClass import org.junit.Rule +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.RunWith import javax.inject.Inject -abstract class BaseTestCase : UltronTest() { +abstract class BaseTestCase : TestCase( + kaspressoBuilder = Kaspresso.Builder.withComposeSupport() +) { + @Inject lateinit var tangemSdkManager: TangemSdkManager @Inject lateinit var appPreferencesStore: AppPreferencesStore - @get:Rule(order = 0) - val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( + private val hiltRule = HiltAndroidRule(this) + private val permissionRule = GrantPermissionRule.grant( Manifest.permission.POST_NOTIFICATIONS, - Manifest.permission.CAMERA + Manifest.permission.CAMERA, ) - @get:Rule(order = 1) - val hiltRule = HiltAndroidRule(this) + val composeTestRule = createAndroidComposeRule() - @get:Rule (order = 2) - val injectionRule = ApplicationInjectionExecutionRule() + @Rule + @JvmField + val ruleChain: TestRule = RuleChain + .outerRule(hiltRule) + .around(ApplicationInjectionExecutionRule()) + .around(permissionRule) + .around(composeTestRule) - @get:Rule(order = 3) - val composeRule = createUltronComposeRule() - - override val beforeTest: () -> Unit = { + protected fun setupHooks( + additionalBeforeSection: () -> Unit = {}, + additionalAfterSection: () -> Unit = {}, + ) = before { hiltRule.inject() - runBlocking { - delay(INIT_DELAY) - } - } - - override val afterTest: () -> Unit = { - runBlocking { - appPreferencesStore.editData { prefs -> prefs.clear() } - } + Intents.init() + additionalBeforeSection() + }.after { + additionalAfterSection() + Intents.release() } companion object { - @BeforeClass - @JvmStatic - fun config() { - UltronConfig.applyRecommended() - UltronComposeConfig.applyRecommended() - UltronCommonConfig.addListener(ComposDebugListener()) - } - - private const val INIT_DELAY = 2000L + private const val INIT_DELAY = 1000L } + } \ No newline at end of file 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/common/extensions/TestDataUtils.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/TestDataUtils.kt deleted file mode 100644 index 671c7e4c60..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/TestDataUtils.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tangem.common.extensions - -import androidx.test.platform.app.InstrumentationRegistry - -object TestDataUtils { - fun getResourceString(resourceId: Int): String { - return InstrumentationRegistry.getInstrumentation().targetContext.resources.getString(resourceId) - } -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MainPageScenario.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MainPageScenario.kt deleted file mode 100644 index 2513c160b0..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/MainPageScenario.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.tangem.scenarios - -import com.atiurin.ultron.allure.step.step -import com.atiurin.ultron.extensions.assertIsDisplayed -import com.atiurin.ultron.extensions.click -import com.tangem.domain.models.scan.ProductType -import com.tangem.screens.DisclaimerPage -import com.tangem.screens.MainPage -import com.tangem.screens.StoriesPage -import com.tangem.tap.domain.sdk.mocks.MockProvider - -object MainPageScenario { - fun open(productType: ProductType? = null) { - if (productType != null) { - MockProvider.setMocks(productType) - } - step("Click on \"Accept\" button") { - DisclaimerPage.acceptButton.click() - } - step("Click on \"Scan\" button emulating scan error") { - StoriesPage.scanButton.click() - } - step("Assert: main is displayed") { - MainPage.container.assertIsDisplayed() - } - } -} \ 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..cd966f6a0f --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/OpenMainScreenScenario.kt @@ -0,0 +1,44 @@ +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.screens.TestTopBar +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() + } + } + ComposeScreen.onComposeScreen(testRule) { + step("Close Markets tooltip"){ + performClick() + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DetailsPage.kt b/app/src/androidTest/kotlin/com/tangem/screens/DetailsPage.kt deleted file mode 100644 index 990f09205c..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/screens/DetailsPage.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tangem.screens - -import androidx.compose.ui.test.hasText -import com.atiurin.ultron.page.Page -import com.tangem.common.extensions.TestDataUtils.getResourceString -import com.tangem.wallet.R - -object DetailsPage : Page() { - val walletConnectButton = hasText(getResourceString(R.string.wallet_connect_title)) - val walletNameButton = hasText(getResourceString(R.string.manage_tokens_network_selector_wallet)) - val scanCardButton = hasText(getResourceString(R.string.scan_card_settings_button)) - val buyTangemButton = hasText(getResourceString(R.string.details_buy_wallet)) - val appSettingsButton = hasText(getResourceString(R.string.app_settings_title)) - val contactSupportButton = hasText(getResourceString(R.string.details_row_title_contact_to_support)) - val toSButton = hasText(getResourceString(R.string.disclaimer_title)) -} \ 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/DisclaimerPage.kt b/app/src/androidTest/kotlin/com/tangem/screens/DisclaimerPage.kt deleted file mode 100644 index b8dbd41150..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/screens/DisclaimerPage.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tangem.screens - -import androidx.compose.ui.test.hasTestTag -import com.atiurin.ultron.page.Page -import com.tangem.core.ui.test.TestTags - -object DisclaimerPage : Page() { - val acceptButton = hasTestTag(TestTags.DISCLAIMER_SCREEN_ACCEPT_BUTTON) -} \ 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/MainPage.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainPage.kt deleted file mode 100644 index f9016b3f69..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainPage.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tangem.screens - -import androidx.compose.ui.test.hasTestTag -import com.atiurin.ultron.page.Page -import com.tangem.core.ui.test.TestTags - -object MainPage : Page() { - val container = hasTestTag(TestTags.MAIN_SCREEN) -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainTestScreen.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainTestScreen.kt new file mode 100644 index 0000000000..a342754478 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainTestScreen.kt @@ -0,0 +1,11 @@ +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 + +class MainTestScreen(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(TestTags.MAIN_SCREEN) } + ) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/StoriesPage.kt b/app/src/androidTest/kotlin/com/tangem/screens/StoriesPage.kt deleted file mode 100644 index 51f472521a..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/screens/StoriesPage.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.screens - -import androidx.compose.ui.test.hasTestTag -import com.atiurin.ultron.page.Page -import com.tangem.core.ui.test.TestTags - -object StoriesPage : Page() { - val scanButton = hasTestTag(TestTags.STORIES_SCREEN_SCAN_BUTTON) - val orderButton = hasTestTag(TestTags.STORIES_SCREEN_ORDER_BUTTON) -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/StoriesTestScreen.kt b/app/src/androidTest/kotlin/com/tangem/screens/StoriesTestScreen.kt new file mode 100644 index 0000000000..9dd1a415e9 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/StoriesTestScreen.kt @@ -0,0 +1,32 @@ +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.views.KView +import io.github.kakaocup.kakao.text.KButton + +class StoriesTestScreen(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(TestTags.STORIES_SCREEN) } + ) { + + val scanButton: KNode = child { + hasTestTag(TestTags.STORIES_SCREEN_SCAN_BUTTON) + } + + val orderButton: KNode = child { + hasTestTag(TestTags.STORIES_SCREEN_ORDER_BUTTON) + } + + val enableNFCAlert: KView = KView { + withId(R.id.alertTitle) + } + + val cancelButton: KButton = KButton { + withId(android.R.id.button2) + } +} \ No newline at end of file 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/TopBarPage.kt b/app/src/androidTest/kotlin/com/tangem/screens/TopBarPage.kt deleted file mode 100644 index 2505ffae8b..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/screens/TopBarPage.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tangem.screens - -import androidx.compose.ui.test.hasTestTag -import com.atiurin.ultron.page.Page -import com.tangem.core.ui.test.TestTags - -object TopBarPage : Page() { - val moreButton = hasTestTag(TestTags.MAIN_SCREEN_MORE_BUTTON) -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPage.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPage.kt deleted file mode 100644 index a38559a523..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPage.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.screens - -import androidx.compose.ui.test.hasText -import com.atiurin.ultron.page.Page -import com.tangem.common.extensions.TestDataUtils.getResourceString -import com.tangem.wallet.R - -object WalletSettingsPage : Page() { - val linkMoreCardsButton = hasText(getResourceString(R.string.details_row_title_create_backup)) - val cardSettingsButton = hasText(getResourceString(R.string.card_settings_title)) - val referralProgramButton = hasText(getResourceString(R.string.details_referral_title)) - val forgetWalletButton = hasText(getResourceString(R.string.settings_forget_wallet)) -} \ 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 index 1951caf4d7..dd86a046cc 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/DetailsScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/DetailsScreenTest.kt @@ -1,138 +1,157 @@ package com.tangem.tests -import com.atiurin.ultron.allure.step.step -import com.atiurin.ultron.extensions.assertIsDisplayed -import com.atiurin.ultron.extensions.assertIsNotDisplayed -import com.atiurin.ultron.extensions.click import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion import com.tangem.domain.models.scan.ProductType -import com.tangem.scenarios.MainPageScenario -import com.tangem.screens.DetailsPage -import com.tangem.screens.TopBarPage -import com.tangem.screens.WalletSettingsPage +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() { - MainPageScenario.open() - step("Open wallet details") { - TopBarPage.moreButton.click() + 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() + } + } } - step("Assert wallet connect button is visible") { - DetailsPage.walletConnectButton.assertIsDisplayed() - } - step("Assert scan card button is visible") { - DetailsPage.scanCardButton.assertIsDisplayed() - } - step("Assert buy Tangem card button is visible") { - DetailsPage.buyTangemButton.assertIsDisplayed() - } - step("Assert app settings button is visible") { - DetailsPage.appSettingsButton.assertIsDisplayed() - } - step("Assert contact support button is visible") { - DetailsPage.contactSupportButton.assertIsDisplayed() - } - step("Assert terms or service button is visible") { - DetailsPage.toSButton.assertIsDisplayed() - } - step("Open wallet settings screen") { - DetailsPage.walletNameButton.click() - } - step("Assert Link more cards button is visible") { - WalletSettingsPage.linkMoreCardsButton.assertIsDisplayed() - } - step("Assert Card Settings button is visible") { - WalletSettingsPage.cardSettingsButton.assertIsDisplayed() - } - step("Assert Referral program button is visible") { - WalletSettingsPage.referralProgramButton.assertIsDisplayed() - } - step("Assert Forget wallet button is visible") { - WalletSettingsPage.forgetWalletButton.assertIsDisplayed() - } - } @Test - fun wallet2Details() { - MainPageScenario.open(ProductType.Wallet2) - step("Open wallet details") { - TopBarPage.moreButton.click() + 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() + } + } } - step("Assert wallet connect button is visible") { - DetailsPage.walletConnectButton.assertIsDisplayed() - } - step("Assert scan card button is visible") { - DetailsPage.scanCardButton.assertIsDisplayed() - } - step("Assert buy Tangem card button is visible") { - DetailsPage.buyTangemButton.assertIsDisplayed() - } - step("Assert app settings button is visible") { - DetailsPage.appSettingsButton.assertIsDisplayed() - } - step("Assert contact support button is visible") { - DetailsPage.contactSupportButton.assertIsDisplayed() - } - step("Assert terms or service button is visible") { - DetailsPage.toSButton.assertIsDisplayed() - } - step("Open wallet settings screen") { - DetailsPage.walletNameButton.click() - } - step("Assert Link more cards button does not exist") { - WalletSettingsPage.linkMoreCardsButton.assertIsNotDisplayed() - } - step("Assert Card Settings button is visible") { - WalletSettingsPage.cardSettingsButton.assertIsDisplayed() - } - step("Assert Referral program button is visible") { - WalletSettingsPage.referralProgramButton.assertIsDisplayed() - } - step("Assert Forget wallet button is visible") { - WalletSettingsPage.forgetWalletButton.assertIsDisplayed() - } - } @Test - fun noteDetails() { - MainPageScenario.open(ProductType.Note) - step("Open wallet details") { - TopBarPage.moreButton.click() + 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 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() + } + } } - step("Assert wallet connect button does not exist") { - DetailsPage.walletConnectButton.assertIsNotDisplayed() - } - step("Assert scan card button is visible") { - DetailsPage.scanCardButton.assertIsDisplayed() - } - step("Assert buy Tangem card button is visible") { - DetailsPage.buyTangemButton.assertIsDisplayed() - } - step("Assert app settings button is visible") { - DetailsPage.appSettingsButton.assertIsDisplayed() - } - step("Assert contact support button is visible") { - DetailsPage.contactSupportButton.assertIsDisplayed() - } - step("Assert terms or service button is visible") { - DetailsPage.toSButton.assertIsDisplayed() - } - step("Open wallet settings screen") { - DetailsPage.walletNameButton.click() - } - step("Assert Card Settings button is visible") { - WalletSettingsPage.cardSettingsButton.assertIsDisplayed() - } - step("Assert Referral program button does not exist") { - WalletSettingsPage.referralProgramButton.assertIsNotDisplayed() - } - step("Assert Forget wallet button is visible") { - WalletSettingsPage.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 new file mode 100644 index 0000000000..fd3a2df953 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/MainScreenTest.kt @@ -0,0 +1,21 @@ +package com.tangem.tests + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.tangem.common.BaseTestCase +import com.tangem.scenarios.OpenMainScreenScenario +import com.tangem.tap.MainActivity +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class MainScreenTest : BaseTestCase() { + + @Test + fun goToMain() { + setupHooks().run { + scenario(OpenMainScreenScenario(composeTestRule)) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/ScanErrorTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/ScanErrorTest.kt index de198088d3..da686950a7 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/ScanErrorTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/ScanErrorTest.kt @@ -1,42 +1,40 @@ package com.tangem.tests -import com.atiurin.ultron.allure.step.step -import com.atiurin.ultron.extensions.assertIsDisplayed -import com.atiurin.ultron.extensions.assertIsNotDisplayed -import com.atiurin.ultron.extensions.click import com.tangem.common.BaseTestCase -import com.tangem.screens.DisclaimerPage -import com.tangem.screens.MainPage -import com.tangem.screens.StoriesPage +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 import org.junit.Test @HiltAndroidTest class ScanErrorTest : BaseTestCase() { @Test - fun goToMain() { - step("Click on \"Accept\" button") { - DisclaimerPage.acceptButton.click() + fun goToMain() = + setupHooks().run { + ComposeScreen.onComposeScreen(composeTestRule) { + step("Click on \"Accept\" button") { + acceptButton.clickWithAssertion() + } + } + ComposeScreen.onComposeScreen(composeTestRule) { + step("Click on \"Scan\" button emulating scan error") { + MockProvider.setEmulateError() + scanButton.clickWithAssertion() + } + step("Click on \"Scan\" button again without emulating error") { + MockProvider.resetEmulateError() + scanButton.clickWithAssertion() + } + } + ComposeScreen.onComposeScreen(composeTestRule) { + step("Make sure wallet screen is visible") { + assertIsDisplayed() + } + } } - step("Emulate scan error") { - MockProvider.setEmulateError() - } - step("Click on \"Scan\" button emulating scan error") { - StoriesPage.scanButton.click() - } - step("Assert: Error is displayed") { - MainPage.container.assertIsNotDisplayed() - } - step("Emulate success scan") { - MockProvider.resetEmulateError() - } - step("Click on \"Scan\" button") { - StoriesPage.scanButton.click() - } - step("Assert: wallet screen is displayed") { - MainPage.container.assertIsDisplayed() - } - } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/StoriesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/StoriesTest.kt index 5adaaeada1..eab4389f6b 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/StoriesTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/StoriesTest.kt @@ -1,36 +1,41 @@ package com.tangem.tests import android.content.Intent.ACTION_VIEW -import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction -import androidx.test.espresso.intent.matcher.IntentMatchers.hasData -import com.atiurin.ultron.allure.step.step -import com.atiurin.ultron.core.config.UltronConfig.UiAutomator.Companion.uiDevice -import com.atiurin.ultron.extensions.click +import androidx.test.espresso.intent.matcher.UriMatchers import com.tangem.common.BaseTestCase -import com.tangem.screens.DisclaimerPage -import com.tangem.screens.StoriesPage +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 org.hamcrest.core.AllOf.allOf +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.kakao.intent.KIntent +import org.hamcrest.Matchers import org.junit.Test @HiltAndroidTest class StoriesTest : BaseTestCase() { @Test - fun checkOrderButton() { - Intents.init() - step("Click Accept on ToS") { - DisclaimerPage.acceptButton.click() + fun clickOnOrderButton() = + setupHooks().run { + ComposeScreen.onComposeScreen(composeTestRule) { + step("Click on \"Accept\" button") { + acceptButton.clickWithAssertion() + } + } + ComposeScreen.onComposeScreen(composeTestRule) { + step("Click on \"Order\" button") { + orderButton.clickWithAssertion() + } + step("Assert: browser opened") { + val expectedIntent = KIntent { + hasAction(ACTION_VIEW) + hasData { toString().startsWith(NEW_BUY_WALLET_URL) } + } + expectedIntent.intended() + device.uiDevice.pressBack() + } + } } - step("Click order button ") { - StoriesPage.orderButton.click() - } - step("Assert: browser is opened ") { - Intents.intended(allOf(hasAction(ACTION_VIEW), hasData(NEW_BUY_WALLET_URL))) - uiDevice.pressBack() - Intents.release() - } - } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ac76057c04..a2c1f4e5fe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -45,7 +45,14 @@ android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true" tools:ignore="GoogleAppIndexingWarning" - tools:replace="android:allowBackup, android:fullBackupContent, android:label"> + tools:replace="android:allowBackup, android:fullBackupContent, android:label, android:largeHeap"> + + + { - val signature = signChallengeResult.data.cardSignature ?: run { - Timber.i("Failed to sign challenge with Wallet public key") - return CompletionResult.Failure(VisaCardScanError.FailedToSignChallenge.tangemError) - } + val signature = signChallengeResult.data.walletSignature + val salt = signChallengeResult.data.salt Timber.i("Challenge signed with Wallet public key") handleWalletAuthorizationTokens( cardWalletAddress = walletAddress.value, - signedChallenge = challengeResponse.toSignedChallenge(signature.toHexString()), + signedChallenge = challengeResponse.toSignedChallenge( + signedChallenge = signature.toHexString(), + salt = salt.toHexString(), + ), ) } is CompletionResult.Failure -> { diff --git a/app/src/main/java/com/tangem/tap/features/home/DefaultHomeComponent.kt b/app/src/main/java/com/tangem/tap/features/home/DefaultHomeComponent.kt index 85e57a4ae2..ade00b0024 100644 --- a/app/src/main/java/com/tangem/tap/features/home/DefaultHomeComponent.kt +++ b/app/src/main/java/com/tangem/tap/features/home/DefaultHomeComponent.kt @@ -16,6 +16,7 @@ import com.tangem.core.ui.utils.findActivity import com.tangem.tap.common.redux.AppState import com.tangem.tap.features.home.api.HomeComponent import com.tangem.tap.features.home.compose.StoriesScreen +import com.tangem.tap.features.home.compose.StoriesScreenV2 import com.tangem.tap.features.home.redux.HomeAction import com.tangem.tap.features.home.redux.HomeState import com.tangem.tap.store @@ -57,12 +58,22 @@ internal class DefaultHomeComponent @AssistedInject constructor( val activity = LocalContext.current.findActivity() BackHandler(onBack = activity::finish) SystemBarsIconsDisposable(darkIcons = false) - StoriesScreen( - homeState = homeState, - onScanButtonClick = model::onScanClick, - onShopButtonClick = model::onShopClick, - onSearchTokensClick = model::onSearchClick, - ) + if (homeState.value.isV2StoriesEnabled) { + StoriesScreenV2( + homeState = homeState, + onCreateNewWalletButtonClick = model::onCreateNewWalletScreen, + onAddExistingWalletButtonClick = model::onAddExistingWalletScreen, + onScanButtonClick = model::onScanClick, + ) + } else { + StoriesScreen( + homeState = homeState, + onScanButtonClick = model::onScanClick, + onShopButtonClick = model::onShopClick, + onSearchTokensClick = model::onSearchClick, + ) + } + ChangeRootBackgroundColorEffect(Color(color = 0xFF010101)) } diff --git a/app/src/main/java/com/tangem/tap/features/home/HomeModel.kt b/app/src/main/java/com/tangem/tap/features/home/HomeModel.kt index 132cbd1773..8936e89b41 100644 --- a/app/src/main/java/com/tangem/tap/features/home/HomeModel.kt +++ b/app/src/main/java/com/tangem/tap/features/home/HomeModel.kt @@ -71,6 +71,14 @@ internal class HomeModel @Inject constructor( .launchIn(modelScope) } + fun onCreateNewWalletScreen() { + // TODO implement navigation to create new wallet + } + + fun onAddExistingWalletScreen() { + // TODO implement navigation to add existing wallet + } + fun onScanClick() { analyticsEventHandler.send(IntroductionProcess.ButtonScanCard()) scanCard() diff --git a/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreenV2.kt b/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreenV2.kt new file mode 100644 index 0000000000..a17f707453 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreenV2.kt @@ -0,0 +1,252 @@ +@file:Suppress("MagicNumber") + +package com.tangem.tap.features.home.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +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.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TestTags +import com.tangem.tap.features.home.compose.content.* +import com.tangem.tap.features.home.compose.views.HomeButtonsV2 +import com.tangem.tap.features.home.compose.views.StoriesProgressBar +import com.tangem.tap.features.home.redux.HomeState +import com.tangem.tap.features.home.redux.Stories +import com.tangem.wallet.R +import kotlin.math.max + +@Composable +internal fun StoriesScreenV2( + homeState: MutableState, + onCreateNewWalletButtonClick: () -> Unit, + onAddExistingWalletButtonClick: () -> Unit, + onScanButtonClick: () -> Unit, +) { + val state = homeState.value + + var currentStory by remember { mutableStateOf(state.firstStory) } + val currentStoryIndex by rememberUpdatedState(newValue = state.stepOf(currentStory)) + + val goToPreviousStory = remember(currentStory, currentStoryIndex) { + { currentStory = state.stories[max(0, currentStoryIndex - 1)] } + } + val goToNextStory = remember(currentStory, currentStoryIndex) { + { + currentStory = if (currentStoryIndex < state.stories.lastIndex) { + state.stories[currentStoryIndex + 1] + } else { + state.firstStory + } + } + } + + // todo refactor [REDACTED_TASK_KEY] + StoriesScreenContentV2( + modifier = Modifier + .fillMaxSize() + .testTag(TestTags.STORIES_SCREEN), + config = StoriesScreenContentV2Config( + storiesSize = state.stories.lastIndex, + currentStoryIndex = currentStoryIndex, + currentStory = currentStory, + isScanInProgress = homeState.value.scanInProgress, + onGoToPreviousStory = goToPreviousStory, + onGoToNextStory = goToNextStory, + onCreateNewWalletButtonClick = onCreateNewWalletButtonClick, + onAddExistingWalletButtonClick = onAddExistingWalletButtonClick, + onScanButtonClick = onScanButtonClick, + ), + ) +} + +@Deprecated("Use StoriesContainer from core/ui") +@Suppress("LongMethod") +@Composable +private fun StoriesScreenContentV2(config: StoriesScreenContentV2Config, modifier: Modifier = Modifier) { + var isPressed by remember { mutableStateOf(value = false) } + + val isPaused = isPressed || config.isScanInProgress + val currentStoryDuration = config.currentStory.duration + + Box( + modifier = modifier.background(Color(0xFF010101)), + ) { + Row( + modifier = Modifier.fillMaxSize(), + ) { + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .pointerInput(Unit) { + detectTapGestures( + onPress = { + val pressStartTime = System.currentTimeMillis() + isPressed = true + this.tryAwaitRelease() + val pressEndTime = System.currentTimeMillis() + val totalPressTime = pressEndTime - pressStartTime + if (totalPressTime < 200) config.onGoToPreviousStory() + isPressed = false + }, + ) + }, + ) + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .pointerInput(Unit) { + detectTapGestures( + onPress = { + val pressStartTime = System.currentTimeMillis() + isPressed = true + this.tryAwaitRelease() + val pressEndTime = System.currentTimeMillis() + val totalPressTime = pressEndTime - pressStartTime + if (totalPressTime < 200) config.onGoToNextStory() + isPressed = false + }, + ) + }, + ) + } + + Column( + modifier = Modifier + .statusBarsPadding() + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + StoriesProgressBar( + steps = config.storiesSize, + currentStep = config.currentStoryIndex, + stepDuration = currentStoryDuration, + paused = isPaused, + onStepFinish = config.onGoToNextStory, + ) + Image( + painter = painterResource(id = R.drawable.ic_tangem_logo), + contentDescription = null, + contentScale = ContentScale.FillHeight, + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing16, + ) + .height(TangemTheme.dimens.size18) + .align(Alignment.Start), + ) + when (config.currentStory) { + Stories.TangemIntro -> FirstStoriesContent( + isPaused = isPaused, + duration = currentStoryDuration, + ) + Stories.RevolutionaryWallet -> StoriesRevolutionaryWallet() + Stories.UltraSecureBackup -> StoriesUltraSecureBackup( + isPaused = isPaused, + stepDuration = currentStoryDuration, + ) + Stories.Currencies -> StoriesCurrencies(isPaused, currentStoryDuration) + Stories.Web3 -> StoriesWeb3(isPaused, currentStoryDuration) + Stories.WalletForEveryone -> StoriesWalletForEveryone(currentStoryDuration) + } + } + Column( + modifier = Modifier + .navigationBarsPadding() + .padding(bottom = TangemTheme.dimens.spacing16) + .padding(horizontal = TangemTheme.dimens.spacing16) + .align(Alignment.BottomCenter) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + HomeButtonsV2( + modifier = Modifier.fillMaxWidth(), + btnScanStateInProgress = config.isScanInProgress, + onScanButtonClick = config.onScanButtonClick, + onCreateNewWalletButtonClick = config.onCreateNewWalletButtonClick, + onAddExistingWalletButtonClick = config.onAddExistingWalletButtonClick, + ) + } + } +} + +private data class StoriesScreenContentV2Config( + val storiesSize: Int, + val currentStoryIndex: Int, + val currentStory: Stories, + val isScanInProgress: Boolean, + val onGoToPreviousStory: () -> Unit = {}, + val onGoToNextStory: () -> Unit = {}, + val onCreateNewWalletButtonClick: () -> Unit = {}, + val onAddExistingWalletButtonClick: () -> Unit = {}, + val onScanButtonClick: () -> Unit = {}, +) + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun StoriesScreenContentV2Preview( + @PreviewParameter(StoriesScreenContentV2ConfigProvider::class) config: StoriesScreenContentV2Config, +) { + TangemThemePreview { + StoriesScreenContentV2(config = config) + } +} + +private class StoriesScreenContentV2ConfigProvider : CollectionPreviewParameterProvider( + collection = listOf( + StoriesScreenContentV2Config( + storiesSize = 6, + currentStoryIndex = 0, + currentStory = Stories.TangemIntro, + isScanInProgress = true, + ), + StoriesScreenContentV2Config( + storiesSize = 6, + currentStoryIndex = 1, + currentStory = Stories.RevolutionaryWallet, + isScanInProgress = false, + ), + StoriesScreenContentV2Config( + storiesSize = 6, + currentStoryIndex = 2, + currentStory = Stories.UltraSecureBackup, + isScanInProgress = false, + ), + StoriesScreenContentV2Config( + storiesSize = 6, + currentStoryIndex = 3, + currentStory = Stories.Currencies, + isScanInProgress = false, + ), + StoriesScreenContentV2Config( + storiesSize = 6, + currentStoryIndex = 4, + currentStory = Stories.Web3, + isScanInProgress = false, + ), + StoriesScreenContentV2Config( + storiesSize = 6, + currentStoryIndex = 5, + currentStory = Stories.WalletForEveryone, + isScanInProgress = false, + ), + ), +) +// endregion Preview \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/compose/views/HomeButtonsV2.kt b/app/src/main/java/com/tangem/tap/features/home/compose/views/HomeButtonsV2.kt new file mode 100644 index 0000000000..6b620da6dc --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/home/compose/views/HomeButtonsV2.kt @@ -0,0 +1,124 @@ +package com.tangem.tap.features.home.compose.views + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TestTags +import com.tangem.wallet.R + +@Composable +internal fun HomeButtonsV2( + btnScanStateInProgress: Boolean, + onScanButtonClick: () -> Unit, + onCreateNewWalletButtonClick: () -> Unit, + onAddExistingWalletButtonClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + CreateNewWalletButton( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.STORIES_SCREEN_CREATE_NEW_WALLET_BUTTON), + onClick = onCreateNewWalletButtonClick, + ) + AddExistingWalletButton( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.STORIES_SCREEN_ADD_EXISTING_WALLET_BUTTON), + onClick = onAddExistingWalletButtonClick, + ) + ScanCardButton( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.STORIES_SCREEN_SCAN_BUTTON), + showProgress = btnScanStateInProgress, + onClick = onScanButtonClick, + ) + } +} + +@Composable +private fun CreateNewWalletButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + StoriesButton( + modifier = modifier, + text = stringResourceSafe(id = R.string.home_button_create_new_wallet), + useDarkerColors = false, + onClick = onClick, + ) +} + +@Composable +private fun AddExistingWalletButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + StoriesButton( + modifier = modifier, + text = stringResourceSafe(id = R.string.home_button_add_existing_wallet), + useDarkerColors = true, + onClick = onClick, + ) +} + +@Composable +private fun ScanCardButton(showProgress: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + StoriesButton( + modifier = modifier, + text = stringResourceSafe(id = R.string.home_button_scan), + useDarkerColors = true, + icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24), + onClick = onClick, + showProgress = showProgress, + ) +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun HomeButtonsV2Preview(@PreviewParameter(HomeButtonsV2ParameterProvider::class) state: HomeButtonsV2State) { + TangemThemePreview { + Box( + modifier = Modifier.background(Color.Black), + ) { + HomeButtonsV2( + btnScanStateInProgress = state.btnScanStateInProgress, + onCreateNewWalletButtonClick = {}, + onAddExistingWalletButtonClick = {}, + onScanButtonClick = {}, + modifier = Modifier.padding(all = TangemTheme.dimens.spacing16), + ) + } + } +} + +private class HomeButtonsV2ParameterProvider : CollectionPreviewParameterProvider( + collection = listOf( + HomeButtonsV2State( + btnScanStateInProgress = false, + ), + HomeButtonsV2State( + btnScanStateInProgress = true, + ), + ), +) + +private data class HomeButtonsV2State( + val btnScanStateInProgress: Boolean, +) +// endregion Preview \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/redux/HomeState.kt b/app/src/main/java/com/tangem/tap/features/home/redux/HomeState.kt index b1d45779ba..d36ea27e67 100644 --- a/app/src/main/java/com/tangem/tap/features/home/redux/HomeState.kt +++ b/app/src/main/java/com/tangem/tap/features/home/redux/HomeState.kt @@ -7,6 +7,7 @@ import org.rekotlin.StateType // todo refactor [REDACTED_TASK_KEY] data class HomeState( val scanInProgress: Boolean = false, + val isV2StoriesEnabled: Boolean = false, val stories: ImmutableList = getRestrictedStories().toImmutableList(), ) : StateType { diff --git a/ci_resources/Dockerfile.android b/ci_resources/Dockerfile.android deleted file mode 100644 index f54bfd51ad..0000000000 --- a/ci_resources/Dockerfile.android +++ /dev/null @@ -1,54 +0,0 @@ -FROM ubuntu:24.04 - -ENV DEBIAN_FRONTEND=noninteractive -ENV ANDROID_HOME=/opt/android-sdk -ENV BUNDLE_PATH=vendor/bundle -ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools/34.0.0:$BUNDLE_PATH/bin -ENV LC_ALL=en_US.UTF-8 -ENV LANG=en_US.UTF-8 - -RUN apt-get update && apt-get install -y \ - locales \ - openjdk-17-jdk \ - wget \ - unzip \ - curl \ - git \ - ruby \ - ruby-dev \ - build-essential \ - jq \ - && locale-gen en_US.UTF-8 \ - && update-locale LANG=en_US.UTF-8 \ - && apt-get clean - -RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \ - chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && \ - echo "deb [signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list && \ - apt-get update && apt-get install -y gh && \ - apt-get clean - -RUN mkdir -p $ANDROID_HOME/cmdline-tools/latest && \ - wget https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -O /tmp/cmdline-tools.zip && \ - unzip /tmp/cmdline-tools.zip -d $ANDROID_HOME/cmdline-tools/latest && \ - mv $ANDROID_HOME/cmdline-tools/latest/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ && \ - rm -rf $ANDROID_HOME/cmdline-tools/latest/cmdline-tools && \ - rm -f /tmp/cmdline-tools.zip && \ - yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses && \ - $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "platform-tools" "platforms;android-31" "platforms;android-34" "build-tools;34.0.0" - -RUN wget https://github.com/lzhiyong/android-sdk-tools/releases/download/34.0.3/android-sdk-tools-static-aarch64.zip -O /tmp/android-sdk-tools-static-aarch64.zip && \ - unzip /tmp/android-sdk-tools-static-aarch64.zip -d /tmp/android-sdk-tools-static-arm && \ - cp -r /tmp/android-sdk-tools-static-arm/build-tools/* $ANDROID_HOME/build-tools/34.0.0/ && \ - rm -rf /tmp/android-sdk-tools-static-arm /tmp/android-sdk-tools-static-aarch64.zip - -RUN gem install bundler:2.5.23 -RUN gem install fastlane -v 2.225.0 -N -V -RUN gem install fastlane-plugin-firebase_app_distribution -v 0.9.1 -N -V - -COPY ../Gemfile Gemfile.lock ./ -RUN bundle install --jobs=4 --retry=3 --verbose - -RUN bundle exec fastlane -v - -CMD ["bash"] diff --git a/ci_resources/Dockerfile.lightweight b/ci_resources/Dockerfile.lightweight deleted file mode 100644 index c2b4b399b2..0000000000 --- a/ci_resources/Dockerfile.lightweight +++ /dev/null @@ -1,26 +0,0 @@ -FROM ubuntu:24.04 - -ENV LC_ALL=en_US.UTF-8 -ENV LANG=en_US.UTF-8 -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update && apt-get install -y \ - locales \ - curl \ - git \ - && locale-gen en_US.UTF-8 \ - && update-locale LANG=en_US.UTF-8 \ - && apt-get clean - -RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \ - chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && \ - echo "deb [signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list && \ - apt-get update && apt-get install -y gh && \ - apt-get clean - -RUN curl -s https://packagecloud.io/install/repositories/github/git-lfs/script.deb.sh | bash && \ - apt-get install -y git-lfs && \ - git lfs install && \ - apt-get clean - -CMD ["bash"] diff --git a/ci_resources/build_ci_gradle.properties b/ci_resources/build_ci_gradle.properties deleted file mode 100644 index bb0256c8c4..0000000000 --- a/ci_resources/build_ci_gradle.properties +++ /dev/null @@ -1,13 +0,0 @@ -org.gradle.jvmargs=-Xmx7g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -org.gradle.parallel=true -org.gradle.workers.max=8 -org.gradle.daemon=false -org.gradle.unsafe.configuration-cache=false -org.gradle.console=plain -org.gradle.caching=false - -android.useAndroidX=true -android.nonTransitiveRClass=false -android.aapt2FromMavenOverride=/opt/android-sdk/build-tools/34.0.0/aapt2 - -kotlin.incremental=true diff --git a/ci_resources/find-latest-release-branch.sh b/ci_resources/find-latest-release-branch.sh deleted file mode 100755 index 091cb911e9..0000000000 --- a/ci_resources/find-latest-release-branch.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/bash - -set -eo pipefail - -# Input args -if [ -z "$1" ]; then - echo "Error: No base branch argument provided or empty string given." - echo "Usage: $0 " - exit 1 -fi -base_branch=$1 - -# Initialize arrays to store branch-commit mapping -declare -a branches -declare -a commits - -# Source refs to find branches from -local_refs="refs/heads/releases/*" # For debug and development -#TODO remove pre_release branches matching after 5.21 release -remote_refs=$(git for-each-ref --sort=-committerdate --format="%(refname:short)" refs/remotes/origin/ | grep -E "origin/([a-zA-Z0-9._-]+_pre_release|releases/[a-zA-Z0-9._-]+)$") - -# Iterate over all remote release branches, sorted by commit date (most recent first) -index=0 -for branch in $remote_refs; do - # Find the latest commit that is an ancestor of base_branch using --first-parent strategy - candidate_commit=$(git rev-list --first-parent "$branch..$base_branch" | tail -1) - - if [ -n "$candidate_commit" ]; then - branches[$index]="$branch" - commits[$index]="$candidate_commit" - index=$((index + 1)) - fi -done - -# Debug output of branches and commits -echo "Branches and their corresponding commits:" >&2 -for i in "${!branches[@]}"; do - echo "${branches[$i]} -> ${commits[$i]}" >&2 -done - -# Find the branch with the most recent commit -latest_branch="" -latest_commit="" -for i in "${!branches[@]}"; do - branch=${branches[$i]} - commit=${commits[$i]} - if [ -z "$latest_commit" ] || [ "$(git rev-list --count $latest_commit..$commit)" -gt 0 ]; then - latest_branch=$branch - latest_commit=$commit - fi -done - -# Output validation -if [ -z "$latest_branch" ]; then - echo "Error: Can't find the latest 'release/*' branch for the base branch '$base_branch'" >&2 - exit 2 -fi - -# Stripping 'origin/' prefix if needed -latest_branch="${latest_branch#origin/}" - -echo "$latest_branch" > "find-latest-release-branch.output" -echo "Latest release branch created directly from '$base_branch' or its ancestor: '$latest_branch'" diff --git a/ci_resources/tests_8gb_ram_ci_gradle.properties b/ci_resources/tests_8gb_ram_ci_gradle.properties deleted file mode 100644 index 78577a0975..0000000000 --- a/ci_resources/tests_8gb_ram_ci_gradle.properties +++ /dev/null @@ -1,13 +0,0 @@ -org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=512m -XX:+UseParallelGC -XX:ParallelGCThreads=3 -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -org.gradle.parallel=true -org.gradle.workers.max=3 -org.gradle.daemon=false -org.gradle.unsafe.configuration-cache=false -org.gradle.console=plain -org.gradle.caching=false - -android.useAndroidX=true -android.nonTransitiveRClass=false -android.aapt2FromMavenOverride=/opt/android-sdk/build-tools/34.0.0/aapt2 - -kotlin.incremental=false diff --git a/common/test/build.gradle.kts b/common/test/build.gradle.kts index 52931ce5f3..d0c4c8fd00 100644 --- a/common/test/build.gradle.kts +++ b/common/test/build.gradle.kts @@ -33,4 +33,5 @@ dependencies { implementation(tangemDeps.card.core) implementation(deps.test.junit5) + implementation(deps.test.truth) } \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/data/quote/QuoteResponseExt.kt b/common/test/src/main/java/com/tangem/common/test/data/quote/QuoteResponseExt.kt index ac570f6d81..67ff49b2b2 100644 --- a/common/test/src/main/java/com/tangem/common/test/data/quote/QuoteResponseExt.kt +++ b/common/test/src/main/java/com/tangem/common/test/data/quote/QuoteResponseExt.kt @@ -1,14 +1,22 @@ package com.tangem.common.test.data.quote import com.tangem.datasource.api.tangemTech.models.QuotesResponse -import com.tangem.datasource.local.quote.converter.QuoteConverter import com.tangem.domain.models.StatusSource -import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.utils.extensions.orZero -fun QuotesResponse.Quote.toDomain(rawCurrencyId: String, source: StatusSource = StatusSource.ACTUAL): Quote { - return QuoteConverter(source = source).convert(value = mapOf(rawCurrencyId to this).entries.first()) +fun QuotesResponse.Quote.toDomain(rawCurrencyId: String, source: StatusSource = StatusSource.ACTUAL): QuoteStatus { + return QuoteStatus( + rawCurrencyId = CryptoCurrency.RawID(rawCurrencyId), + value = QuoteStatus.Data( + source = source, + fiatRate = price.orZero(), + priceChange = priceChange24h.orZero().movePointLeft(2), + ), + ) } -fun Pair.toDomain(source: StatusSource = StatusSource.ACTUAL): Quote { - return QuoteConverter(source = source).convert(value = mapOf(this).entries.first()) +fun Pair.toDomain(source: StatusSource = StatusSource.ACTUAL): QuoteStatus { + return second.toDomain(rawCurrencyId = first, source = source) } \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/utils/TruthExt.kt b/common/test/src/main/java/com/tangem/common/test/utils/TruthExt.kt new file mode 100644 index 0000000000..d28fe3df7e --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/utils/TruthExt.kt @@ -0,0 +1,14 @@ +package com.tangem.common.test.utils + +import arrow.core.Either +import com.google.common.truth.Truth + +fun assertEither(actual: Either, expected: Either) { + actual + .onRight { Truth.assertThat(it).isEqualTo(expected) } + .onLeft { + val expectedError = expected.leftOrNull() ?: error("Expected must be Either.Left") + + Truth.assertThat(it::class.java).isEqualTo(expectedError::class.java) + } +} \ 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 d8a18ff233..bdb765bbbf 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 @@ -12,6 +12,7 @@ import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.preview.AmountScreenClickIntentsStub import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData import com.tangem.common.ui.amountScreen.ui.amountField +import com.tangem.common.ui.amountScreen.ui.amountFieldV2 import com.tangem.common.ui.amountScreen.ui.buttons import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -40,18 +41,28 @@ fun AmountScreenContent( bottom = TangemTheme.dimens.spacing16, ), ) { - amountField( - amountState = amountState, - isBalanceHidden = isBalanceHidden, - onValueChange = clickIntents::onAmountValueChange, - onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, - ) - buttons( - segmentedButtonConfig = amountState.segmentedButtonConfig, - clickIntents = clickIntents, - isSegmentedButtonsEnabled = amountState.isSegmentedButtonsEnabled, - selectedButton = amountState.selectedButton, - ) + if (amountState.isRedesignEnabled) { + amountFieldV2( + amountState = amountState, + onValueChange = clickIntents::onAmountValueChange, + onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, + onCurrencyChange = clickIntents::onCurrencyChangeClick, + onMaxAmountClick = clickIntents::onMaxValueClick, + ) + } else { + amountField( + amountState = amountState, + isBalanceHidden = isBalanceHidden, + onValueChange = clickIntents::onAmountValueChange, + onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, + ) + buttons( + segmentedButtonConfig = amountState.segmentedButtonConfig, + clickIntents = clickIntents, + isSegmentedButtonsEnabled = amountState.isSegmentedButtonsEnabled, + selectedButton = amountState.selectedButton, + ) + } } } @@ -75,6 +86,7 @@ private class SendAmountContentPreviewProvider : PreviewParameterProvider get() = sequenceOf( AmountStatePreviewData.amountState, + AmountStatePreviewData.amountStateV2, ) } // endregion \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt index 94981c571b..9bef8f3470 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt @@ -58,10 +58,11 @@ class AmountStateConverter( return AmountState.Data( title = value.title, availableBalance = resourceReference(R.string.common_crypto_fiat_format, wrappedList(crypto, fiat)), + tokenName = stringReference(status.currency.name), tokenIconState = iconStateConverter.convert(status), amountTextField = amountFieldConverter.convert(value.value), isPrimaryButtonEnabled = false, - appCurrencyCode = appCurrency.code, + appCurrency = appCurrency, segmentedButtonConfig = persistentListOf( AmountSegmentedButtonsConfig( title = stringReference(status.currency.symbol), @@ -80,6 +81,7 @@ class AmountStateConverter( ), isSegmentedButtonsEnabled = !noFeeRate, selectedButton = 0, + isRedesignEnabled = false, ) } } @@ -99,6 +101,7 @@ class AmountStateConverterV2( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val maxEnterAmount: EnterAmountBoundary, private val iconStateConverter: CryptoCurrencyToIconStateConverter, + private val isRedesignEnabled: Boolean, ) : Converter { private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) { @@ -116,11 +119,16 @@ class AmountStateConverterV2( return AmountState.Data( title = value.title, - availableBalance = resourceReference(R.string.common_crypto_fiat_format, wrappedList(crypto, fiat)), + availableBalance = if (isRedesignEnabled) { + resourceReference(R.string.common_balance, wrappedList(crypto)) + } else { + resourceReference(R.string.common_crypto_fiat_format, wrappedList(crypto, fiat)) + }, + tokenName = stringReference(cryptoCurrencyStatus.currency.name), tokenIconState = iconStateConverter.convert(cryptoCurrencyStatus), amountTextField = amountFieldConverter.convert(value.value), isPrimaryButtonEnabled = false, - appCurrencyCode = appCurrency.code, + appCurrency = appCurrency, segmentedButtonConfig = persistentListOf( AmountSegmentedButtonsConfig( title = stringReference(cryptoCurrencyStatus.currency.symbol), @@ -139,6 +147,7 @@ class AmountStateConverterV2( ), isSegmentedButtonsEnabled = !noFeeRate, selectedButton = 0, + isRedesignEnabled = isRedesignEnabled, ) } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt index 9f714127da..b5eab4301d 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt @@ -3,6 +3,7 @@ package com.tangem.common.ui.amountScreen.models import androidx.compose.runtime.Stable import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.appcurrency.model.AppCurrency import kotlinx.collections.immutable.PersistentList import java.math.BigDecimal @@ -21,7 +22,7 @@ sealed class AmountState { * @param selectedButton selected currency index * @param isSegmentedButtonsEnabled indicates if currency switches is enabled * @param amountTextField amount field state - * @param appCurrencyCode app currency code + * @param appCurrency app currency * @param isEditingDisabled indicated whether amount is editable * @param reduceAmountBy reduces amount to be sent by specified value * @param isIgnoreReduce ignores reduce amount value @@ -30,15 +31,17 @@ sealed class AmountState { override val isPrimaryButtonEnabled: Boolean, val title: TextReference, val availableBalance: TextReference, + val tokenName: TextReference, val tokenIconState: CurrencyIconState, val segmentedButtonConfig: PersistentList, val selectedButton: Int, val isSegmentedButtonsEnabled: Boolean, val amountTextField: AmountFieldModel, - val appCurrencyCode: String, + val appCurrency: AppCurrency, val isEditingDisabled: Boolean = false, val reduceAmountBy: BigDecimal = BigDecimal.ZERO, val isIgnoreReduce: Boolean = false, + val isRedesignEnabled: Boolean, ) : AmountState() data class Empty( 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 63c4d0cd5d..ab6ead2229 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/preview/AmountStatePreviewData.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/preview/AmountStatePreviewData.kt @@ -2,12 +2,16 @@ package com.tangem.common.ui.amountScreen.preview import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountFieldModel import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.AmountType import kotlinx.collections.immutable.persistentListOf @@ -15,6 +19,8 @@ import java.math.BigDecimal object AmountStatePreviewData { + val emptyState = AmountState.Empty() + val amountState = AmountState.Data( isPrimaryButtonEnabled = false, title = stringReference("Family Wallet"), @@ -31,7 +37,8 @@ object AmountStatePreviewData { isFiat = true, ), ), - appCurrencyCode = "usd", + appCurrency = AppCurrency.Default, + tokenName = stringReference("Tether"), amountTextField = AmountFieldModel( value = "", onValueChange = {}, @@ -60,6 +67,7 @@ object AmountStatePreviewData { ), isSegmentedButtonsEnabled = true, selectedButton = 0, + isRedesignEnabled = false, ) val amountWithValueState = amountState.copy( @@ -75,6 +83,11 @@ object AmountStatePreviewData { ), ) + val amountStateV2 = amountState.copy( + isRedesignEnabled = true, + availableBalance = resourceReference(R.string.common_balance, wrappedList("2 130,88 USDT")), + ) + val amountWithValueFiatState = amountWithValueState.copy( amountTextField = amountWithValueState.amountTextField.copy(isFiatValue = false), ) diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt index 3fd2bd8ad8..e4f345ca11 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt @@ -36,7 +36,7 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis val fiatAmount = amount.fiatAmount.value.format { fiat( fiatCurrencySymbol = amount.fiatAmount.currencySymbol, - fiatCurrencyCode = amountState.appCurrencyCode, + fiatCurrencyCode = amountState.appCurrency.code, ) } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt new file mode 100644 index 0000000000..611e613b72 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt @@ -0,0 +1,180 @@ +package com.tangem.common.ui.amountScreen.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.R +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +fun AmountBlockV2( + amountState: AmountState, + currencyIconState: CurrencyIconState, + isClickDisabled: Boolean, + isEditingDisabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (amountState !is AmountState.Data) return + val amount = amountState.amountTextField + + val cryptoAmount = amount.cryptoAmount.value?.format { + crypto( + symbol = "", + decimals = amount.cryptoAmount.decimals, + ) + }.orEmpty() + + val fiatAmount = amount.fiatAmount.value.format { + fiat( + fiatCurrencySymbol = amount.fiatAmount.currencySymbol, + fiatCurrencyCode = amountState.appCurrency.code, + ) + } + + val (firstAmount, secondAmount) = if (amount.isFiatValue) { + fiatAmount to cryptoAmount + } else { + cryptoAmount to fiatAmount + } + + val title = TextReference.Str( + stringResourceSafe( + R.string.send_from_wallet_name, + amountState.title + .resolveReference(), + ), + ) + val currencyTitle = amount.cryptoAmount.currencySymbol + + AmountBlockV2( + title = title, + balance = amountState.availableBalance, + currencyTitle = currencyTitle, + currencyIconState = currencyIconState, + firstAmount = firstAmount, + secondAmount = secondAmount, + isClickDisabled = isClickDisabled, + isEditingDisabled = isEditingDisabled, + onClick = onClick, + modifier = modifier, + ) +} + +@Suppress("LongParameterList") +@Composable +internal fun AmountBlockV2( + title: TextReference, + balance: TextReference, + currencyTitle: String, + currencyIconState: CurrencyIconState, + firstAmount: String, + secondAmount: String, + isClickDisabled: Boolean, + isEditingDisabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action) + .clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick) + .padding(TangemTheme.dimens.spacing16), + ) { + Row { + Text( + text = title.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + Spacer(modifier = modifier.weight(1f)) + Text( + text = balance.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(top = 8.dp), + ) { + Text( + text = firstAmount, + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = secondAmount, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(top = 6.dp), + ) { + CurrencyIcon( + state = currencyIconState, + modifier = Modifier.padding(horizontal = 14.dp), + ) + Text( + text = currencyTitle, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + } + } + } +} + +// region Preview +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AmountBlockPreview(@PreviewParameter(AmountBlockV2PreviewProvider::class) value: AmountState) { + TangemThemePreview { + AmountBlockV2( + amountState = value, + currencyIconState = CurrencyIconState.Empty(), + isClickDisabled = false, + isEditingDisabled = false, + onClick = {}, + ) + } +} + +private class AmountBlockV2PreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + AmountStatePreviewData.amountState, + ) +} +// endregion \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt index 70d51c2503..35d49a5387 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 @@ -31,56 +31,6 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.rememberDecimalFormat import kotlinx.coroutines.delay -@Deprecated("Use AmountField with clicks") -@Composable -internal fun AmountField(amountField: AmountFieldModel, appCurrencyCode: String) { - val decimalFormat = rememberDecimalFormat() - val isFiatValue = amountField.isFiatValue - val currencyCode = if (isFiatValue) appCurrencyCode else null - val (primaryAmount, primaryValue) = if (isFiatValue) { - amountField.fiatAmount to amountField.fiatValue - } else { - amountField.cryptoAmount to amountField.value - } - val requester = remember { FocusRequester() } - - AmountTextField( - value = primaryValue, - decimals = primaryAmount.decimals, - visualTransformation = AmountVisualTransformation( - decimals = primaryAmount.decimals, - symbol = primaryAmount.currencySymbol, - currencyCode = currencyCode, - decimalFormat = decimalFormat, - ), - onValueChange = amountField.onValueChange, - keyboardOptions = amountField.keyboardOptions, - keyboardActions = amountField.keyboardActions, - textStyle = TangemTheme.typography.h2.copy( - color = TangemTheme.colors.text.primary1, - textAlign = TextAlign.Center, - ), - isAutoResize = true, - isValuePasted = amountField.isValuePasted, - onValuePastedTriggerDismiss = amountField.onValuePastedTriggerDismiss, - modifier = Modifier - .focusRequester(requester) - .padding( - top = TangemTheme.dimens.spacing24, - start = TangemTheme.dimens.spacing12, - end = TangemTheme.dimens.spacing12, - ) - .requiredHeightIn(min = TangemTheme.dimens.size32), - ) - - LaunchedEffect(key1 = Unit) { - delay(timeMillis = 200) - requester.requestFocus() - } - - AmountSecondary(amountField, appCurrencyCode) -} - @Composable internal fun AmountField( amountField: AmountFieldModel, @@ -97,7 +47,7 @@ internal fun AmountField( amountField.cryptoAmount to amountField.value } val requester = remember { FocusRequester() } - + val symbolColor = if (primaryValue.isBlank()) TangemTheme.colors.text.disabled else TangemTheme.colors.text.primary1 AmountTextField( value = primaryValue, decimals = primaryAmount.decimals, @@ -106,6 +56,7 @@ internal fun AmountField( symbol = primaryAmount.currencySymbol, currencyCode = currencyCode, decimalFormat = decimalFormat, + symbolColor = symbolColor, ), onValueChange = onValueChange, keyboardOptions = amountField.keyboardOptions, 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 104f2f60ae..7b4dfe4e57 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 @@ -2,20 +2,32 @@ package com.tangem.common.ui.amountScreen.ui import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme private const val AMOUNT_FIELD_KEY = "amountFieldKey" @@ -64,10 +76,142 @@ internal fun LazyListScope.amountField( ) AmountField( amountField = amountState.amountTextField, - appCurrencyCode = amountState.appCurrencyCode, + appCurrencyCode = amountState.appCurrency.code, onValueChange = onValueChange, onValuePastedTriggerDismiss = onValuePastedTriggerDismiss, ) } } +} + +internal fun LazyListScope.amountFieldV2( + amountState: AmountState, + modifier: Modifier = Modifier, + onValueChange: (String) -> Unit, + onValuePastedTriggerDismiss: () -> Unit, + onCurrencyChange: (Boolean) -> Unit, + onMaxAmountClick: () -> Unit, +) { + item(key = AMOUNT_FIELD_KEY) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.background.action), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .padding(top = 48.dp, bottom = 28.dp), + ) { + if (amountState !is AmountState.Data) { + TextShimmer( + style = TangemTheme.typography.caption2, + modifier = Modifier.width(60.dp), + ) + } else { + Text( + text = amountState.title.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + AmountFieldV2( + amountUM = amountState, + onValueChange = onValueChange, + onValuePastedTriggerDismiss = onValuePastedTriggerDismiss, + onCurrencyChange = onCurrencyChange, + modifier = Modifier, + ) + } + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + thickness = 0.5.dp, + color = TangemTheme.colors.stroke.primary, + ) + AmountInfo( + amountUM = amountState, + onMaxAmountClick = onMaxAmountClick, + ) + } + } +} + +@Composable +private fun AmountInfo(amountUM: AmountState, onMaxAmountClick: () -> Unit, modifier: Modifier = Modifier) { + val tokenIconState = (amountUM as? AmountState.Data)?.tokenIconState ?: CurrencyIconState.Loading + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier, + ) { + CurrencyIcon( + state = tokenIconState, + shouldDisplayNetwork = true, + modifier = Modifier.padding( + start = 16.dp, + top = 16.dp, + bottom = 16.dp, + ), + ) + AmountInfoMain(amountUM = amountUM) + SpacerWMax() + Text( + text = stringResourceSafe(R.string.send_max_amount), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding(end = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.background.secondary) + .padding(horizontal = 12.dp, vertical = 4.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = onMaxAmountClick, + ), + ) + } +} + +@Composable +private fun AmountInfoMain(amountUM: AmountState, modifier: Modifier = Modifier) { + AnimatedContent( + targetState = amountUM !is AmountState.Data, + modifier = modifier, + ) { isContent -> + if (isContent) { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + TextShimmer( + style = TangemTheme.typography.subtitle2, + modifier = Modifier.width(56.dp), + ) + TextShimmer( + style = TangemTheme.typography.caption2, + modifier = Modifier.width(72.dp), + ) + } + } else { + val amountUM = amountUM as AmountState.Data + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = amountUM.tokenName.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + ) + EllipsisText( + text = amountUM.availableBalance.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ellipsis = TextEllipsis.OffsetEnd(amountUM.amountTextField.cryptoAmount.currencySymbol.length), + ) + } + } + } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldV2.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldV2.kt new file mode 100644 index 0000000000..184f7ed46e --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldV2.kt @@ -0,0 +1,329 @@ +package com.tangem.common.ui.amountScreen.ui + +import android.content.res.Configuration +import androidx.compose.animation.* +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.BottomCenter +import androidx.compose.ui.Alignment.Companion.TopCenter +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.graphicsLayer +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.text.style.TextDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.R +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.currency.fiaticon.FiatIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.fields.AmountTextField +import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.rememberDecimalFormat +import kotlinx.coroutines.delay + +private const val ROTATED_DEGREE = 180f +private const val INITIAL_DEGREE = 0f + +@Composable +fun AmountFieldV2( + amountUM: AmountState, + onValueChange: (String) -> Unit, + onValuePastedTriggerDismiss: () -> Unit, + onCurrencyChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val decimalFormat = rememberDecimalFormat() + + val requester = remember { FocusRequester() } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + if (amountUM is AmountState.Empty) { + TextShimmer( + style = TangemTheme.typography.head, + modifier = Modifier.width(150.dp), + ) + } else if (amountUM is AmountState.Data) { + AnimatedContent( + targetState = amountUM.amountTextField.isFiatValue, + transitionSpec = { primaryFieldCurrencyAnimation() }, + label = "Primary field change animation", + ) { isFiatValue -> + val currencyCode = if (isFiatValue) amountUM.appCurrency.code else null + val (primaryAmount, primaryValue) = if (isFiatValue) { + amountUM.amountTextField.fiatAmount to amountUM.amountTextField.fiatValue + } else { + amountUM.amountTextField.cryptoAmount to amountUM.amountTextField.value + } + AmountTextField( + value = primaryValue, + decimals = primaryAmount.decimals, + visualTransformation = AmountVisualTransformation( + decimals = primaryAmount.decimals, + symbol = primaryAmount.currencySymbol, + currencyCode = currencyCode, + decimalFormat = decimalFormat, + symbolColor = TangemTheme.colors.text.disabled, + ), + onValueChange = onValueChange, + keyboardOptions = amountUM.amountTextField.keyboardOptions, + keyboardActions = amountUM.amountTextField.keyboardActions, + textStyle = TangemTheme.typography.head.copy( + textAlign = TextAlign.Center, + ), + isAutoResize = true, + isValuePasted = amountUM.amountTextField.isValuePasted, + onValuePastedTriggerDismiss = onValuePastedTriggerDismiss, + modifier = Modifier + .focusRequester(requester) + .requiredHeightIn(min = 44.dp), + ) + LaunchedEffect(key1 = Unit) { + delay(timeMillis = 200) + requester.requestFocus() + } + } + } + AmountSecondary( + amountUM = amountUM, + onCurrencyChange = onCurrencyChange, + ) + } +} + +@Composable +private fun AmountSecondary(amountUM: AmountState, onCurrencyChange: (Boolean) -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .animateContentSize() + .padding(top = 8.dp), + ) { + if (amountUM is AmountState.Empty) { + TextShimmer( + style = TangemTheme.typography.caption2, + modifier = Modifier + .width(90.dp) + .padding(bottom = 20.dp) + .align(TopCenter), + ) + } else if (amountUM is AmountState.Data) { + AmountFieldCurrencyInfo( + amountUM = amountUM, + onCurrencyChange = onCurrencyChange, + ) + AmountFieldError( + isError = amountUM.amountTextField.isError, + isWarning = amountUM.amountTextField.isWarning, + error = amountUM.amountTextField.error, + modifier = Modifier + .align(BottomCenter) + .padding(top = 20.dp), + ) + } + } +} + +@Composable +private fun BoxScope.AmountFieldCurrencyInfo(amountUM: AmountState.Data, onCurrencyChange: (Boolean) -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .align(TopCenter) + .padding(bottom = 20.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { onCurrencyChange(!amountUM.amountTextField.isFiatValue) }, + ), + ) { + val iconRotateState by animateFloatAsState( + targetValue = if (amountUM.amountTextField.isFiatValue) ROTATED_DEGREE else INITIAL_DEGREE, + animationSpec = tween( + durationMillis = 400, + easing = FastOutSlowInEasing, + ), + label = "Currency change icon animation", + ) + Icon( + painter = rememberVectorPainter( + ImageVector.vectorResource(R.drawable.ic_exchange_vertical_24), + ), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + modifier = Modifier + .size(16.dp) + .graphicsLayer { + rotationY = iconRotateState + }, + ) + AmountFieldCurrencyIcon( + amountUM = amountUM, + ) + } +} + +@Composable +private fun AmountFieldCurrencyIcon(amountUM: AmountState.Data) { + AnimatedContent( + targetState = amountUM.amountTextField.isFiatValue, + transitionSpec = { secondaryFieldCurrencyAnimation() }, + label = "Secondary field change animation", + ) { isFiatValue -> + val secondaryAmount = + if (isFiatValue) amountUM.amountTextField.cryptoAmount else amountUM.amountTextField.fiatAmount + val text = secondaryAmount.value.format { + if (isFiatValue) { + crypto(secondaryAmount.currencySymbol, secondaryAmount.decimals) + } else { + fiat( + fiatCurrencySymbol = amountUM.appCurrency.symbol, + fiatCurrencyCode = amountUM.appCurrency.code, + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = text, + style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr), + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + if (isFiatValue) { + CurrencyIcon( + state = amountUM.tokenIconState, + shouldDisplayNetwork = false, + modifier = Modifier.size(12.dp), + ) + } else { + FiatIcon( + url = amountUM.appCurrency.iconSmallUrl, + size = 12.dp, + isGrayscale = false, + modifier = Modifier.size(12.dp), + ) + } + } + } +} + +@Composable +private fun AmountFieldError( + isError: Boolean, + isWarning: Boolean, + error: TextReference, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = isError || isWarning, + enter = fadeIn(), + exit = fadeOut(), + modifier = modifier, + label = "Error field appearance animation", + ) { + val errorText = remember(this, error) { error } + val color = if (isError) TangemTheme.colors.text.warning else TangemTheme.colors.text.attention + Text( + text = errorText.resolveReference(), + style = TangemTheme.typography.caption2, + color = color, + textAlign = TextAlign.Center, + ) + } +} + +private fun AnimatedContentTransitionScope.secondaryFieldCurrencyAnimation(): ContentTransform { + return ( + fadeIn( + animationSpec = tween(durationMillis = 300), + ) + scaleIn( + animationSpec = tween(durationMillis = 300), + initialScale = 0.9f, + ) + ).togetherWith( + fadeOut( + animationSpec = tween(durationMillis = 300), + ) + scaleOut( + animationSpec = tween(durationMillis = 300), + targetScale = 0.9f, + ), + ) +} + +private fun AnimatedContentTransitionScope.primaryFieldCurrencyAnimation(): ContentTransform { + return ( + fadeIn( + animationSpec = tween(durationMillis = 300), + ) + scaleIn( + animationSpec = tween(durationMillis = 300), + initialScale = 0.9f, + ) + slideInVertically(initialOffsetY = { it / 2 }) + ) + .togetherWith( + fadeOut( + animationSpec = tween(durationMillis = 300), + ) + scaleOut( + animationSpec = tween(durationMillis = 300), + targetScale = 0.9f, + ) + slideOutVertically(targetOffsetY = { it / 2 }), + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun AmountFieldV2_Preview(@PreviewParameter(AmountFieldV2PreviewProvider::class) params: AmountState) { + TangemThemePreview { + AmountFieldV2( + amountUM = params, + onValueChange = {}, + onValuePastedTriggerDismiss = { }, + onCurrencyChange = {}, + modifier = Modifier.background(TangemTheme.colors.background.action), + ) + } +} + +private class AmountFieldV2PreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + AmountStatePreviewData.emptyState, + AmountStatePreviewData.amountState, + ) +} +// endregion \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/utils/FormatterUtils.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/utils/FormatterUtils.kt index c578193df0..9ee08290a8 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/utils/FormatterUtils.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/utils/FormatterUtils.kt @@ -13,12 +13,18 @@ fun getFiatReference(value: BigDecimal?, rate: BigDecimal?, appCurrency: AppCurr return stringReference(formattedFiat) } -fun getFiatString(value: BigDecimal?, rate: BigDecimal?, appCurrency: AppCurrency): String { +fun getFiatString( + value: BigDecimal?, + rate: BigDecimal?, + appCurrency: AppCurrency, + approximate: Boolean = false, +): String { if (value == null || rate == null) return EMPTY_BALANCE_SIGN val feeValue = value.multiply(rate) return BigDecimalFormatter.formatFiatAmount( fiatAmount = feeValue, fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol, + withApproximateSign = approximate, ) } \ 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 index 7337dccc5d..374cbde833 100644 --- 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 @@ -6,6 +6,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +@Suppress("CyclomaticComplexMethod") fun ScenarioUnavailabilityReason.getUnavailabilityReasonText(): TextReference { return when (val unavailabilityReason = this) { is ScenarioUnavailabilityReason.StakingUnavailable -> { @@ -46,6 +47,9 @@ fun ScenarioUnavailabilityReason.getUnavailabilityReasonText(): TextReference { ScenarioUnavailabilityReason.UnassociatedAsset -> resourceReference( id = R.string.warning_receive_blocked_hedera_token_association_required_message, ) + ScenarioUnavailabilityReason.TrustlineRequired -> resourceReference( + id = R.string.warning_receive_blocked_token_trustline_required_message, + ) ScenarioUnavailabilityReason.UsedOutdatedData -> { resourceReference(id = R.string.token_button_unavailability_reason_out_of_date_balance) } diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt index bc60544cb1..9ad35360a5 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt @@ -166,7 +166,7 @@ private fun NameAndInfo( @Suppress("MagicNumber") @Composable -private fun CardImage(imageState: UserWalletItemUM.ImageState, modifier: Modifier = Modifier) { +fun CardImage(imageState: UserWalletItemUM.ImageState, modifier: Modifier = Modifier) { val imageModifier = modifier .width(TangemTheme.dimens.size36) .height(TangemTheme.dimens.size24) 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 d02be99d34..1dd4445077 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 @@ -219,6 +219,7 @@ sealed class AnalyticsParam { const val ACTION = "Action" const val COLLECTIONS = "Collections" const val NFT = "Nft" + const val NONCE = "Nonce" const val STANDARD = "Standard" const val NO_COLLECTION = "No collection" } diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 46dc027b89..0ab98cc318 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -62,5 +62,17 @@ { "name": "USEDESK_ENABLED", "version": "undefined" + }, + { + "name": "SEND_VIA_SWAP_ENABLED", + "version": "undefined" + }, + { + "name": "SWAP_REDESIGN_ENABLED", + "version": "undefined" + }, + { + "name": "SEND_REDESIGN_ENABLED", + "version": "undefined" } ] diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/version/DefaultVersionProvider.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/version/DefaultVersionProvider.kt index b55e9637c3..105ca52d15 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/version/DefaultVersionProvider.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/version/DefaultVersionProvider.kt @@ -28,9 +28,9 @@ internal class DefaultVersionProvider @Inject constructor( context.packageName, PackageManager.PackageInfoFlags.of(0), ) - .versionName + .versionName!! } else { - context.packageManager.getPackageInfo(context.packageName, 0).versionName + context.packageManager.getPackageInfo(context.packageName, 0).versionName!! } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/Trace.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/Trace.kt index 89f2f3db60..84040fc6a5 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/Trace.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/Trace.kt @@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class Trace( - @Json(name = "exposed") val exposed: Exposed, + @Json(name = "exposed") val exposed: Exposed?, @Json(name = "asset") val asset: NftAsset, ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/ValidationResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/ValidationResponse.kt index 08e86a0697..2e27aab60b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/ValidationResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/ValidationResponse.kt @@ -7,4 +7,5 @@ import com.squareup.moshi.JsonClass data class ValidationResponse( @Json(name = "status") val status: String, @Json(name = "result_type") val resultType: String, + @Json(name = "description") val description: String?, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index f3cc288b25..2c1dcaea5b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -24,7 +24,7 @@ sealed class ApiConfig { Express, TangemTech, StakeKit, - TangemVisa, + TangemPay, Attestation, BlockAid, } @@ -34,7 +34,7 @@ sealed class ApiConfig { is Express -> ID.Express is TangemTech -> ID.TangemTech is StakeKit -> ID.StakeKit - is TangemVisa -> ID.TangemVisa + is TangemPay -> ID.TangemPay is Attestation -> ID.Attestation is BlockAid -> ID.BlockAid } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemVisa.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt similarity index 97% rename from core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemVisa.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt index b46a599b2d..b45492d87b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemVisa.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt @@ -3,7 +3,7 @@ package com.tangem.datasource.api.common.config import com.tangem.utils.ProviderSuspend import com.tangem.utils.version.AppVersionProvider -internal class TangemVisa( +internal class TangemPay( private val appVersionProvider: AppVersionProvider, ) : ApiConfig() { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/TangemVisaApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt similarity index 70% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/TangemVisaApi.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 5b2755a78a..2749826753 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/TangemVisaApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -1,27 +1,27 @@ -package com.tangem.datasource.api.visa +package com.tangem.datasource.api.pay import com.tangem.datasource.api.common.response.ApiResponse -import com.tangem.datasource.api.visa.models.request.ActivationByCardWalletRequest -import com.tangem.datasource.api.visa.models.request.ActivationByCustomerWalletRequest -import com.tangem.datasource.api.visa.models.request.ActivationStatusRequest -import com.tangem.datasource.api.visa.models.request.ExchangeAccessTokenRequest -import com.tangem.datasource.api.visa.models.request.GenerateNoneByCardIdRequest -import com.tangem.datasource.api.visa.models.request.GenerateNoneByCardWalletRequest -import com.tangem.datasource.api.visa.models.request.GetAccessTokenByCardIdRequest -import com.tangem.datasource.api.visa.models.request.GetAccessTokenByCardWalletRequest -import com.tangem.datasource.api.visa.models.request.GetCardWalletAcceptanceRequest -import com.tangem.datasource.api.visa.models.request.GetCustomerWalletAcceptanceRequest -import com.tangem.datasource.api.visa.models.request.RefreshTokenByCardIdRequest -import com.tangem.datasource.api.visa.models.request.RefreshTokenByCardWalletRequest -import com.tangem.datasource.api.visa.models.request.SetPinCodeRequest -import com.tangem.datasource.api.visa.models.response.* +import com.tangem.datasource.api.pay.models.request.ActivationByCardWalletRequest +import com.tangem.datasource.api.pay.models.request.ActivationByCustomerWalletRequest +import com.tangem.datasource.api.pay.models.request.ActivationStatusRequest +import com.tangem.datasource.api.pay.models.request.ExchangeAccessTokenRequest +import com.tangem.datasource.api.pay.models.request.GenerateNoneByCardIdRequest +import com.tangem.datasource.api.pay.models.request.GenerateNoneByCardWalletRequest +import com.tangem.datasource.api.pay.models.request.GetAccessTokenByCardIdRequest +import com.tangem.datasource.api.pay.models.request.GetAccessTokenByCardWalletRequest +import com.tangem.datasource.api.pay.models.request.GetCardWalletAcceptanceRequest +import com.tangem.datasource.api.pay.models.request.GetCustomerWalletAcceptanceRequest +import com.tangem.datasource.api.pay.models.request.RefreshTokenByCardIdRequest +import com.tangem.datasource.api.pay.models.request.RefreshTokenByCardWalletRequest +import com.tangem.datasource.api.pay.models.request.SetPinCodeRequest +import com.tangem.datasource.api.pay.models.response.* import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Query -interface TangemVisaApi { +interface TangemPayApi { // region: auth @@ -104,4 +104,7 @@ interface TangemVisaApi { @Query("limit") limit: Int, @Query("offset") offset: Int, ): ApiResponse + + @GET("v1/customer/kyc") + suspend fun getKycAccess(@Header("Authorization") authHeader: String): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCardWalletRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ActivationByCardWalletRequest.kt similarity index 95% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCardWalletRequest.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ActivationByCardWalletRequest.kt index c16198f0c6..a5dafd7b32 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCardWalletRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ActivationByCardWalletRequest.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.request +package com.tangem.datasource.api.pay.models.request import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCustomerWalletRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ActivationByCustomerWalletRequest.kt similarity index 90% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCustomerWalletRequest.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ActivationByCustomerWalletRequest.kt index ace2090603..731188d92a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCustomerWalletRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ActivationByCustomerWalletRequest.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.request +package com.tangem.datasource.api.pay.models.request import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationStatusRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ActivationStatusRequest.kt similarity index 82% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationStatusRequest.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ActivationStatusRequest.kt index 7ce0614abc..9f3e017e55 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationStatusRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ActivationStatusRequest.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.request +package com.tangem.datasource.api.pay.models.request import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ExchangeAccessTokenRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ExchangeAccessTokenRequest.kt similarity index 82% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ExchangeAccessTokenRequest.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ExchangeAccessTokenRequest.kt index 63b9fe43d8..164d71fa5c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ExchangeAccessTokenRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ExchangeAccessTokenRequest.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.request +package com.tangem.datasource.api.pay.models.request import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GenerateNoneByCardIdRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GenerateNoneByCardIdRequest.kt similarity index 85% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GenerateNoneByCardIdRequest.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GenerateNoneByCardIdRequest.kt index d3aa5eaa50..7ab945b8f8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GenerateNoneByCardIdRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GenerateNoneByCardIdRequest.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.request +package com.tangem.datasource.api.pay.models.request import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GenerateNoneByCardWalletRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GenerateNoneByCardWalletRequest.kt similarity index 74% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GenerateNoneByCardWalletRequest.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GenerateNoneByCardWalletRequest.kt index 172c39e727..95d2f8f9bf 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GenerateNoneByCardWalletRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GenerateNoneByCardWalletRequest.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.request +package com.tangem.datasource.api.pay.models.request import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @@ -7,4 +7,5 @@ import com.squareup.moshi.JsonClass data class GenerateNoneByCardWalletRequest( @Json(name = "auth_type") val authType: String = "card_wallet", @Json(name = "card_wallet_address") val cardWalletAddress: String, + @Json(name = "card_id") val cardId: String, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetAccessTokenByCardIdRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetAccessTokenByCardIdRequest.kt similarity index 86% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetAccessTokenByCardIdRequest.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetAccessTokenByCardIdRequest.kt index c212c81412..380284bab7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetAccessTokenByCardIdRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetAccessTokenByCardIdRequest.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.request +package com.tangem.datasource.api.pay.models.request import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetAccessTokenByCardWalletRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetAccessTokenByCardWalletRequest.kt similarity index 77% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetAccessTokenByCardWalletRequest.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetAccessTokenByCardWalletRequest.kt index c80e97b0c9..50fc683c9b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetAccessTokenByCardWalletRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetAccessTokenByCardWalletRequest.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.request +package com.tangem.datasource.api.pay.models.request import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @@ -8,4 +8,5 @@ data class GetAccessTokenByCardWalletRequest( @Json(name = "auth_type") val authType: String = "card_wallet", @Json(name = "session_id") val sessionId: String, @Json(name = "signature") val signature: String, + @Json(name = "salt") val salt: String, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetCardWalletAcceptanceRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetCardWalletAcceptanceRequest.kt similarity index 86% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetCardWalletAcceptanceRequest.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetCardWalletAcceptanceRequest.kt index dd084fa326..de4cec941b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetCardWalletAcceptanceRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetCardWalletAcceptanceRequest.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.request +package com.tangem.datasource.api.pay.models.request import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetCustomerWalletAcceptanceRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetCustomerWalletAcceptanceRequest.kt similarity index 87% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetCustomerWalletAcceptanceRequest.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetCustomerWalletAcceptanceRequest.kt index 3948fc43b3..7ff5c16337 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetCustomerWalletAcceptanceRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetCustomerWalletAcceptanceRequest.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.request +package com.tangem.datasource.api.pay.models.request import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/RefreshTokenByCardIdRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshTokenByCardIdRequest.kt similarity index 83% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/RefreshTokenByCardIdRequest.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshTokenByCardIdRequest.kt index f94a59a280..50653c8f79 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/RefreshTokenByCardIdRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshTokenByCardIdRequest.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.request +package com.tangem.datasource.api.pay.models.request import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/RefreshTokenByCardWalletRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshTokenByCardWalletRequest.kt similarity index 83% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/RefreshTokenByCardWalletRequest.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshTokenByCardWalletRequest.kt index 108469e1b6..d21bc53edf 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/RefreshTokenByCardWalletRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshTokenByCardWalletRequest.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.request +package com.tangem.datasource.api.pay.models.request import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/SetPinCodeRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinCodeRequest.kt similarity index 85% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/SetPinCodeRequest.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinCodeRequest.kt index 69e1a5ac08..2266910591 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/SetPinCodeRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinCodeRequest.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.request +package com.tangem.datasource.api.pay.models.request import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CardActivationRemoteStateResponse.kt similarity index 93% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CardActivationRemoteStateResponse.kt index a34a31a366..ae9c701b06 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CardActivationRemoteStateResponse.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.response +package com.tangem.datasource.api.pay.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/GenerateNonceResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/GenerateNonceResponse.kt similarity index 86% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/GenerateNonceResponse.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/GenerateNonceResponse.kt index 9a79a457b8..958580bc5e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/GenerateNonceResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/GenerateNonceResponse.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.response +package com.tangem.datasource.api.pay.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/JWTResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/JWTResponse.kt similarity index 93% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/JWTResponse.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/JWTResponse.kt index 0380074d02..c28a882ae9 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/JWTResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/JWTResponse.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.response +package com.tangem.datasource.api.pay.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/KycAccessInfoResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/KycAccessInfoResponse.kt new file mode 100644 index 0000000000..dea8079161 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/KycAccessInfoResponse.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class KycAccessInfoResponse( + @Json(name = "result") val result: Result, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "token") val token: String, + @Json(name = "locale") val locale: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaCustomerInfo.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/VisaCustomerInfo.kt similarity index 89% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaCustomerInfo.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/VisaCustomerInfo.kt index 70d5f6ac94..b6a227f62f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaCustomerInfo.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/VisaCustomerInfo.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.response +package com.tangem.datasource.api.pay.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaDataToSignResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/VisaDataToSignResponse.kt similarity index 84% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaDataToSignResponse.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/VisaDataToSignResponse.kt index a19d3e7ca4..a35c99ab59 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaDataToSignResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/VisaDataToSignResponse.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.response +package com.tangem.datasource.api.pay.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaErrorResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/VisaErrorResponse.kt similarity index 84% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaErrorResponse.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/VisaErrorResponse.kt index 49a5050d0c..b211405000 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaErrorResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/VisaErrorResponse.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.response +package com.tangem.datasource.api.pay.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaTxHistoryResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/VisaTxHistoryResponse.kt similarity index 98% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaTxHistoryResponse.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/VisaTxHistoryResponse.kt index 7c4d7d0a22..aea17cc4c1 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaTxHistoryResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/VisaTxHistoryResponse.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.api.visa.models.response +package com.tangem.datasource.api.pay.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index 0ac0fdc78a..a6793286f0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -65,7 +65,7 @@ interface TangemTechApi { suspend fun getQuotes( @Query("currencyId") currencyId: String, @Query("coinIds") coinIds: String, - @Query("fields") fields: String = "price,priceChange24h,lastUpdatedAt", + @Query("fields") fields: String, ): ApiResponse @GET("promotion") @@ -176,13 +176,4 @@ interface TangemTechApi { @GET("user-wallets/wallets/by-app/{app_id}") suspend fun getWallets(@Path("app_id") appId: String): ApiResponse> // endregion - - companion object { - val marketsQuoteFields = listOf( - "price", - "priceChange24h", - "priceChange1w", - "priceChange30d", - ) - } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index 8afffb0a2a..b1761922d3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -53,7 +53,7 @@ internal object ApiConfigsModule { @Provides @IntoSet - fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemVisa(appVersionProvider) + fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemPay(appVersionProvider) @Provides @IntoSet diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 1110e9f745..4122660756 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -17,7 +17,7 @@ import com.tangem.datasource.api.onramp.OnrampApi import com.tangem.datasource.api.stakekit.StakeKitApi import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApiV2 -import com.tangem.datasource.api.visa.TangemVisaApi +import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.utils.* @@ -206,9 +206,9 @@ internal object NetworkModule { analyticsErrorHandler: AnalyticsErrorHandler, apiConfigsManager: ApiConfigsManager, appLogsStore: AppLogsStore, - ): TangemVisaApi { - return createApi( - id = ApiConfig.ID.TangemVisa, + ): TangemPayApi { + return createApi( + id = ApiConfig.ID.TangemPay, moshi = moshi, context = context, apiConfigsManager = apiConfigsManager, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt deleted file mode 100644 index 555eb268f9..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.datasource.local.quote.converter - -import com.tangem.datasource.api.tangemTech.models.QuotesResponse -import com.tangem.domain.models.StatusSource -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.model.Quote -import com.tangem.utils.converter.Converter -import com.tangem.utils.extensions.orZero - -/** - * Converter from [QuotesResponse.Quote] to [Quote.Value] - * - * @property source status source - * -[REDACTED_AUTHOR] - */ -class QuoteConverter( - private val source: StatusSource, -) : - Converter, Quote.Value> { - - /** - * Secondary constructor - * - * @param isCached flag that determines whether the quote is a cache - */ - constructor(isCached: Boolean) : this( - source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL, - ) - - override fun convert(value: Map.Entry): Quote.Value { - val (currencyId, quote) = value - - return Quote.Value( - rawCurrencyId = CryptoCurrency.RawID(currencyId), - fiatRate = quote.price.orZero(), - priceChange = quote.priceChange24h.orZero().movePointLeft(2), - source = source, - ) - } -} \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index 4570f390fc..fe329835cd 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -37,7 +37,7 @@ private val API_CONFIGS = setOf( Express(configManager, expressAuthProvider, appVersionProvider, appInfoProvider), TangemTech(appVersionProvider, appAuthProvider, appInfoProvider), StakeKit(stakeKitAuthProvider), - TangemVisa(appVersionProvider), + TangemPay(appVersionProvider), ) /** @@ -88,7 +88,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { is Express -> createExpressModel() is TangemTech -> createTangemTechModel() is StakeKit -> createStakeKitModel() - is TangemVisa -> createVisaModel() + is TangemPay -> createVisaModel() is Attestation -> createAttestationModel() is BlockAid -> createBlockAidSdkModel() } @@ -182,7 +182,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { private fun createVisaModel(): Model { return Model( - id = ApiConfig.ID.TangemVisa, + id = ApiConfig.ID.TangemPay, expected = ApiEnvironmentConfig( environment = ApiEnvironment.DEV, baseUrl = "[REDACTED_ENV_URL]", diff --git a/core/decompose/build.gradle.kts b/core/decompose/build.gradle.kts index aa1b6e3c82..792dbe9a1c 100644 --- a/core/decompose/build.gradle.kts +++ b/core/decompose/build.gradle.kts @@ -1,13 +1,19 @@ plugins { - alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) alias(deps.plugins.kotlin.kapt) id("configuration") } +android { + namespace = "com.tangem.core.decompose" +} + dependencies { api(projects.core.utils) api(deps.decompose) + api(deps.androidx.appCompat) implementation(deps.kotlin.coroutines) implementation(deps.hilt.core) diff --git a/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/AppComponentContext.kt b/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/AppComponentContext.kt index 9426735800..8201bba2c4 100644 --- a/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/AppComponentContext.kt +++ b/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/AppComponentContext.kt @@ -4,6 +4,7 @@ import com.arkivanov.decompose.ComponentContext import com.tangem.core.decompose.di.HiltComponentBuilderOwner import com.tangem.core.decompose.navigation.NavigationOwner import com.tangem.core.decompose.ui.UiMessageSenderOwner +import com.tangem.core.decompose.utils.ActivityHolder import com.tangem.core.decompose.utils.ComponentScopeOwner import com.tangem.core.decompose.utils.DispatchersOwner import com.tangem.core.decompose.utils.TagsOwner @@ -20,4 +21,5 @@ interface AppComponentContext : DispatchersOwner, UiMessageSenderOwner, HiltComponentBuilderOwner, - TagsOwner \ No newline at end of file + TagsOwner, + ActivityHolder \ No newline at end of file diff --git a/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/AppComponentContextChildrenFactory.kt b/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/AppComponentContextChildrenFactory.kt index 146a7e2bb0..2ae9d71656 100644 --- a/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/AppComponentContextChildrenFactory.kt +++ b/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/AppComponentContextChildrenFactory.kt @@ -10,6 +10,7 @@ import com.tangem.core.decompose.ui.DefaultUiMessageSender import com.tangem.core.decompose.ui.UiMessageHandler import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.decompose.ui.UiMessageSenderOwner +import com.tangem.core.decompose.utils.ActivityHolder import com.tangem.core.decompose.utils.ComponentCoroutineScope import com.tangem.core.decompose.utils.DispatchersOwner import kotlinx.coroutines.CoroutineScope @@ -58,7 +59,8 @@ fun AppComponentContext.childByContext( NavigationOwner by this@childByContext, UiMessageSenderOwner by this@childByContext, DispatchersOwner by this@childByContext, - HiltComponentBuilderOwner by this@childByContext { + HiltComponentBuilderOwner by this@childByContext, + ActivityHolder by this@childByContext { override val tags: HashMap = HashMap() 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 ea9afa04a5..a8a4fac2f7 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 @@ -1,5 +1,6 @@ package com.tangem.core.decompose.context +import androidx.appcompat.app.AppCompatActivity import com.arkivanov.decompose.ComponentContext import com.arkivanov.essenty.instancekeeper.getOrCreate import com.tangem.core.decompose.di.ModelComponent @@ -17,6 +18,7 @@ class DefaultAppComponentContext( override val dispatchers: CoroutineDispatcherProvider, override val hiltComponentBuilder: ModelComponent.Builder, override val messageSender: UiMessageSender, + override val activity: AppCompatActivity, private val replaceRouter: Router? = null, ) : AppComponentContext, ComponentContext by componentContext { diff --git a/core/decompose/src/main/kotlin/com/tangem/core/decompose/utils/ActivityHolder.kt b/core/decompose/src/main/kotlin/com/tangem/core/decompose/utils/ActivityHolder.kt new file mode 100644 index 0000000000..5ef79e239d --- /dev/null +++ b/core/decompose/src/main/kotlin/com/tangem/core/decompose/utils/ActivityHolder.kt @@ -0,0 +1,16 @@ +package com.tangem.core.decompose.utils + +import androidx.appcompat.app.AppCompatActivity + +/** + * Interface for holding an [AppCompatActivity] instance. + * + * This interface is used to provide access to the activity in which the component is running. + */ +interface ActivityHolder { + + /** + * The [AppCompatActivity] instance associated with this context. + */ + val activity: AppCompatActivity +} \ No newline at end of file diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 24c3cc5827..e90c8e89e2 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -1299,8 +1299,8 @@ Der Kontostand ist möglicherweise veraltet. Aktualisiere bittre die Seite. Nicht genug %1$s. Lade Dein XLM-Konto auf, um dieses Token zu verknüpfen Trustline aktivieren - Um dieses Token zu erhalten, muss eine Trustline aktiviert sein. Das Netzwerk benötigt eine Reserve von %1$s %2$s. - Trustline erforderlich + Um dieses Token zu erhalten, muss eine Trustline aktiviert sein. Das Netzwerk benötigt eine Reserve von %1$s %2$s. + Trustline erforderlich Unbekannte Domäne Trotzdem verbinden Zeitüberschreitungsfehler. Bitte versuche es später erneut. diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 722f628412..11cb1fb068 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -1279,8 +1279,8 @@ 残高が古い可能性があります。ページを更新してください。 %1$sが足りません。このトークンを関連付けるには、XLMアカウントにチャージしてください。 トラストラインを有効にする - このトークンを受け取るには、トラストラインを有効にする必要があります。ネットワークには%1$s %2$s予備金が必要です。 - トラストラインが必要 + このトークンを受け取るには、トラストラインを有効にする必要があります。ネットワークには%1$s %2$s予備金が必要です。 + トラストラインが必要 不明なドメイン とにかく接続する タイムアウトエラーが発生しました。しばらくしてからもう一度お試しください。 diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 6be2e1fb65..8ac257b580 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1263,8 +1263,8 @@ Только для целей тестирования Возможно, баланс устарел. Обновите страницу. Открыть Trustline - Согласно правилам сети, чтобы пополнить этот токен, необходимо сначала открыть Trustline — это позволит вашему кошельку принимать и хранить этот актив. - Откройте Trustline + Согласно правилам сети, чтобы пополнить этот токен, необходимо сначала открыть Trustline — это позволит вашему кошельку принимать и хранить этот актив. + Откройте Trustline Пожалуйста, вернитесь в браузер и выполните повторное подключение через WalletConnect. Выбрана не верная карта или кольцо Адрес diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 59973b51ac..fc05ef9a38 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -822,6 +822,7 @@ Check your network connection Network fee info unreachable From + From %s Gas limit This is the maximum amount of gas that will be spent to complete a transaction or contract. A gas limit prevents unexpected or unlimited charges when executing a transaction. Gas price @@ -829,6 +830,7 @@ Max Maximum amount Fee up to + Memo: %s Invalid Memo Network fee coverage Nonce @@ -879,6 +881,7 @@ 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 + To address 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. Amount exceeds balance @@ -888,6 +891,7 @@ Fee exceeds balance Total amount exceeds balance Send any token, and we’ll convert it on the way. Your recipient gets exactly what they need—seamlessly. + Will be sent a recipient Send with swap Transaction sent Prepare to scan card or ring you want to set up. @@ -1344,8 +1348,8 @@ Balance may be outdated. Refresh the page. Not enough %1$s. Top up your XLM account to associate this token Enable Trustline - A Trustline must be enabled to receive this token. The network requires a %1$s %2$s reserve. - Trustline Required + A Trustline must be enabled to receive this token. The network requires a %1$s %2$s reserve. + Trustline Required Unknown domain Connect anyway Timeout error. Please, try again later. diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabel.kt b/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabel.kt index eed177c971..e5293e07ab 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabel.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabel.kt @@ -2,6 +2,7 @@ package com.tangem.core.ui.components.audits import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -11,6 +12,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme @@ -30,41 +32,59 @@ import com.tangem.core.ui.res.TangemThemePreview fun AuditLabel(state: AuditLabelUM, modifier: Modifier = Modifier) { AuditLabelInternal( textReference = state.text, - textColor = getColorByType(state.type), - backgroundColor = getColorByType(state.type).copy(alpha = 0.1f), + type = state.type, modifier = modifier, ) } @Composable -private fun AuditLabelInternal( - textReference: TextReference, - textColor: Color, - backgroundColor: Color, - modifier: Modifier = Modifier, -) { +private fun AuditLabelInternal(textReference: TextReference, type: AuditLabelUM.Type, modifier: Modifier = Modifier) { Box( modifier = modifier.background( - color = backgroundColor, + color = getBackgroundColorByType(type), shape = TangemTheme.shapes.roundedCornersSmall2, ), ) { Text( text = textReference.resolveReference(), style = TangemTheme.typography.caption1, - color = textColor, - modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing4), + color = getTextColorByType(type), + modifier = Modifier.padding(getPaddingByType(type)), ) } } @ReadOnlyComposable @Composable -private fun getColorByType(type: AuditLabelUM.Type): Color { +private fun getTextColorByType(type: AuditLabelUM.Type): Color { return when (type) { AuditLabelUM.Type.Prohibition -> TangemTheme.colors.text.warning AuditLabelUM.Type.Warning -> TangemTheme.colors.text.attention AuditLabelUM.Type.Permit -> TangemTheme.colors.text.accent + AuditLabelUM.Type.Info -> TangemTheme.colors.text.primary2 + } +} + +@ReadOnlyComposable +@Composable +private fun getBackgroundColorByType(type: AuditLabelUM.Type): Color { + return when (type) { + AuditLabelUM.Type.Prohibition -> TangemTheme.colors.text.warning.copy(alpha = 0.1f) + AuditLabelUM.Type.Warning -> TangemTheme.colors.text.attention.copy(alpha = 0.1f) + AuditLabelUM.Type.Permit -> TangemTheme.colors.text.accent.copy(alpha = 0.1f) + AuditLabelUM.Type.Info -> TangemTheme.colors.text.accent + } +} + +@ReadOnlyComposable +@Composable +private fun getPaddingByType(type: AuditLabelUM.Type): PaddingValues { + return when (type) { + AuditLabelUM.Type.Prohibition, + AuditLabelUM.Type.Warning, + AuditLabelUM.Type.Permit, + -> PaddingValues(horizontal = 4.dp) + AuditLabelUM.Type.Info -> PaddingValues(horizontal = 6.dp, vertical = 1.dp) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabelUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabelUM.kt index 25059b9594..4b1ed19772 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabelUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabelUM.kt @@ -22,5 +22,8 @@ data class AuditLabelUM(val text: TextReference, val type: Type) { /** Blue. Like, "trusted" */ Permit, + + /** Blue with white text. Like, "best rate" */ + Info, } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/badge/Badge.kt b/core/ui/src/main/java/com/tangem/core/ui/components/badge/Badge.kt new file mode 100644 index 0000000000..282f4a0618 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/badge/Badge.kt @@ -0,0 +1,74 @@ +package com.tangem.core.ui.components.badge + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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 + +/** + * Badge component + * + * @param iconRes icon drawable resource + * @param text text reference + * @param modifier composable modifier + * + * @see Figma + */ +@Composable +fun Badge(@DrawableRes iconRes: Int, text: TextReference, modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + .border(1.dp, TangemTheme.colors.stroke.primary, RoundedCornerShape(4.dp)) + .padding(horizontal = 4.dp), + ) { + Icon( + painter = rememberVectorPainter( + ImageVector.vectorResource(iconRes), + ), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier.size(12.dp), + ) + Text( + text = text.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Badge_Preview() { + TangemThemePreview { + Badge( + iconRes = R.drawable.ic_star_mini_24, + text = stringReference("4.9"), + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/badge/entity/BadgeUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/badge/entity/BadgeUM.kt new file mode 100644 index 0000000000..ee6aa0bcdb --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/badge/entity/BadgeUM.kt @@ -0,0 +1,15 @@ +package com.tangem.core.ui.components.badge.entity + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.extensions.TextReference + +/** + * Badge component UI model + * + * @param iconRes icon drawable resource + * @param text text reference + */ +data class BadgeUM( + val text: TextReference, + @DrawableRes val iconRes: Int, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt index 407a375ade..57f8d0f0d5 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt @@ -62,7 +62,7 @@ fun MessageBottomSheetV2(state: MessageBottomSheetUMV2, onDismissRequest: () -> } @Composable -private fun MessageBottomSheetV2Content(state: MessageBottomSheetUMV2, modifier: Modifier = Modifier) { +fun MessageBottomSheetV2Content(state: MessageBottomSheetUMV2, modifier: Modifier = Modifier) { Column(modifier = modifier) { state.elements.fastForEach { when (it) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButtonSize.kt b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButtonSize.kt index 961bca8869..46530d78b1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButtonSize.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButtonSize.kt @@ -16,6 +16,7 @@ enum class TangemButtonSize { RoundedAction, WideAction, TwoLines, + Small, } @Composable @@ -28,6 +29,7 @@ internal fun TangemButtonSize.toHeightDp(): Dp = when (this) { TangemButtonSize.RoundedAction, -> TangemTheme.dimens.size36 TangemButtonSize.WideAction -> TangemTheme.dimens.size40 + TangemButtonSize.Small -> 24.dp } @Composable @@ -40,6 +42,7 @@ internal fun TangemButtonSize.toShape(): Shape = when (this) { TangemButtonSize.Action -> TangemTheme.shapes.roundedCornersMedium TangemButtonSize.TwoLines -> TangemTheme.shapes.roundedCornersXMedium TangemButtonSize.RoundedAction -> TangemTheme.shapes.roundedCornersLarge + TangemButtonSize.Small -> TangemTheme.shapes.roundedCornersXMedium } @Composable @@ -48,6 +51,7 @@ internal fun TangemButtonSize.toIconPadding(): Dp = when (this) { TangemButtonSize.Default, TangemButtonSize.WideAction, TangemButtonSize.TwoLines, + TangemButtonSize.Small, -> TangemTheme.dimens.spacing4 TangemButtonSize.Text -> TangemTheme.dimens.spacing8 TangemButtonSize.Selector -> 0.dp @@ -100,11 +104,18 @@ internal fun TangemButtonSize.toContentPadding(icon: TangemButtonIconPosition): start = horizontalPadding.first, end = horizontalPadding.second, ) + TangemButtonSize.Small -> PaddingValues( + top = TangemTheme.dimens.spacing4, + bottom = TangemTheme.dimens.spacing4, + start = horizontalPadding.first, + end = horizontalPadding.second, + ) } } @Composable @ReadOnlyComposable +@Suppress("CyclomaticComplexMethod") internal fun TangemButtonSize.toHorizontalContentPadding(icon: TangemButtonIconPosition): Pair { return when (this) { TangemButtonSize.Default, @@ -124,5 +135,10 @@ internal fun TangemButtonSize.toHorizontalContentPadding(icon: TangemButtonIconP is TangemButtonIconPosition.Start -> TangemTheme.dimens.spacing16 to TangemTheme.dimens.spacing24 is TangemButtonIconPosition.End -> TangemTheme.dimens.spacing24 to TangemTheme.dimens.spacing16 } + TangemButtonSize.Small -> when (icon) { + is TangemButtonIconPosition.None -> TangemTheme.dimens.spacing12 to TangemTheme.dimens.spacing12 + is TangemButtonIconPosition.Start -> TangemTheme.dimens.spacing8 to TangemTheme.dimens.spacing12 + is TangemButtonIconPosition.End -> TangemTheme.dimens.spacing12 to TangemTheme.dimens.spacing8 + } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/containers/FooterContainer.kt b/core/ui/src/main/java/com/tangem/core/ui/components/containers/FooterContainer.kt index bebd090e66..028be341c1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/containers/FooterContainer.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/containers/FooterContainer.kt @@ -9,7 +9,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.res.TangemTheme /** @@ -32,7 +32,7 @@ fun FooterContainer( AnimatedVisibility(visible = footer != null) { val footerWrapped = remember(this) { requireNotNull(footer) } Text( - text = footerWrapped.resolveReference(), + text = footerWrapped.resolveAnnotatedReference(), style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, modifier = Modifier diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt index 017523fbbc..be5c0a9f2c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt @@ -60,7 +60,10 @@ fun AmountTextField( modifier: Modifier = Modifier, color: Color = TangemTheme.colors.text.primary1, backgroundColor: Color = TangemTheme.colors.background.action, - visualTransformation: VisualTransformation = AmountVisualTransformation(decimals), + visualTransformation: VisualTransformation = AmountVisualTransformation( + decimals = decimals, + symbolColor = if (value.isBlank()) TangemTheme.colors.text.disabled else color, + ), keyboardOptions: KeyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, ), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/PinTextField.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/PinTextField.kt new file mode 100644 index 0000000000..7c7285b37d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/PinTextField.kt @@ -0,0 +1,165 @@ +package com.tangem.core.ui.components.fields + +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.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.utils.StringsSigns.PASSWORD_VISUAL_CHAR +import kotlinx.coroutines.delay +import kotlin.random.Random + +@Composable +fun PinTextField( + value: String, + length: Int, + isPasswordVisual: Boolean, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + val textFieldValue = remember(value) { + TextFieldValue(value, selection = TextRange(value.length)) + } + + BasicTextField( + value = textFieldValue, + onValueChange = { + if (it.text.length <= length) { + onValueChange(it.text) + } + }, + modifier = modifier + .clickable { + focusRequester.requestFocus() + } + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + singleLine = true, + textStyle = TangemTheme.typography.body1, + maxLines = 1, + cursorBrush = SolidColor(TangemTheme.colors.text.primary1), + decorationBox = { innerTextField -> + Box(modifier = Modifier.width(0.dp)) { + innerTextField() + } + CellDecoration( + length = length, + isPasswordVisual = isPasswordVisual, + value = value, + ) + }, + ) + + LaunchedEffect(Unit) { + delay(timeMillis = 200) + focusRequester.requestFocus() + } +} + +@Suppress("MagicNumber") +@Composable +private fun CellDecoration( + length: Int, + value: String, + modifier: Modifier = Modifier, + isPasswordVisual: Boolean = false, +) { + val textMeasurer = rememberTextMeasurer() + val width = textMeasurer.measure("0") + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + repeat(length) { + val char = if (it < value.length) { + if (isPasswordVisual) PASSWORD_VISUAL_CHAR.toString() else value[it].toString() + } else { + "" + } + + Box( + modifier = Modifier + .background( + color = TangemTheme.colors.background.tertiary, + shape = RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + AnimatedContent( + targetState = char, + transitionSpec = { + ( + fadeIn(animationSpec = tween(220, delayMillis = 90)) + + slideInVertically(animationSpec = tween(330, delayMillis = 0)) + ) + .togetherWith( + fadeOut(animationSpec = tween(90)) + slideOutVertically(tween(220)), + ) + }, + ) { text -> + Text( + modifier = Modifier.sizeIn(minWidth = width.size.width.dp + 8.dp, minHeight = 48.dp), + text = text, + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + lineHeight = 48.sp, + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + TangemThemePreview { + var text by remember { mutableStateOf("123") } + + Column { + PinTextField( + value = text, + onValueChange = { text = it }, + isPasswordVisual = false, + length = 6, + ) + + Row { + Button(onClick = { text += Random.nextInt(0, 10).toString() }) { + Text("Add Character") + } + Button(onClick = { text = text.dropLast(1) }) { + Text("Delete") + } + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt index 17521b19b9..c16ed2e9f5 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt @@ -1,9 +1,12 @@ package com.tangem.core.ui.components.fields.visualtransformations +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation +import com.tangem.core.ui.extensions.appendColored import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CURRENCY_SPACE import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode @@ -19,6 +22,7 @@ class AmountVisualTransformation( private val symbol: String? = null, private val currencyCode: String? = null, private val decimalFormat: DecimalFormat = DecimalFormat(), + private val symbolColor: Color, ) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { @@ -28,17 +32,22 @@ class AmountVisualTransformation( ) formattedAmount = formattedAmount.ifEmpty { decimalFormat.defaultFormat() } val formattedText = if (formattedAmount.isNotEmpty() && symbol != null) { - AnnotatedString( + buildAnnotatedString { if (currencyCode != null) { - formatFiatEditableAmount( - fiatAmount = formattedAmount, - fiatCurrencyCode = currencyCode, - fiatCurrencySymbol = symbol, + append( + formatFiatEditableAmount( + fiatAmount = formattedAmount, + fiatCurrencyCode = currencyCode, + fiatCurrencySymbol = symbol, + fiatCurrencySymbolColor = symbolColor, + ), ) } else { - formatWithSymbol(formattedAmount, symbol) - }, - ) + append(formattedAmount) + append(CURRENCY_SPACE) + appendColored(symbol, symbolColor) + } + } } else { AnnotatedString(decimalFormat.defaultFormat()) } @@ -54,9 +63,10 @@ class AmountVisualTransformation( fiatAmount: String?, fiatCurrencyCode: String, fiatCurrencySymbol: String, + fiatCurrencySymbolColor: Color, locale: Locale = Locale.getDefault(), - ): String { - if (fiatAmount == null) return BigDecimalFormatConstants.EMPTY_BALANCE_SIGN + ): AnnotatedString { + if (fiatAmount == null) return AnnotatedString(BigDecimalFormatConstants.EMPTY_BALANCE_SIGN) val formatterCurrency = getJavaCurrencyByCode(fiatCurrencyCode) val numberFormatter = NumberFormat.getCurrencyInstance(locale).apply { @@ -64,14 +74,21 @@ class AmountVisualTransformation( } val formatter = requireNotNull(numberFormatter as? DecimalFormat) { Timber.e("NumberFormat is null") - return BigDecimalFormatConstants.EMPTY_BALANCE_SIGN + return AnnotatedString(BigDecimalFormatConstants.EMPTY_BALANCE_SIGN) + } + return buildAnnotatedString { + appendColored( + formatter.positivePrefix.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol), + fiatCurrencySymbolColor, + ) + append(fiatAmount) + appendColored( + formatter.positiveSuffix.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol), + fiatCurrencySymbolColor, + ) } - return "${formatter.positivePrefix}$fiatAmount${formatter.positiveSuffix}" - .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) } - private fun formatWithSymbol(amount: String, symbol: String) = "$amount$CURRENCY_SPACE$symbol" - private class OffsetMappingImpl( private val text: String, private val formattedText: AnnotatedString, 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 6b8f360c17..f930945feb 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 @@ -50,6 +50,7 @@ fun InputRowEnter( text: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, + placeholder: TextReference? = null, titleColor: Color = TangemTheme.colors.text.secondary, textColor: Color = TangemTheme.colors.text.primary1, isSingleLine: Boolean = false, @@ -78,6 +79,7 @@ fun InputRowEnter( SimpleTextField( value = text, onValueChange = onValueChange, + placeholder = placeholder, color = textColor, singleLine = isSingleLine, visualTransformation = visualTransformation, 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 1e15b0b4dd..a3fda95160 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 @@ -82,6 +82,7 @@ fun InputRowEnterAmount( decimals = decimals, symbol = symbol, decimalFormat = rememberDecimalFormat(), + symbolColor = textColor, ), onValueChange = onValueChange, color = textColor, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt index 5a92d21899..5878b03d62 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt @@ -82,6 +82,7 @@ fun InputRowEnterInfoAmount( decimals = decimals, symbol = symbol, decimalFormat = rememberDecimalFormat(), + symbolColor = textColor, ), onValueChange = onValueChange, color = textColor, @@ -120,6 +121,7 @@ fun InputRowEnterInfoAmountV2( titleColor: Color = TangemTheme.colors.text.secondary, textColor: Color = TangemTheme.colors.text.primary1, infoColor: Color = TangemTheme.colors.text.tertiary, + symbolColor: Color = TangemTheme.colors.text.primary1, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, showDivider: Boolean = false, @@ -141,7 +143,7 @@ fun InputRowEnterInfoAmountV2( ) Icon( modifier = Modifier.size(16.dp), - painter = painterResource(R.drawable.ic_union_16), + painter = painterResource(R.drawable.ic_token_info_24), tint = TangemTheme.colors.icon.informative, contentDescription = null, ) @@ -154,6 +156,7 @@ fun InputRowEnterInfoAmountV2( decimals = decimals, symbol = symbol, decimalFormat = rememberDecimalFormat(), + symbolColor = symbolColor, ), onValueChange = onValueChange, color = textColor, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt index 8d5363d3cc..8991b68840 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt @@ -2,6 +2,10 @@ package com.tangem.core.ui.components.inputrow import android.content.res.Configuration import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -16,6 +20,7 @@ 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.R +import com.tangem.core.ui.components.buttons.small.TangemIconButton import com.tangem.core.ui.components.fields.SimpleTextField import com.tangem.core.ui.components.icons.identicon.IdentIcon import com.tangem.core.ui.components.inputrow.inner.CrossIcon @@ -25,24 +30,29 @@ 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.DEFAULT_ANIMATION_DURATION import kotlinx.coroutines.delay /** - * [Input Row Recipient](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-826&mode=design&t=IQ5lBJEkFGU4WSvi-4) + * * [Design Reference](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-826&mode=design&t=IQ5lBJEkFGU4WSvi-4) * - * @param title title reference - * @param value recipient address - * @param placeholder placeholder - * @param onValueChange callback for value change - * @param onPasteClick callback for paste - * @param modifier composable modifier - * @param singleLine is single line text - * @param error error text - * @param isError is error flag - * @param showDivider show divider + * @param title The title text reference to display above the input field + * @param value The current recipient address value to display in the input field + * @param placeholder The placeholder text reference to show when the input is empty + * @param onValueChange Callback invoked when the input value changes, receives the new string value + * @param onPasteClick Callback invoked when the paste button is clicked, receives the pasted string + * @param onQrCodeClick Callback invoked when the QR code scan button is clicked + * @param modifier Optional modifier to apply to the composable + * @param singleLine Whether the text field should be constrained to a single line (default: false) + * @param error Optional error text reference to display when validation fails + * @param isError Whether the input is in an error state, affects styling and title color + * @param showDivider Whether to show a divider below the component (default: false) + * @param isLoading Whether to show a loading indicator instead of the identicon (default: false) + * @param isValuePasted Whether the current value was pasted, affects visual feedback (default: false) * - * @see InputRowRecipientDefault for readonly version + * @see InputRowRecipientDefault for a read-only version of this component */ +@Suppress("LongParameterList", "LongMethod") @Composable fun InputRowRecipient( title: TextReference, @@ -50,6 +60,8 @@ fun InputRowRecipient( placeholder: TextReference, onValueChange: (String) -> Unit, onPasteClick: (String) -> Unit, + onQrCodeClick: () -> Unit, + isRedesignEnabled: Boolean, modifier: Modifier = Modifier, singleLine: Boolean = false, error: TextReference? = null, @@ -109,18 +121,55 @@ fun InputRowRecipient( .padding(start = TangemTheme.dimens.spacing8), ) } - PasteButton( - isPasteButtonVisible = value.isBlank(), - onClick = onPasteClick, + Row( modifier = Modifier - .align(CenterEnd) - .padding(start = TangemTheme.dimens.spacing8), - ) + .align(CenterEnd), + ) { + if (isRedesignEnabled) { + QrButton( + visible = !singleLine, + onQrCodeClick = onQrCodeClick, + ) + } + PasteButton( + isPasteButtonVisible = value.isBlank(), + onClick = onPasteClick, + backgroundColorEnabled = if (isRedesignEnabled) { + TangemTheme.colors.button.secondary + } else { + TangemTheme.colors.button.primary + }, + textColor = if (isRedesignEnabled) { + TangemTheme.colors.text.primary1 + } else { + TangemTheme.colors.text.primary2 + }, + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing8), + ) + } } } } } +@Composable +private fun QrButton(visible: Boolean, onQrCodeClick: () -> Unit, modifier: Modifier = Modifier) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(animationSpec = tween(DEFAULT_ANIMATION_DURATION)), + modifier = modifier, + ) { + TangemIconButton( + iconRes = R.drawable.ic_scan_16, + onClick = onQrCodeClick, + background = TangemTheme.colors.button.secondary, + iconTint = TangemTheme.colors.text.primary1, + ) + } +} + @Composable private fun RowScope.InputIcon(isLoading: Boolean, value: String) { var isLoadingProxy by remember { mutableStateOf(isLoading) } @@ -172,7 +221,9 @@ private fun InputRowRecipientPreview( showDivider = true, onValueChange = {}, onPasteClick = {}, + onQrCodeClick = {}, modifier = Modifier.background(TangemTheme.colors.background.primary), + isRedesignEnabled = false, ) } } 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 ef23a4babc..d816b39eb6 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 @@ -14,6 +14,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalHapticFeedback @@ -34,14 +35,21 @@ import com.tangem.core.ui.utils.DEFAULT_ANIMATION_DURATION * @param modifier composable modifier */ @Composable -fun PasteButton(isPasteButtonVisible: Boolean, onClick: (String) -> Unit, modifier: Modifier = Modifier) { +fun PasteButton( + isPasteButtonVisible: Boolean, + onClick: (String) -> Unit, + modifier: Modifier = Modifier, + backgroundColorEnabled: Color = TangemTheme.colors.button.primary, + backgroundColorDisabled: Color = TangemTheme.colors.button.secondary, + textColor: Color = TangemTheme.colors.text.primary2, +) { val clipboardManager = LocalClipboardManager.current val hapticFeedback = LocalHapticFeedback.current val isPasteEnabled = !clipboardManager.getText()?.text.isNullOrEmpty() val color = if (isPasteEnabled) { - TangemTheme.colors.button.primary + backgroundColorEnabled } else { - TangemTheme.colors.button.secondary + backgroundColorDisabled } AnimatedVisibility( visible = isPasteButtonVisible, @@ -53,7 +61,7 @@ fun PasteButton(isPasteButtonVisible: Boolean, onClick: (String) -> Unit, modifi Text( text = stringResourceSafe(R.string.common_paste), style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.primary2, + color = textColor, modifier = Modifier .background( color = color, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/provider/ProviderChooseCrypto.kt b/core/ui/src/main/java/com/tangem/core/ui/components/provider/ProviderChooseCrypto.kt new file mode 100644 index 0000000000..4130c95004 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/provider/ProviderChooseCrypto.kt @@ -0,0 +1,338 @@ +package com.tangem.core.ui.components.provider + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import androidx.constraintlayout.compose.Visibility +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.R +import com.tangem.core.ui.components.audits.AuditLabel +import com.tangem.core.ui.components.audits.AuditLabelUM +import com.tangem.core.ui.components.badge.Badge +import com.tangem.core.ui.components.badge.entity.BadgeUM +import com.tangem.core.ui.components.provider.entity.ProviderChooseUM +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.persistentListOf + +/** + * Provider Row - Choose Crypto component + * + * @param providerChooseUM component ui model + * @param onClick click listener + * @param modifier composable modifier + * + * @see Figma + */ +@Composable +@Suppress("DestructuringDeclarationWithTooManyEntries") +fun ProviderChooseCrypto(providerChooseUM: ProviderChooseUM, onClick: () -> Unit, modifier: Modifier = Modifier) { + ConstraintLayout( + modifier = modifier + .background(TangemTheme.colors.background.action) + .clip(RoundedCornerShape(14.dp)) + .selectedBorder(isSelected = providerChooseUM.isSelected) + .clickable(enabled = true, onClick = onClick) + .fillMaxWidth() + .padding(all = 12.dp), + ) { + val (iconRef, titleRef, extraRef, infoRef, labelRef) = createRefs() + val titleEndRef = createStartBarrier(infoRef, labelRef) + + IconContent( + iconUrl = providerChooseUM.iconUrl, + modifier = Modifier + .constrainAs(iconRef) { + start.linkTo(parent.start) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + ) + TitleContent( + providerChooseUM = providerChooseUM, + modifier = Modifier.constrainAs(titleRef) { + start.linkTo(iconRef.end, 12.dp) + end.linkTo(titleEndRef, 4.dp) + top.linkTo(parent.top) + bottom.linkTo(extraRef.top) + width = Dimension.fillToConstraints + }, + ) + ExtraContent( + providerChooseUM = providerChooseUM, + modifier = Modifier.constrainAs(extraRef) { + start.linkTo(iconRef.end, 12.dp) + end.linkTo(labelRef.start, 4.dp) + top.linkTo(titleRef.bottom) + bottom.linkTo(parent.bottom) + width = Dimension.fillToConstraints + visibility = if (providerChooseUM.extraUM is ProviderChooseUM.ExtraUM.Empty) { + Visibility.Gone + } else { + Visibility.Visible + } + }, + ) + InfoContent( + text = providerChooseUM.infoText, + modifier = Modifier.constrainAs(infoRef) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(labelRef.top, 4.dp) + }, + ) + LabelContent( + labelUM = providerChooseUM.labelUM, + modifier = Modifier.constrainAs(labelRef) { + end.linkTo(parent.end) + top.linkTo(infoRef.bottom) + bottom.linkTo(parent.bottom) + visibility = if (providerChooseUM.labelUM == null) { + Visibility.Gone + } else { + Visibility.Visible + } + }, + ) + } +} + +@Composable +private fun IconContent(iconUrl: String, modifier: Modifier = Modifier) { + SubcomposeAsyncImage( + modifier = modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .background(TangemColorPalette.Light1), + model = ImageRequest.Builder(context = LocalContext.current) + .data(iconUrl) + .crossfade(enable = true) + .allowHardware(false) + .build(), + contentDescription = null, + ) +} + +@Composable +private fun TitleContent(providerChooseUM: ProviderChooseUM, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + Text( + text = providerChooseUM.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + ) + Text( + text = providerChooseUM.subtitle.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + ) + } +} + +@Composable +private fun InfoContent(text: TextReference, modifier: Modifier = Modifier) { + Text( + text = text.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) +} + +@Composable +private fun ExtraContent(providerChooseUM: ProviderChooseUM, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + when (val extraUM = providerChooseUM.extraUM) { + is ProviderChooseUM.ExtraUM.Action -> Text( + text = extraUM.text.resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .padding(top = 4.dp) + .clip(RoundedCornerShape(4.dp)) + .background(TangemTheme.colors.button.secondary) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) + is ProviderChooseUM.ExtraUM.Badges -> extraUM.badgeList.fastForEach { badge -> + Badge( + iconRes = badge.iconRes, + text = badge.text, + modifier = Modifier.padding(top = 4.dp), + ) + } + is ProviderChooseUM.ExtraUM.Error -> Text( + text = extraUM.text.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = 6.dp), + ) + else -> Unit + } + } +} + +@Composable +private fun LabelContent(labelUM: ProviderChooseUM.LabelUM?, modifier: Modifier = Modifier) { + Box( + modifier = modifier, + ) { + when (labelUM) { + is ProviderChooseUM.LabelUM.Info -> AuditLabel(labelUM.auditLabelUM) + is ProviderChooseUM.LabelUM.Text -> Text( + text = labelUM.text.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.warning, + ) + null -> Unit + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun ProviderChooseCrypto_Preview( + @PreviewParameter(ProviderChooseCryptoPreviewProvider::class) params: ProviderChooseUM, +) { + TangemThemePreview { + ProviderChooseCrypto( + providerChooseUM = params, + onClick = {}, + ) + } +} + +private class ProviderChooseCryptoPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + ProviderChooseUM( + title = stringReference("ChangeHero"), + subtitle = stringReference("CEX"), + infoText = stringReference("1 800,00 POL"), + iconUrl = "", + extraUM = ProviderChooseUM.ExtraUM.Badges( + persistentListOf( + BadgeUM( + text = stringReference("4.9"), + iconRes = R.drawable.ic_star_outline_24, + ), + BadgeUM( + text = stringReference("5 mins"), + iconRes = R.drawable.ic_speed_24, + ), + BadgeUM( + text = stringReference("$3.45"), + iconRes = R.drawable.ic_gas_24, + ), + ), + ), + isSelected = true, + labelUM = ProviderChooseUM.LabelUM.Info( + auditLabelUM = AuditLabelUM( + text = resourceReference(R.string.express_provider_best_rate), + type = AuditLabelUM.Type.Info, + ), + ), + ), + ProviderChooseUM( + title = stringReference("Changelly"), + subtitle = stringReference("CEX"), + infoText = stringReference("1 799,12 POL"), + iconUrl = "", + extraUM = ProviderChooseUM.ExtraUM.Action( + text = resourceReference(R.string.express_provider_permission_needed), + ), + isSelected = false, + labelUM = ProviderChooseUM.LabelUM.Text( + text = stringReference("–0.2%"), + ), + ), + ProviderChooseUM( + title = stringReference("Changelly"), + subtitle = stringReference("CEX"), + infoText = stringReference("1 799,12 POL"), + iconUrl = "", + extraUM = ProviderChooseUM.ExtraUM.Error( + text = resourceReference(R.string.express_provider_min_amount, wrappedList("1 POL")), + ), + isSelected = false, + labelUM = ProviderChooseUM.LabelUM.Text( + text = stringReference("–0.2%"), + ), + ), + ProviderChooseUM( + title = stringReference("Changelly"), + subtitle = stringReference("CEX"), + infoText = stringReference("1 POL"), + iconUrl = "", + extraUM = ProviderChooseUM.ExtraUM.Empty, + isSelected = false, + labelUM = ProviderChooseUM.LabelUM.Text( + text = stringReference("–0.2%"), + ), + ), + ProviderChooseUM( + title = stringReference("Changelly"), + subtitle = stringReference("CEX"), + infoText = stringReference("1 POL"), + iconUrl = "", + extraUM = ProviderChooseUM.ExtraUM.Empty, + isSelected = false, + labelUM = null, + ), + ProviderChooseUM( + title = stringReference("Changelly"), + subtitle = stringReference("CEX"), + infoText = stringReference("1 POL"), + iconUrl = "", + extraUM = ProviderChooseUM.ExtraUM.Badges( + persistentListOf( + BadgeUM( + text = stringReference("4.9"), + iconRes = R.drawable.ic_star_outline_24, + ), + BadgeUM( + text = stringReference("5 mins"), + iconRes = R.drawable.ic_speed_24, + ), + BadgeUM( + text = stringReference("$3.45"), + iconRes = R.drawable.ic_gas_24, + ), + ), + ), + isSelected = false, + labelUM = null, + ), + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/provider/entity/ProviderChooseUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/provider/entity/ProviderChooseUM.kt new file mode 100644 index 0000000000..8814ed4c8f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/provider/entity/ProviderChooseUM.kt @@ -0,0 +1,55 @@ +package com.tangem.core.ui.components.provider.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.audits.AuditLabelUM +import com.tangem.core.ui.components.badge.entity.BadgeUM +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +/** + * Provider Row - Choose Crypto component UI Model + * + * @property title title text + * @property subtitle subtitle text + * @property infoText info text + * @property iconUrl icon url + * @property isSelected indicated whether component is selected (border outline) + * @property extraUM list of extra components + * @property labelUM label component (whether AuditLabel or text) + */ +data class ProviderChooseUM( + val title: TextReference, + val subtitle: TextReference, + val infoText: TextReference, + val iconUrl: String, + val isSelected: Boolean, + val extraUM: ExtraUM, + val labelUM: LabelUM?, +) { + @Immutable + sealed class LabelUM { + data class Info( + val auditLabelUM: AuditLabelUM, + ) : LabelUM() + + data class Text( + val text: TextReference, + ) : LabelUM() + } + + @Immutable + sealed class ExtraUM { + data object Empty : ExtraUM() + data class Badges( + val badgeList: ImmutableList, + ) : ExtraUM() + + data class Action( + val text: TextReference, + ) : ExtraUM() + + data class Error( + val text: TextReference, + ) : ExtraUM() + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/MarkdownExtension.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/MarkdownExtension.kt index c510835242..f1e49e308f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/MarkdownExtension.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/MarkdownExtension.kt @@ -60,4 +60,40 @@ fun AnnotatedString.Builder.appendSpace() = append(" ") fun AnnotatedString.Builder.appendColored(text: String, color: Color) = withStyle(SpanStyle(color = color)) { append(text) +} + +/** + * Appends text from a template string to the AnnotatedString.Builder, replacing a placeholder (default "%s") + * with custom styled content provided by a lambda. The lambda allows you to insert styled or complex content + * (e.g., colored, bold, or annotated text) at the placeholder position. + * + * If the placeholder is not found in the template, the function does nothing. + * + * Example usage: + * builder.appendWithStyledPlaceholder( + * template = "Ensure you %s network address, as errors may result in lost transfers" + * ) { + * withStyle(SpanStyle(color = Color.Red, fontWeight = FontWeight.Bold)) { + * append("Ethereum") + * } + * } + * + * @param template The template string containing the placeholder to be replaced. + * @param placeholder The placeholder string to be replaced by styled content. Default is "%s". + * @param styledContent Lambda to build the styled content to insert at the placeholder position. + */ +fun AnnotatedString.Builder.appendWithStyledPlaceholder( + template: String, + placeholder: String = "%s", + styledContent: AnnotatedString.Builder.() -> Unit, +) { + val index = template.indexOf(placeholder) + if (index < 0) return + // Append before placeholder + if (index > 0) append(template.substring(0, index)) + // Append styled content + styledContent() + // Append after placeholder + val afterIndex = index + placeholder.length + if (afterIndex < template.length) append(template.substring(afterIndex)) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt index a014910b24..ef94c6b681 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt @@ -1,12 +1,21 @@ package com.tangem.core.ui.extensions import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.MultipleClickPreventer /** @@ -38,4 +47,51 @@ fun Modifier.conditional(condition: Boolean, modifier: Modifier.() -> Modifier): } else { this } -} \ No newline at end of file +} + +/** + * Conditionally applies a modifier based on a boolean condition. + */ +@Composable +fun Modifier.conditionalCompose( + condition: Boolean, + modifier: @Composable Modifier.() -> Modifier = { Modifier }, + otherModifier: @Composable Modifier.() -> Modifier = { this }, +): Modifier { + return if (condition) { + then(modifier(Modifier)) + } else { + otherModifier() + } +} + +/** + * Border with accent outline when selected + */ +@Composable +fun Modifier.selectedBorder( + isSelected: Boolean, + width: Dp = 2.5.dp, + color: Color = TangemTheme.colors.text.accent.copy(alpha = 0.1f), + radius: Dp = 16.dp, +) = conditionalCompose( + condition = isSelected, + modifier = { + border( + width = width, + color = color, + shape = RoundedCornerShape(radius), + ) + .padding(width) + .border( + width = 1.dp, + color = TangemTheme.colors.text.accent, + shape = RoundedCornerShape(radius - 2.dp), + ) + .clip(RoundedCornerShape(radius - 2.dp)) + }, + otherModifier = { + padding(width) + .clip(RoundedCornerShape(radius - 2.dp)) + }, +) \ No newline at end of file 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 d7de6de18e..feca4f686c 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 @@ -4,6 +4,8 @@ object TestTags { const val STORIES_SCREEN = "STORIES_SCREEN_CONTAINER" const val STORIES_SCREEN_SCAN_BUTTON = "STORIES_SCREEN_SCAN_BUTTON" const val STORIES_SCREEN_ORDER_BUTTON = "STORIES_SCREEN_ORDER_BUTTON" + const val STORIES_SCREEN_CREATE_NEW_WALLET_BUTTON = "STORIES_SCREEN_CREATE_NEW_WALLET_BUTTON" + const val STORIES_SCREEN_ADD_EXISTING_WALLET_BUTTON = "STORIES_SCREEN_ADD_EXISTING_WALLET_BUTTON" const val MAIN_SCREEN = "MAIN_SCREEN_CONTAINER" const val MAIN_SCREEN_MORE_BUTTON = "MAIN_SCREEN_MORE_BUTTON" diff --git a/core/ui/src/main/res/drawable/ic_create_mobile_wallet_56.xml b/core/ui/src/main/res/drawable/ic_create_mobile_wallet_56.xml new file mode 100644 index 0000000000..c4efe75b71 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_create_mobile_wallet_56.xml @@ -0,0 +1,43 @@ + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_fee_new_24.xml b/core/ui/src/main/res/drawable/ic_fee_new_24.xml index a644c488cf..435ad051d4 100644 --- a/core/ui/src/main/res/drawable/ic_fee_new_24.xml +++ b/core/ui/src/main/res/drawable/ic_fee_new_24.xml @@ -1,21 +1,27 @@ + android:width="24dp" + android:height="24dp" + android:autoMirrored="true" + android:viewportWidth="24" + android:viewportHeight="24"> + + android:fillColor="#0099FF" + android:fillType="evenOdd" + android:pathData="M12,3.547C11.407,3.547 10.866,3.701 10.283,3.956C9.721,4.201 9.073,4.562 8.271,5.008L5.073,6.788C4.611,7.045 4.214,7.374 3.939,7.825C3.662,8.278 3.547,8.793 3.547,9.357L3.547,9.411C3.546,9.602 3.546,9.825 3.599,10.027C3.733,10.53 4.055,10.86 4.448,11.035C4.663,11.13 4.887,11.175 5.088,11.194V16.66C4.167,17.188 3.547,18.181 3.547,19.318C3.547,19.945 4.055,20.453 4.682,20.453H11.23C11.644,20.453 11.98,20.117 11.98,19.703C11.98,19.289 11.644,18.953 11.23,18.953H5.09C5.254,18.267 5.872,17.757 6.608,17.757H11.23C11.644,17.757 11.98,17.421 11.98,17.007C11.98,16.593 11.644,16.257 11.23,16.257H9.669V11.21H18.572C18.834,11.21 19.205,11.19 19.553,11.035C19.946,10.86 20.268,10.53 20.401,10.027C20.455,9.825 20.454,9.602 20.454,9.411L20.454,9.357C20.454,8.793 20.339,8.278 20.062,7.825C19.787,7.374 19.39,7.045 18.928,6.788L15.73,5.008C14.928,4.562 14.28,4.201 13.718,3.956C13.134,3.701 12.594,3.547 12,3.547ZM8.968,6.337C9.81,5.868 10.395,5.543 10.883,5.33C11.356,5.124 11.685,5.047 12,5.047C12.316,5.047 12.645,5.124 13.118,5.33C13.606,5.543 14.191,5.868 15.033,6.337L18.198,8.099C18.506,8.269 18.679,8.437 18.782,8.606C18.883,8.772 18.954,9 18.954,9.357C18.954,9.489 18.954,9.563 18.951,9.617C18.95,9.635 18.949,9.646 18.949,9.652C18.948,9.657 18.946,9.66 18.945,9.663L18.942,9.664C18.923,9.673 18.884,9.686 18.817,9.695C18.75,9.705 18.67,9.71 18.572,9.71H5.429C5.331,9.71 5.251,9.705 5.184,9.695C5.116,9.686 5.078,9.673 5.059,9.664L5.056,9.663C5.055,9.66 5.053,9.657 5.052,9.652C5.051,9.646 5.051,9.635 5.05,9.617C5.047,9.563 5.047,9.489 5.047,9.357C5.047,9 5.118,8.772 5.219,8.606C5.322,8.437 5.495,8.269 5.802,8.099L8.968,6.337ZM6.608,16.257L6.588,16.257V11.21H8.169V16.257H6.608Z" /> + + android:fillColor="#0099FF" + android:pathData="M13.396,18.788C13.103,19.081 13.103,19.556 13.396,19.849C13.689,20.142 14.164,20.142 14.457,19.849L19.849,14.456C20.142,14.163 20.142,13.689 19.849,13.396C19.556,13.103 19.081,13.103 18.788,13.396L13.396,18.788Z" /> + + android:fillColor="#0099FF" + android:fillType="evenOdd" + android:pathData="M13.176,14.503C13.176,13.77 13.77,13.176 14.504,13.176C15.237,13.176 15.832,13.77 15.832,14.503C15.832,15.237 15.237,15.831 14.504,15.831C13.77,15.831 13.176,15.237 13.176,14.503ZM14.504,14.676C14.599,14.676 14.676,14.599 14.676,14.503C14.676,14.408 14.599,14.331 14.504,14.331C14.409,14.331 14.332,14.408 14.332,14.503C14.332,14.599 14.409,14.676 14.504,14.676Z" /> + + android:fillColor="#0099FF" + android:fillType="evenOdd" + android:pathData="M18.741,17.412C18.008,17.412 17.413,18.007 17.413,18.74C17.413,19.473 18.008,20.068 18.741,20.068C19.474,20.068 20.069,19.473 20.069,18.74C20.069,18.007 19.474,17.412 18.741,17.412ZM18.913,18.74C18.913,18.835 18.836,18.912 18.741,18.912C18.646,18.912 18.569,18.835 18.569,18.74C18.569,18.645 18.646,18.568 18.741,18.568C18.836,18.568 18.913,18.645 18.913,18.74Z" /> + diff --git a/core/ui/src/main/res/drawable/ic_gas_24.xml b/core/ui/src/main/res/drawable/ic_gas_24.xml new file mode 100644 index 0000000000..278945f9fa --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_gas_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_scan_16.xml b/core/ui/src/main/res/drawable/ic_scan_16.xml new file mode 100644 index 0000000000..21e35b19c6 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_scan_16.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_speed_24.xml b/core/ui/src/main/res/drawable/ic_speed_24.xml new file mode 100644 index 0000000000..8e4390a831 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_speed_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_star_outline_24.xml b/core/ui/src/main/res/drawable/ic_star_outline_24.xml new file mode 100644 index 0000000000..116e8fdc03 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_star_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_token_info_24.xml b/core/ui/src/main/res/drawable/ic_token_info_24.xml new file mode 100644 index 0000000000..87c467d679 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_token_info_24.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_union_16.xml b/core/ui/src/main/res/drawable/ic_union_16.xml deleted file mode 100644 index 3281f5e0ff..0000000000 --- a/core/ui/src/main/res/drawable/ic_union_16.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/features/wallet/impl/src/main/res/drawable/img_tangem_logo_90_24.xml b/core/ui/src/main/res/drawable/img_tangem_logo_90_24.xml similarity index 100% rename from features/wallet/impl/src/main/res/drawable/img_tangem_logo_90_24.xml rename to core/ui/src/main/res/drawable/img_tangem_logo_90_24.xml 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 7d53361c16..36945de43e 100644 --- a/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt +++ b/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt @@ -13,4 +13,5 @@ object StringsSigns { const val NON_BREAKING_SPACE = '\u00A0' const val PERCENT = "%" const val THREE_STARS = "\u2217\u2217\u2217" + const val PASSWORD_VISUAL_CHAR = '\u2022' } \ No newline at end of file diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidMapper.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidMapper.kt index 64daa2aaf6..e245687538 100644 --- a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidMapper.kt +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidMapper.kt @@ -39,6 +39,7 @@ internal object BlockAidMapper { } else { mapSimulationSuccessResult(from.simulation.accountSummary) }, + description = from.validation.description, ) } diff --git a/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/BlockAidMapperTest.kt b/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/BlockAidMapperTest.kt index 6d83fc64db..1980d63605 100644 --- a/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/BlockAidMapperTest.kt +++ b/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/BlockAidMapperTest.kt @@ -46,7 +46,7 @@ class BlockAidMapperTest { spenders = mapOf("spender" to spenderDetails), ) val response = TransactionScanResponse( - validation = ValidationResponse(status = "Success", resultType = "Benign"), + validation = ValidationResponse(status = "Success", resultType = "Benign", description = ""), simulation = SimulationResponse( status = "Success", accountSummary = AccountSummaryResponse( @@ -79,7 +79,7 @@ class BlockAidMapperTest { outTransfer = listOf(Transfer(value = "1.5", rawValue = "0x2")), ) val response = TransactionScanResponse( - validation = ValidationResponse(status = "Success", resultType = "Benign"), + validation = ValidationResponse(status = "Success", resultType = "Benign", description = ""), simulation = SimulationResponse( status = "Success", accountSummary = AccountSummaryResponse( @@ -105,7 +105,7 @@ class BlockAidMapperTest { @Test fun `when response error validation rhen returns failed to validate`() { val response = TransactionScanResponse( - validation = ValidationResponse(status = "Error", resultType = "Benign"), + validation = ValidationResponse(status = "Error", resultType = "Benign", description = ""), simulation = SimulationResponse( status = "Success", accountSummary = AccountSummaryResponse(emptyList(), emptyList(), null), @@ -120,7 +120,7 @@ class BlockAidMapperTest { @Test fun `when response not benign then returns validation unsafe`() { val response = TransactionScanResponse( - validation = ValidationResponse(status = "Success", resultType = "Phishing"), + validation = ValidationResponse(status = "Success", resultType = "Phishing", description = ""), simulation = SimulationResponse( status = "Success", accountSummary = AccountSummaryResponse(emptyList(), emptyList(), null), @@ -134,7 +134,7 @@ class BlockAidMapperTest { @Test fun `when response simulation not success then returns simulation failed ro simulate`() { val response = TransactionScanResponse( - validation = ValidationResponse(status = "Success", resultType = "Benign"), + validation = ValidationResponse(status = "Success", resultType = "Benign", description = ""), simulation = SimulationResponse( status = "Error", accountSummary = AccountSummaryResponse(emptyList(), emptyList(), null), @@ -148,7 +148,7 @@ class BlockAidMapperTest { @Test fun `when response simulation is empty then returns failed to simulate`() { val txResponse = TransactionScanResponse( - validation = ValidationResponse(status = "Success", resultType = "Benign"), + validation = ValidationResponse(status = "Success", resultType = "Benign", description = ""), simulation = SimulationResponse( status = "Success", accountSummary = AccountSummaryResponse( diff --git a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt index f443f4bd8a..6ceeb800e9 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt @@ -5,6 +5,8 @@ import com.tangem.data.common.currency.CardCryptoCurrencyFactory import com.tangem.data.common.currency.DefaultCardCryptoCurrencyFactory import com.tangem.data.common.currency.UserTokensResponseAddressesEnricher import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.data.common.quote.DefaultQuotesFetcher +import com.tangem.data.common.quote.QuotesFetcher import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.UserTokensResponseStore @@ -70,4 +72,10 @@ internal object DataCommonModule { userTokensResponseAddressesEnricher = enricher, ) } + + @Provides + @Singleton + fun provideQuotesFetcher(tangemTechApi: TangemTechApi, dispatchers: CoroutineDispatcherProvider): QuotesFetcher { + return DefaultQuotesFetcher(tangemTechApi = tangemTechApi, dispatchers = dispatchers) + } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcher.kt b/data/common/src/main/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcher.kt new file mode 100644 index 0000000000..ca867be571 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcher.kt @@ -0,0 +1,240 @@ +package com.tangem.data.common.quote + +import androidx.annotation.VisibleForTesting +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.ensure +import arrow.core.raise.ensureNotNull +import com.tangem.data.common.api.safeApiCallWithTimeout +import com.tangem.data.common.quote.QuotesFetcher.Error +import com.tangem.data.common.quote.QuotesFetcher.Field +import com.tangem.data.common.quote.utils.combine +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.domain.core.utils.eitherOn +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.joda.time.DateTime +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.seconds + +/** + * Default implementation of [QuotesFetcher] + * + * @property tangemTechApi Tangem tech API + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultQuotesFetcher( + private val tangemTechApi: TangemTechApi, + private val dispatchers: CoroutineDispatcherProvider, +) : QuotesFetcher { + + /** Mutex for synchronization of the [getFetchMutex] method */ + private val getFetchMethodMutex = Mutex() + + /** + * Map for tracking active [fetch] methods. + * The key is the parameters of the [fetch] method, and the value is [Mutex]. + */ + private val fetchMutexMap = ConcurrentHashMap() + + /** + * Cache that stores a list of quotes for a certain fiat currency. + * Quotes are considered expired if they are in the cache for more than [tenSecInMillis] seconds. + */ + private val quotesCache = ConcurrentHashMap>() + + override suspend fun fetch( + fiatCurrencyId: String, + currenciesIds: Set, + fields: Set, + ): Either = eitherOn(dispatchers.default) { + val validatedParams = validateParams(fiatCurrencyId, currenciesIds, fields) + + if (validatedParams.currenciesIds.isEmpty()) return@eitherOn emptyQuotesResponse + + val mutex = getFetchMutex(params = validatedParams) + + return@eitherOn fetch(params = validatedParams, mutex = mutex) + } + .onLeft { + val params = RequestParams(fiatCurrencyId, currenciesIds, fields) + fetchMutexMap.remove(params) + } + + private fun Raise.validateParams( + fiatCurrencyId: String, + currenciesIds: Set, + fields: Set, + ): RequestParams { + ensure(fiatCurrencyId.isNotBlank() && fields.isNotEmpty()) { + raise(Error.InvalidArgumentsError) + } + + val filterCurrenciesIds = currenciesIds.filter(String::isNotEmpty).toSet() + + return RequestParams(fiatCurrencyId = fiatCurrencyId, currenciesIds = filterCurrenciesIds, fields = fields) + } + + /** + * The method for determining the [Mutex], which will be used by the [fetch] method. + * + * @param params request params + * + * @return if at the moment the method of [fetch] is already executed for an adjacent set of parameters + + */ + private suspend fun Raise.getFetchMutex(params: RequestParams): Mutex { + return getFetchMethodMutex.withLock { + val similarJobsMutexes = fetchMutexMap.filterKeys { metadata -> + metadata.fiatCurrencyId == params.fiatCurrencyId && + params.currenciesIds.any { it in metadata.currenciesIds } + } + + val storedMutex = similarJobsMutexes.firstNotNullOfOrNull { it.value } + + when { + storedMutex == null || !storedMutex.isLocked -> Mutex() + storedMutex.isLocked -> storedMutex + else -> raise(Error.CacheOperationError) + } + .also { fetchMutexMap[params] = it } + } + } + + /** + * Fetch quotes by [params]. + * + * It works in the scope of the transferred [mutex]. + * If at the moment [Mutex] is busy, then it is working on fetching related data, then the current request will + * wait for its execution. + * + * @return [QuotesResponse] + */ + private suspend fun Raise.fetch(params: RequestParams, mutex: Mutex): QuotesResponse { + val (fiatCurrencyId, currenciesIds) = params + + return mutex.withLock { + val quotes = quotesCache[fiatCurrencyId].orEmpty() + + val expiredOrAbsentIds = currenciesIds.filterExpiredOrAbsent(quotes) + + // We will fetch quotes only for those quotes that are absent in cache or expired + if (expiredOrAbsentIds.isNotEmpty()) { + val response = requestQuotes( + fiatCurrencyId = fiatCurrencyId, + currenciesIds = expiredOrAbsentIds.toSet(), + fields = params.fields, + ) + + saveQuotes(fiatCurrencyId = fiatCurrencyId, response = response) + } + + fetchMutexMap.remove(params) + + getCachedResult(fiatCurrencyId = fiatCurrencyId, currenciesIds = currenciesIds) + } + } + + private fun Set.filterExpiredOrAbsent(quotes: Set?): List { + return filter { id -> + val quote = quotes?.firstOrNull { it.cryptoCurrencyId == id && !it.isExpired } + + quote == null + } + } + + private fun Raise.getCachedResult(fiatCurrencyId: String, currenciesIds: Set): QuotesResponse { + val storedQuotes = quotesCache[fiatCurrencyId] + + ensureNotNull(storedQuotes) { + raise(Error.CacheOperationError) + } + + val quotes = catch( + block = { + currenciesIds.associateWith { currencyId -> + storedQuotes.first { it.cryptoCurrencyId == currencyId }.value + } + }, + catch = { raise(Error.CacheOperationError) }, + ) + + return QuotesResponse(quotes = quotes) + } + + private suspend fun Raise.requestQuotes( + fiatCurrencyId: String, + currenciesIds: Set, + fields: Set, + ) = withContext(dispatchers.io) { + safeApiCallWithTimeout( + call = { + tangemTechApi.getQuotes( + currencyId = fiatCurrencyId, + coinIds = currenciesIds.joinToString(separator = ","), + fields = fields.combine(), + ) + .bind() + }, + onError = { raise(Error.ApiOperationError(it)) }, + ) + } + + private fun saveQuotes(fiatCurrencyId: String, response: QuotesResponse) { + val newQuotes = response.quotes.mapTo(destination = hashSetOf()) { (currencyId, quote) -> + QuoteMetadata( + cryptoCurrencyId = currencyId, + timestamp = DateTime.now().millis, + value = quote, + ) + } + + val storedQuotes = quotesCache[fiatCurrencyId].orEmpty() + + val storedUniqueQuotes = storedQuotes.filterNot { stored -> + newQuotes.any { stored.cryptoCurrencyId == it.cryptoCurrencyId } + } + + quotesCache[fiatCurrencyId] = (storedUniqueQuotes + newQuotes).toSet() + } + + data class RequestParams( + val fiatCurrencyId: String, + val currenciesIds: Set, + val fields: Set, + ) + + data class QuoteMetadata( + val cryptoCurrencyId: String, + val timestamp: Long, + val value: QuotesResponse.Quote, + ) { + + val isExpired: Boolean + get() = DateTime.now().millis - timestamp > tenSecInMillis + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun getCachedQuotes() = quotesCache + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun setCachedQuotes(fiatCurrencyId: String, quotes: Set) { + quotesCache[fiatCurrencyId] = quotes + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun clearCache() { + quotesCache.clear() + } + + private companion object { + val emptyQuotesResponse = QuotesResponse(quotes = emptyMap()) + val tenSecInMillis = 10.seconds.inWholeMilliseconds + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/quote/QuotesFetcher.kt b/data/common/src/main/kotlin/com/tangem/data/common/quote/QuotesFetcher.kt new file mode 100644 index 0000000000..4f1e8b92ce --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/quote/QuotesFetcher.kt @@ -0,0 +1,61 @@ +package com.tangem.data.common.quote + +import arrow.core.Either +import com.tangem.data.common.quote.utils.combine +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.tangemTech.models.QuotesResponse + +/** + * Fetcher of quotes + * +[REDACTED_AUTHOR] + * + * @see Documentation + */ +interface QuotesFetcher { + + /** + * Fetch + * + * @param fiatCurrencyId fiat currency id + * @param currenciesIds crypto currencies ids + * @param fields fields of [QuotesResponse.Quote] + */ + suspend fun fetch( + fiatCurrencyId: String, + currenciesIds: Set, + fields: Set, + ): Either + + /** + * Fetch + * + * @param fiatCurrencyId fiat currency id + * @param currencyId crypto currencies ids + * @param field fields of [QuotesResponse.Quote] + */ + suspend fun fetch(fiatCurrencyId: String, currencyId: String, field: Field): Either { + return fetch(fiatCurrencyId = fiatCurrencyId, currenciesIds = setOf(currencyId), fields = setOf(field)) + } + + enum class Field(internal val value: String) { + PRICE(value = "price"), + PRICE_CHANGE_24H(value = "priceChange24h"), + PRICE_CHANGE_1W(value = "priceChange1w"), + PRICE_CHANGE_30D(value = "priceChange30d"), + ALL_PRICES( + value = setOf(PRICE, PRICE_CHANGE_24H, PRICE_CHANGE_1W, PRICE_CHANGE_30D).combine(), + ), + LAST_UPDATED_AT(value = "lastUpdatedAt"), + ; + } + + sealed interface Error { + + data object InvalidArgumentsError : Error + + data object CacheOperationError : Error + + data class ApiOperationError(val apiError: ApiResponseError) : Error + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/quote/utils/FieldExt.kt b/data/common/src/main/kotlin/com/tangem/data/common/quote/utils/FieldExt.kt new file mode 100644 index 0000000000..e7ea17d255 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/quote/utils/FieldExt.kt @@ -0,0 +1,5 @@ +package com.tangem.data.common.quote.utils + +import com.tangem.data.common.quote.QuotesFetcher + +fun Set.combine(): String = joinToString(separator = ",", transform = QuotesFetcher.Field::value) \ No newline at end of file diff --git a/data/common/src/test/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcherTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcherTest.kt new file mode 100644 index 0000000000..add4f12f94 --- /dev/null +++ b/data/common/src/test/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcherTest.kt @@ -0,0 +1,444 @@ +package com.tangem.data.common.quote + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.data.common.quote.DefaultQuotesFetcher.QuoteMetadata +import com.tangem.data.common.quote.QuotesFetcher.Field +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import org.joda.time.DateTime +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal +import java.util.concurrent.ConcurrentHashMap + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultQuotesFetcherTest { + + private val tangemTechApi = mockk() + private val fetcher = DefaultQuotesFetcher( + tangemTechApi = tangemTechApi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @BeforeEach + fun resetMocks() { + clearMocks(tangemTechApi) + fetcher.clearCache() + } + + @Test + fun `fetch if fiatCurrencyId is EMPTY`() = runTest { + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "", + currenciesIds = setOf("ethereum"), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesFetcher.Error.InvalidArgumentsError.left() + Truth.assertThat(actual).isEqualTo(expected) + + val expectedCacheData = ConcurrentHashMap>() + Truth.assertThat(actualCacheData).isEqualTo(expectedCacheData) + + coVerify(inverse = true) { tangemTechApi.getQuotes(currencyId = any(), coinIds = any(), fields = any()) } + } + + @Test + fun `fetch if currenciesIds is EMPTY`() = runTest { + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = emptySet(), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesResponse(quotes = emptyMap()).right() + Truth.assertThat(actual).isEqualTo(expected) + + val expectedCacheData = ConcurrentHashMap>() + Truth.assertThat(actualCacheData).isEqualTo(expectedCacheData) + + coVerify(inverse = true) { tangemTechApi.getQuotes(currencyId = any(), coinIds = any(), fields = any()) } + } + + @Test + fun `fetch if fields is EMPTY`() = runTest { + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum"), + fields = emptySet(), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesFetcher.Error.InvalidArgumentsError.left() + Truth.assertThat(actual).isEqualTo(expected) + + val expectedCacheData = ConcurrentHashMap>() + Truth.assertThat(actualCacheData).isEqualTo(expectedCacheData) + + coVerify(inverse = true) { tangemTechApi.getQuotes(currencyId = any(), coinIds = any(), fields = any()) } + } + + @Test + fun `fetch if all currencies ids ARE CACHED and ARE NOT EXPIRED`() = runTest { + // Arrange + val quote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + + fetcher.setCachedQuotes( + fiatCurrencyId = "usd", + quotes = setOf( + QuoteMetadata( + cryptoCurrencyId = "ethereum", + timestamp = DateTime.now().millis, + value = quote, + ), + ), + ) + + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum"), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesResponse(quotes = mapOf("ethereum" to quote)).right() + Truth.assertThat(actual).isEqualTo(expected) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1) + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()).isEqualTo(mapOf("ethereum" to quote)) + + coVerify(inverse = true) { tangemTechApi.getQuotes(currencyId = any(), coinIds = any(), fields = any()) } + } + + @Test + fun `fetch if all currencies ids ARE CACHED and EXPIRED`() = runTest { + // Arrange + val cachedQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + val apiResponse = ApiResponse.Success( + data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)), + ) + + fetcher.setCachedQuotes( + fiatCurrencyId = "usd", + quotes = setOf( + QuoteMetadata( + cryptoCurrencyId = "ethereum", + timestamp = DateTime.now().millis - 10_000, + value = cachedQuote, + ), + ), + ) + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") + } returns apiResponse + + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum"), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)).right() + Truth.assertThat(actual).isEqualTo(expected) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1) + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()).isEqualTo(mapOf("ethereum" to apiQuote)) + + coVerify(exactly = 1) { tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") } + } + + @Test + fun `fetch if cache contain EXPIRED and NOT EXPIRED quotes`() = runTest { + // Arrange + val cachedQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + val apiResponse = ApiResponse.Success( + data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)), + ) + + fetcher.setCachedQuotes( + fiatCurrencyId = "usd", + quotes = setOf( + QuoteMetadata( + cryptoCurrencyId = "ethereum", + timestamp = DateTime.now().millis - 10_000, + value = cachedQuote, + ), + QuoteMetadata( + cryptoCurrencyId = "bitcoin", + timestamp = DateTime.now().millis, + value = cachedQuote, + ), + ), + ) + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") + } returns apiResponse + + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum", "bitcoin"), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesResponse( + quotes = mapOf("ethereum" to apiQuote, "bitcoin" to cachedQuote), + ).right() + + Truth.assertThat(actual).isEqualTo(expected) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1) + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()) + .isEqualTo(mapOf("ethereum" to apiQuote, "bitcoin" to cachedQuote)) + + coVerify(exactly = 1) { tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") } + } + + @Test + fun `fetch if cache DOES NOT CONTAIN fiat currency`() = runTest { + // Arrange + val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + val apiResponse = ApiResponse.Success( + data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)), + ) + + fetcher.setCachedQuotes(fiatCurrencyId = "eu", quotes = emptySet()) + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") + } returns apiResponse + + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum"), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)).right() + Truth.assertThat(actual).isEqualTo(expected) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(2) + Truth.assertThat(actualCacheData["eu"]).isEmpty() + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()).isEqualTo(mapOf("ethereum" to apiQuote)) + + coVerify(exactly = 1) { tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") } + } + + @Test + fun `fetch if cached quotes are ABSENT`() = runTest { + // Arrange + val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + val apiResponse = ApiResponse.Success( + data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)), + ) + + fetcher.setCachedQuotes(fiatCurrencyId = "usd", quotes = emptySet()) + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") + } returns apiResponse + + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum"), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)).right() + Truth.assertThat(actual).isEqualTo(expected) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1) + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()) + .isEqualTo(mapOf("ethereum" to apiQuote)) + + coVerify(exactly = 1) { tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") } + } + + @Test + fun `two parallel fetch if cache is empty`() = runTest { + // Arrange + val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + + val usdApiResponse = ApiResponse.Success( + data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)), + ) + + val euApiResponse = ApiResponse.Success( + data = QuotesResponse(quotes = mapOf("bitcoin" to apiQuote)), + ) + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") + } returns usdApiResponse + + coEvery { + tangemTechApi.getQuotes(currencyId = "eu", coinIds = "bitcoin", fields = "price") + } returns euApiResponse + + // Act + val actual = listOf( + async { + fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum"), + fields = setOf(Field.PRICE), + ) + }, + async { + fetcher.fetch( + fiatCurrencyId = "eu", + currenciesIds = setOf("bitcoin"), + fields = setOf(Field.PRICE), + ) + }, + ) + .awaitAll() + + val actual1 = actual[0] + val actual2 = actual[1] + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected1 = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)).right() + val expected2 = QuotesResponse(quotes = mapOf("bitcoin" to apiQuote)).right() + Truth.assertThat(actual1).isEqualTo(expected1) + Truth.assertThat(actual2).isEqualTo(expected2) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(2) + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()) + .isEqualTo(mapOf("ethereum" to apiQuote)) + Truth.assertThat(actualCacheData["eu"].toResponseQuotes()) + .isEqualTo(mapOf("bitcoin" to apiQuote)) + + coVerify(exactly = 1) { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") + tangemTechApi.getQuotes(currencyId = "eu", coinIds = "bitcoin", fields = "price") + } + } + + @Test + fun `two parallel fetch`() = runTest { + // Arrange + val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + + val firstApiResponse = ApiResponse.Success( + data = QuotesResponse( + quotes = mapOf("ethereum" to apiQuote, "solana" to apiQuote), + ), + ) + + val secondApiResponse = ApiResponse.Success( + data = QuotesResponse( + quotes = mapOf("bitcoin" to apiQuote, "solana" to apiQuote), + ), + ) + + fetcher.setCachedQuotes( + fiatCurrencyId = "usd", + quotes = setOf( + QuoteMetadata( + cryptoCurrencyId = "solana", + timestamp = DateTime.now().millis - 10_000, + value = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO), + ), + ), + ) + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum,solana", fields = "price") + } returns firstApiResponse + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "bitcoin", fields = "price") + } returns secondApiResponse + + // Act + val actual = listOf( + async { + fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum", "solana"), + fields = setOf(Field.PRICE), + ) + }, + async { + fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("bitcoin", "solana"), + fields = setOf(Field.PRICE), + ) + }, + ) + .awaitAll() + + val actual1 = actual[0] + val actual2 = actual[1] + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected1 = QuotesResponse(quotes = mapOf("ethereum" to apiQuote, "solana" to apiQuote)).right() + val expected2 = QuotesResponse(quotes = mapOf("bitcoin" to apiQuote, "solana" to apiQuote)).right() + Truth.assertThat(actual1).isEqualTo(expected1) + Truth.assertThat(actual2).isEqualTo(expected2) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1) + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()).isEqualTo( + mapOf("ethereum" to apiQuote, "bitcoin" to apiQuote, "solana" to apiQuote), + ) + + coVerifyOrder { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum,solana", fields = "price") + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "bitcoin", fields = "price") + } + } + + private fun Iterable?.toResponseQuotes() = this!!.associate { it.cryptoCurrencyId to it.value } +} \ No newline at end of file diff --git a/features/fee-selector/api/.gitignore b/data/express/.gitignore similarity index 100% rename from features/fee-selector/api/.gitignore rename to data/express/.gitignore diff --git a/data/express/build.gradle.kts b/data/express/build.gradle.kts new file mode 100644 index 0000000000..f9a4304009 --- /dev/null +++ b/data/express/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + alias(deps.plugins.ksp) + id("configuration") +} + +android { + namespace = "com.tangem.data.express" +} + +dependencies { + + /** Core */ + implementation(projects.core.datasource) + implementation(projects.core.utils) + + /** Data */ + implementation(projects.data.common) + + /** Domain */ + implementation(projects.domain.express.models) + implementation(projects.domain.express) + implementation(projects.domain.wallets.models) + + /** Other */ + implementation(deps.moshi) + implementation(deps.moshi.kotlin) + implementation(deps.timber) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/data/express/src/main/java/com/tangem/data/express/DefaultExpressRepository.kt b/data/express/src/main/java/com/tangem/data/express/DefaultExpressRepository.kt new file mode 100644 index 0000000000..4963d92a82 --- /dev/null +++ b/data/express/src/main/java/com/tangem/data/express/DefaultExpressRepository.kt @@ -0,0 +1,36 @@ +package com.tangem.data.express + +import com.tangem.data.common.api.safeApiCall +import com.tangem.data.express.converter.ExpressProviderConverter +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.express.TangemExpressApi +import com.tangem.datasource.exchangeservice.swap.ExpressUtils +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.ExpressRepository +import com.tangem.domain.wallets.models.UserWallet +import timber.log.Timber + +internal class DefaultExpressRepository( + private val tangemExpressApi: TangemExpressApi, + private val appPreferencesStore: AppPreferencesStore, +) : ExpressRepository { + + override suspend fun getProviders(userWallet: UserWallet): List { + return safeApiCall( + call = { + tangemExpressApi.getProviders( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + ).getOrThrow().map(ExpressProviderConverter()::convert) + }, + onError = { + Timber.w(it, "Unable to fetch express providers") + throw it + }, + ) + } +} \ No newline at end of file diff --git a/data/express/src/main/java/com/tangem/data/express/converter/ExpressErrorConverter.kt b/data/express/src/main/java/com/tangem/data/express/converter/ExpressErrorConverter.kt new file mode 100644 index 0000000000..b191da795b --- /dev/null +++ b/data/express/src/main/java/com/tangem/data/express/converter/ExpressErrorConverter.kt @@ -0,0 +1,91 @@ +package com.tangem.data.express.converter + +import com.squareup.moshi.JsonAdapter +import com.tangem.datasource.api.express.models.response.ExpressErrorResponse +import com.tangem.domain.express.models.ExpressError +import com.tangem.utils.converter.Converter +import com.tangem.datasource.api.express.models.response.ExpressError as ExpressErrorDTO + +class ExpressErrorConverter( + private val jsonAdapter: JsonAdapter, +) : Converter { + + @Suppress("MagicNumber", "CyclomaticComplexMethod") + override fun convert(value: String): ExpressError { + try { + val error: ExpressErrorDTO = jsonAdapter.fromJson(value)?.error ?: return ExpressError.UnknownError + + return when (error.code) { + 2010 -> ExpressError.BadRequest(code = error.code) + 2020 -> ExpressError.Forbidden(code = error.code) + + 2200 -> ExpressError.InternalError(code = error.code) + 2210 -> ExpressError.ProviderNotFoundError(code = error.code) + 2220 -> ExpressError.ProviderNotActiveError(code = error.code) + 2230 -> ExpressError.ProviderNotAvailableError(code = error.code) + 2231 -> ExpressError.ProviderInternalError(code = error.code) + 2240 -> ExpressError.ExchangeNotPossibleError(code = error.code) + 2250 -> error.toTooSmallAmountError() + 2251 -> error.toTooBigAmountError() + 2260 -> error.toNotEnoughAllowanceError() + 2270 -> ExpressError.NotEnoughBalanceError(code = error.code) + 2280 -> ExpressError.InvalidAddressError(code = error.code) + 2290 -> error.toInvalidFromDecimalsError() + + 2320 -> error.toProviderDifferentAmountError() + else -> ExpressError.DataError(error.code, error.description) + } + } catch (e: Exception) { + return ExpressError.UnknownError + } + } + + private fun ExpressErrorDTO.toTooSmallAmountError(): ExpressError { + val minAmount = value?.minAmount ?: return ExpressError.DataError(code, description) + val decimals = value?.decimals ?: return ExpressError.DataError(code, description) + val requiredAmount = minAmount.toBigDecimalOrNull()?.movePointLeft(decimals) + ?: return ExpressError.DataError(code, description) + + return ExpressError.AmountError.TooSmallError(code = code, amount = requiredAmount) + } + + private fun ExpressErrorDTO.toTooBigAmountError(): ExpressError { + val maxAmount = value?.maxAmount ?: return ExpressError.DataError(code, description) + val decimals = value?.decimals ?: return ExpressError.DataError(code, description) + val requiredAmount = maxAmount.toBigDecimalOrNull()?.movePointLeft(decimals) + ?: return ExpressError.DataError(code, description) + + return ExpressError.AmountError.TooBigError(code = code, amount = requiredAmount) + } + + private fun ExpressErrorDTO.toNotEnoughAllowanceError(): ExpressError { + val currentAllowance = value?.currentAllowance ?: return ExpressError.DataError(code, description) + + return ExpressError.AmountError.NotEnoughAllowanceError(code = code, amount = currentAllowance) + } + + private fun ExpressErrorDTO.toInvalidFromDecimalsError(): ExpressError { + val receivedFromDecimals = value?.receivedFromDecimals ?: return ExpressError.DataError(code, description) + val expressFromDecimals = value?.expressFromDecimals ?: return ExpressError.DataError(code, description) + + return ExpressError.InvalidFromDecimalsError( + code = code, + receivedFromDecimals = receivedFromDecimals, + expressFromDecimals = expressFromDecimals, + ) + } + + private fun ExpressErrorDTO.toProviderDifferentAmountError(): ExpressError { + val decimals = value?.decimals ?: return ExpressError.DataError(code, description) + val fromAmount = value?.fromAmount?.toBigDecimalOrNull() ?: return ExpressError.DataError(code, description) + val fromAmountProvider = value?.fromAmountProvider?.toBigDecimalOrNull() + ?: return ExpressError.DataError(code, description) + + return ExpressError.ProviderDifferentAmountError( + code = code, + decimals = decimals, + fromAmount = fromAmount.movePointLeft(decimals), + fromProviderAmount = fromAmountProvider.movePointLeft(decimals), + ) + } +} \ No newline at end of file diff --git a/data/express/src/main/java/com/tangem/data/express/converter/ExpressProviderConverter.kt b/data/express/src/main/java/com/tangem/data/express/converter/ExpressProviderConverter.kt new file mode 100644 index 0000000000..36c5e88460 --- /dev/null +++ b/data/express/src/main/java/com/tangem/data/express/converter/ExpressProviderConverter.kt @@ -0,0 +1,33 @@ +package com.tangem.data.express.converter + +import com.tangem.datasource.api.express.models.response.ExchangeProvider +import com.tangem.datasource.api.express.models.response.ExchangeProviderType +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.utils.converter.Converter + +internal class ExpressProviderConverter : Converter { + + override fun convert(value: ExchangeProvider): ExpressProvider { + return ExpressProvider( + providerId = value.id, + rateTypes = emptyList(), + name = value.name, + type = convertExchangeType(value.type), + imageLarge = value.imageLargeUrl, + termsOfUse = value.termsOfUse, + privacyPolicy = value.privacyPolicy, + isRecommended = value.isRecommended, + slippage = value.slippage, + ) + } + + private fun convertExchangeType(type: ExchangeProviderType): ExpressProviderType { + return when (type) { + ExchangeProviderType.DEX -> ExpressProviderType.DEX + ExchangeProviderType.CEX -> ExpressProviderType.CEX + ExchangeProviderType.DEX_BRIDGE -> ExpressProviderType.DEX_BRIDGE + ExchangeProviderType.ONRAMP -> ExpressProviderType.ONRAMP + } + } +} \ No newline at end of file diff --git a/data/express/src/main/java/com/tangem/data/express/di/ExpressDataModule.kt b/data/express/src/main/java/com/tangem/data/express/di/ExpressDataModule.kt new file mode 100644 index 0000000000..75bfe50bf5 --- /dev/null +++ b/data/express/src/main/java/com/tangem/data/express/di/ExpressDataModule.kt @@ -0,0 +1,43 @@ +package com.tangem.data.express.di + +import com.squareup.moshi.Moshi +import com.tangem.data.express.DefaultExpressRepository +import com.tangem.data.express.converter.ExpressErrorConverter +import com.tangem.data.express.error.DefaultExpressErrorResolver +import com.tangem.datasource.api.express.TangemExpressApi +import com.tangem.datasource.api.express.models.response.ExpressErrorResponse +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.express.ExpressErrorResolver +import com.tangem.domain.express.ExpressRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object ExpressDataModule { + + @Provides + @Singleton + fun provideExpressErrorResolver(@NetworkMoshi moshi: Moshi): ExpressErrorResolver { + val jsonAdapter = moshi.adapter(ExpressErrorResponse::class.java) + return DefaultExpressErrorResolver( + ExpressErrorConverter(jsonAdapter), + ) + } + + @Provides + @Singleton + fun provideExpressRepository( + tangemExpressApi: TangemExpressApi, + appPreferencesStore: AppPreferencesStore, + ): ExpressRepository { + return DefaultExpressRepository( + tangemExpressApi = tangemExpressApi, + appPreferencesStore = appPreferencesStore, + ) + } +} \ No newline at end of file diff --git a/data/express/src/main/java/com/tangem/data/express/error/DefaultExpressErrorResolver.kt b/data/express/src/main/java/com/tangem/data/express/error/DefaultExpressErrorResolver.kt new file mode 100644 index 0000000000..96ca3d07a8 --- /dev/null +++ b/data/express/src/main/java/com/tangem/data/express/error/DefaultExpressErrorResolver.kt @@ -0,0 +1,20 @@ +package com.tangem.data.express.error + +import com.tangem.data.express.converter.ExpressErrorConverter +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.domain.express.ExpressErrorResolver +import com.tangem.domain.express.models.ExpressError + +internal class DefaultExpressErrorResolver( + private val expressErrorConverter: ExpressErrorConverter, +) : ExpressErrorResolver { + + override fun resolve(throwable: Throwable): ExpressError { + return when (throwable) { + is ApiResponseError.HttpException -> { + expressErrorConverter.convert(throwable.errorBody.orEmpty()) + } + else -> ExpressError.UnknownError + } + } +} \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt index b426db7892..9064c8fb08 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt @@ -57,6 +57,8 @@ internal class DefaultCustomTokensRepository( -> true Blockchain.Cardano, Blockchain.Sui, + Blockchain.Stellar, + Blockchain.XRP, -> blockchain.validateContractAddress(contractAddress) else -> blockchain.validateAddress(contractAddress) } 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 cba7c2bf69..f6d9ebbfbd 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,5 +1,6 @@ package com.tangem.data.markets +import arrow.core.getOrElse import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.compatibility.applyL2Compatibility import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network @@ -9,6 +10,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.common.quote.QuotesFetcher import com.tangem.data.common.utils.retryOnError import com.tangem.data.markets.analytics.MarketsDataAnalyticsEvent import com.tangem.data.markets.converters.* @@ -16,8 +18,6 @@ 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.TokenMarketExchangesResponse -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.TangemTechApi.Companion.marketsQuoteFields import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.markets.* @@ -37,7 +37,7 @@ import java.util.concurrent.atomic.AtomicLong @Suppress("LongParameterList") internal class DefaultMarketsTokenRepository( private val marketsApi: TangemTechMarketsApi, - private val tangemTechApi: TangemTechApi, + private val quotesFetcher: QuotesFetcher, private val userWalletsStore: UserWalletsStore, private val dispatcherProvider: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, @@ -112,7 +112,7 @@ internal class DefaultMarketsTokenRepository( nextBatchSize: Int, ): BatchFlow, TokenMarketUpdateRequest> { val tokenMarketsUpdateFetcher = MarketsBatchUpdateFetcher( - tangemTechApi = tangemTechApi, + quotesFetcher = quotesFetcher, marketsApi = marketsApi, analyticsEventHandler = analyticsEventHandler, onApiResponseError = { @@ -225,16 +225,24 @@ internal class DefaultMarketsTokenRepository( withContext(dispatcherProvider.io) { // for second markets iteration we should use extended api method with all required fields - val result = catchDetailsErrorAndSendEvent( - request = MarketsDataAnalyticsEvent.Details.Error.Request.Info, - tokenSymbol = tokenSymbol, - ) { - tangemTechApi.getQuotes( - currencyId = fiatCurrencyCode, - coinIds = tokenId.value, - fields = marketsQuoteFields.joinToString(separator = ","), - ).getOrThrow() - } + val result = quotesFetcher.fetch( + fiatCurrencyId = fiatCurrencyCode, + currencyId = tokenId.value, + field = QuotesFetcher.Field.ALL_PRICES, + ) + .getOrElse { + val error = it as QuotesFetcher.Error.ApiOperationError + + val errorEvent = createDetailsErrorEvent( + error = error.apiError, + request = MarketsDataAnalyticsEvent.Details.Error.Request.Info, + tokenSymbol = tokenSymbol, + ) + + analyticsEventHandler.send(errorEvent.toEvent()) + + throw error.apiError + } return@withContext TokenQuotesShortConverter.convert(tokenId, result).toFull() } 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 3b5e662379..82ab261728 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 @@ -1,6 +1,8 @@ package com.tangem.data.markets +import arrow.core.getOrElse import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.data.common.quote.QuotesFetcher import com.tangem.data.common.utils.retryOnError import com.tangem.data.markets.analytics.MarketsDataAnalyticsEvent import com.tangem.data.markets.converters.TokenMarketChartsConverter @@ -11,8 +13,6 @@ import com.tangem.datasource.api.common.response.catchApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.markets.TangemTechMarketsApi import com.tangem.datasource.api.markets.models.response.TokenMarketChartListResponse -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.TangemTechApi.Companion.marketsQuoteFields import com.tangem.domain.markets.TokenMarket import com.tangem.domain.markets.TokenMarketUpdateRequest import com.tangem.pagination.Batch @@ -24,7 +24,7 @@ import kotlinx.coroutines.launch internal class MarketsBatchUpdateFetcher( private val marketsApi: TangemTechMarketsApi, - private val tangemTechApi: TangemTechApi, + private val quotesFetcher: QuotesFetcher, private val analyticsEventHandler: AnalyticsEventHandler, private val onApiResponseError: (ApiResponseError) -> Unit, ) : BatchUpdateFetcher, TokenMarketUpdateRequest> { @@ -72,13 +72,24 @@ internal class MarketsBatchUpdateFetcher( } is TokenMarketUpdateRequest.UpdateQuotes -> { val quotesRes = retryOnError { - catchApiResponseError(onApiResponseError) { - tangemTechApi.getQuotes( - currencyId = updateRequest.currencyId, - coinIds = idsToUpdate.map { it.second }.flatten().joinToString(separator = ","), - fields = marketsQuoteFields.joinToString(separator = ","), - ).getOrThrow() - } + val currenciesIds = idsToUpdate.flatMapTo(hashSetOf()) { it.second.map { rawID -> rawID.value } } + + quotesFetcher.fetch( + fiatCurrencyId = updateRequest.currencyId, + currenciesIds = currenciesIds, + fields = setOf(QuotesFetcher.Field.ALL_PRICES), + ) + .getOrElse { + val exception = if (it is QuotesFetcher.Error.ApiOperationError) { + onApiResponseError(it.apiError) + + it.apiError + } else { + error("Cause: $it") + } + + throw exception + } } update { diff --git a/data/markets/src/main/java/com/tangem/data/markets/di/MarketsDataModule.kt b/data/markets/src/main/java/com/tangem/data/markets/di/MarketsDataModule.kt index 1c1830b9cc..da2ddeb1b7 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/di/MarketsDataModule.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/di/MarketsDataModule.kt @@ -4,9 +4,9 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.common.quote.QuotesFetcher import com.tangem.data.markets.DefaultMarketsTokenRepository import com.tangem.datasource.api.markets.TangemTechMarketsApi -import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.markets.repositories.MarketsTokenRepository @@ -25,7 +25,7 @@ internal object MarketsDataModule { @Singleton fun provideMarketsTokenRepository( marketsApi: TangemTechMarketsApi, - tangemTechApi: TangemTechApi, + quotesFetcher: QuotesFetcher, userWalletsStore: UserWalletsStore, dispatchers: CoroutineDispatcherProvider, analyticsEventHandler: AnalyticsEventHandler, @@ -35,7 +35,7 @@ internal object MarketsDataModule { ): MarketsTokenRepository { return DefaultMarketsTokenRepository( marketsApi = marketsApi, - tangemTechApi = tangemTechApi, + quotesFetcher = quotesFetcher, dispatcherProvider = dispatchers, userWalletsStore = userWalletsStore, analyticsEventHandler = analyticsEventHandler, diff --git a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt index 9bb7985754..7f412108e3 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt @@ -80,7 +80,7 @@ internal class DefaultNetworksStatusesStore( ifNotFound: (Network.ID) -> SimpleNetworkStatus?, ) { if (networks.isEmpty()) { - Timber.d("Nothing to update: networks is empty") + Timber.d("Nothing to update: networks are empty") return } diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt b/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt index a0cadbe035..2acf7a68bf 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt @@ -9,9 +9,9 @@ import com.tangem.datasource.api.tangemTech.models.HotCryptoResponse import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.onramp.model.HotCryptoCurrency -import com.tangem.domain.tokens.model.Quote import com.tangem.utils.converter.Converter import java.math.BigDecimal @@ -40,7 +40,7 @@ internal class HotCryptoCurrencyConverter( return HotCryptoCurrency( cryptoCurrency = currency.setupIconUrl(id = rawId.value), - quote = createQuote( + quoteStatus = createQuote( fiatRate = value.currentPrice, priceChange = value.priceChangePercentage, rawCurrencyId = rawId, @@ -89,16 +89,18 @@ internal class HotCryptoCurrencyConverter( fiatRate: BigDecimal?, priceChange: BigDecimal?, rawCurrencyId: CryptoCurrency.RawID, - ): Quote { + ): QuoteStatus { return if (fiatRate != null && priceChange != null) { - Quote.Value( + QuoteStatus( rawCurrencyId = rawCurrencyId, - fiatRate = fiatRate, - priceChange = priceChange.movePointLeft(2), - source = StatusSource.ACTUAL, // It doesn't matter + value = QuoteStatus.Data( + fiatRate = fiatRate, + priceChange = priceChange.movePointLeft(2), + source = StatusSource.ACTUAL, // It doesn't matter + ), ) } else { - Quote.Empty(rawCurrencyId) + QuoteStatus(rawCurrencyId = rawCurrencyId) } } diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/converters/error/OnrampErrorConverter.kt b/data/onramp/src/main/java/com/tangem/data/onramp/converters/error/OnrampErrorConverter.kt index 895da30c18..1abd3b4d3d 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/converters/error/OnrampErrorConverter.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/converters/error/OnrampErrorConverter.kt @@ -6,6 +6,7 @@ import com.tangem.datasource.api.express.models.response.ExpressErrorResponse import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.utils.converter.Converter +@Deprecated("Use ExpressErrorConverter") internal class OnrampErrorConverter( private val jsonAdapter: JsonAdapter, ) : Converter { diff --git a/data/quotes/build.gradle.kts b/data/quotes/build.gradle.kts index 368d9b3bef..2549fee133 100644 --- a/data/quotes/build.gradle.kts +++ b/data/quotes/build.gradle.kts @@ -9,29 +9,50 @@ android { namespace = "com.tangem.data.quotes" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { + // region Project - Core implementation(projects.core.datasource) - implementation(projects.core.utils) + api(projects.core.utils) + // endregion + // region Project - Data implementation(projects.data.common) - implementation(projects.data.tokens) + // endregion - implementation(projects.domain.appCurrency.models) - implementation(projects.domain.models) - implementation(projects.domain.tokens.models) - implementation(projects.domain.quotes) - implementation(projects.domain.wallets.models) + // region Project - Domain + api(projects.domain.models) + api(projects.domain.quotes) + // endregion + // region Project - Libs + implementation(projects.libs.blockchainSdk) + // endregion + + // region DI + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + // endregion + + // region Tangem SDKs + implementation(tangemDeps.blockchain) + // endregion + + // region Other libraries implementation(deps.androidx.datastore) implementation(deps.moshi.kotlin) implementation(deps.timber) + // endregion - implementation(deps.hilt.android) - kapt(deps.hilt.kapt) - + // region Tests testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) testImplementation(deps.test.mockk) testImplementation(deps.test.truth) testImplementation(projects.common.test) + // endregion } \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/DefaultQuotesRepositoryV2.kt b/data/quotes/src/main/java/com/tangem/data/quotes/DefaultQuotesRepositoryV2.kt deleted file mode 100644 index 616b9cebc6..0000000000 --- a/data/quotes/src/main/java/com/tangem/data/quotes/DefaultQuotesRepositoryV2.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.data.quotes - -import com.tangem.data.quotes.store.QuotesStoreV2 -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.quotes.QuotesRepositoryV2 -import com.tangem.domain.tokens.model.Quote -import javax.inject.Inject - -/** - * Default implementation of [QuotesRepositoryV2] - * - * @property quotesStore quotes store - * -[REDACTED_AUTHOR] - */ -internal class DefaultQuotesRepositoryV2 @Inject constructor( - private val quotesStore: QuotesStoreV2, -) : QuotesRepositoryV2 { - - override suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set): Set? { - return quotesStore.getAllSyncOrNull()?.mapTo(hashSetOf()) { - it.takeIf { it.rawCurrencyId in currenciesIds } ?: Quote.Empty(it.rawCurrencyId) - } - } -} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/converter/QuoteStatusConverter.kt b/data/quotes/src/main/java/com/tangem/data/quotes/converter/QuoteStatusConverter.kt new file mode 100644 index 0000000000..6fe470b39a --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/converter/QuoteStatusConverter.kt @@ -0,0 +1,33 @@ +package com.tangem.data.quotes.converter + +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero + +/** + * Converter from [QuotesResponse.Quote] to [QuoteStatus] + * + * @property source status source + * +[REDACTED_AUTHOR] + */ +internal class QuoteStatusConverter( + private val source: StatusSource, +) : Converter, QuoteStatus> { + + override fun convert(value: Map.Entry): QuoteStatus { + val (currencyId, quote) = value + + return QuoteStatus( + rawCurrencyId = CryptoCurrency.RawID(currencyId), + value = QuoteStatus.Data( + source = source, + fiatRate = quote.price.orZero(), + priceChange = quote.priceChange24h.orZero().movePointLeft(2), + ), + ) + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteFetcherModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteFetcherModule.kt deleted file mode 100644 index 1bdef12d69..0000000000 --- a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteFetcherModule.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.tangem.data.quotes.di - -import com.tangem.data.quotes.multi.DefaultMultiQuoteFetcher -import com.tangem.data.quotes.multi.DefaultMultiQuoteUpdater -import com.tangem.data.quotes.single.DefaultSingleQuoteFetcher -import com.tangem.domain.quotes.multi.MultiQuoteFetcher -import com.tangem.domain.quotes.multi.MultiQuoteUpdater -import com.tangem.domain.quotes.single.SingleQuoteFetcher -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 QuoteFetcherModule { - - @Binds - @Singleton - fun bindMultiQuoteFetcher(impl: DefaultMultiQuoteFetcher): MultiQuoteFetcher - - @Binds - @Singleton - fun bindMultiQuoteUpdater(impl: DefaultMultiQuoteUpdater): MultiQuoteUpdater - - @Binds - @Singleton - fun bindSingleQuoteFetcher(impl: DefaultSingleQuoteFetcher): SingleQuoteFetcher -} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteProducerFactoryModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteProducerFactoryModule.kt deleted file mode 100644 index 0fc3d0faed..0000000000 --- a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteProducerFactoryModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.data.quotes.di - -import com.tangem.data.quotes.single.DefaultSingleQuoteProducer -import com.tangem.domain.quotes.single.SingleQuoteProducer -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 QuoteProducerFactoryModule { - - @Binds - @Singleton - fun bindSingleQuoteProducerFactory(impl: DefaultSingleQuoteProducer.Factory): SingleQuoteProducer.Factory -} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteStatusFetcherModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteStatusFetcherModule.kt new file mode 100644 index 0000000000..8f425800fe --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteStatusFetcherModule.kt @@ -0,0 +1,24 @@ +package com.tangem.data.quotes.di + +import com.tangem.data.quotes.multi.DefaultMultiQuoteStatusFetcher +import com.tangem.data.quotes.single.DefaultSingleQuoteStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher +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 QuoteStatusFetcherModule { + + @Binds + @Singleton + fun bindMultiQuoteStatusFetcher(impl: DefaultMultiQuoteStatusFetcher): MultiQuoteStatusFetcher + + @Binds + @Singleton + fun bindSingleQuoteStatusFetcher(impl: DefaultSingleQuoteStatusFetcher): SingleQuoteStatusFetcher +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteStatusProducerFactoryModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteStatusProducerFactoryModule.kt new file mode 100644 index 0000000000..a6b653ba03 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteStatusProducerFactoryModule.kt @@ -0,0 +1,20 @@ +package com.tangem.data.quotes.di + +import com.tangem.data.quotes.single.DefaultSingleQuoteStatusProducer +import com.tangem.domain.quotes.single.SingleQuoteStatusProducer +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 QuoteStatusProducerFactoryModule { + + @Binds + @Singleton + fun bindSingleQuoteStatusProducerFactory( + impl: DefaultSingleQuoteStatusProducer.Factory, + ): SingleQuoteStatusProducer.Factory +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteSupplierModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteStatusSupplierModule.kt similarity index 53% rename from data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteSupplierModule.kt rename to data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteStatusSupplierModule.kt index c31b91a7c7..135f3b3460 100644 --- a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteSupplierModule.kt +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteStatusSupplierModule.kt @@ -1,7 +1,7 @@ package com.tangem.data.quotes.di -import com.tangem.domain.quotes.single.SingleQuoteProducer -import com.tangem.domain.quotes.single.SingleQuoteSupplier +import com.tangem.domain.quotes.single.SingleQuoteStatusProducer +import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -10,12 +10,12 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal object QuoteSupplierModule { +internal object QuoteStatusSupplierModule { @Provides @Singleton - fun provideSingleQuoteSupplier(factory: SingleQuoteProducer.Factory): SingleQuoteSupplier { - return object : SingleQuoteSupplier( + fun provideSingleQuoteStatusSupplier(factory: SingleQuoteStatusProducer.Factory): SingleQuoteStatusSupplier { + return object : SingleQuoteStatusSupplier( factory = factory, keyCreator = { "single_quote_${it.rawCurrencyId.value}" }, ) {} diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt index 58803a1a56..0cf87628bb 100644 --- a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt @@ -4,15 +4,19 @@ import android.content.Context import androidx.datastore.core.DataStoreFactory import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi -import com.tangem.data.quotes.DefaultQuotesRepositoryV2 -import com.tangem.data.quotes.store.DefaultQuotesStoreV2 -import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.data.quotes.multi.DefaultMultiQuoteUpdater +import com.tangem.data.quotes.repository.DefaultQuotesRepository +import com.tangem.data.quotes.store.DefaultQuotesStatusesStore +import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.mapWithStringKeyTypes -import com.tangem.domain.quotes.QuotesRepositoryV2 +import com.tangem.domain.quotes.QuotesRepository +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteUpdater import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -33,8 +37,8 @@ internal object QuotesDataModule { @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, - ): QuotesStoreV2 { - return DefaultQuotesStoreV2( + ): QuotesStatusesStore { + return DefaultQuotesStatusesStore( runtimeStore = RuntimeSharedStore(), persistenceDataStore = DataStoreFactory.create( serializer = MoshiDataStoreSerializer( @@ -51,5 +55,23 @@ internal object QuotesDataModule { @Singleton @Provides - fun providesQuotesRepositoryV2(impl: DefaultQuotesRepositoryV2): QuotesRepositoryV2 = impl + fun providesQuotesRepository(quotesStatusesStore: QuotesStatusesStore): QuotesRepository { + return DefaultQuotesRepository(quotesStatusesStore = quotesStatusesStore) + } + + @Singleton + @Provides + fun bindMultiQuoteUpdater( + appCurrencyResponseStore: AppCurrencyResponseStore, + quotesStatusesStore: QuotesStatusesStore, + multiQuoteStatusFetcher: MultiQuoteStatusFetcher, + dispatchers: CoroutineDispatcherProvider, + ): MultiQuoteUpdater { + return DefaultMultiQuoteUpdater( + appCurrencyResponseStore = appCurrencyResponseStore, + quotesStatusesStore = quotesStatusesStore, + multiQuoteStatusFetcher = multiQuoteStatusFetcher, + dispatchers = dispatchers, + ) + } } \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcher.kt b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcher.kt deleted file mode 100644 index cfa6b766f0..0000000000 --- a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcher.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.tangem.data.quotes.multi - -import arrow.core.Either -import com.tangem.data.common.api.safeApiCallWithTimeout -import com.tangem.data.quotes.store.QuotesStoreV2 -import com.tangem.data.tokens.utils.QuotesUnsupportedCurrenciesIdAdapter -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.appcurrency.AppCurrencyResponseStore -import com.tangem.domain.core.utils.catchOn -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.quotes.multi.MultiQuoteFetcher -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.withContext -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Default implementation of [MultiQuoteFetcher] - * - * @property tangemTechApi tangemTech api - * @property appCurrencyResponseStore app currency response store - * @property quotesStore quotes store - * @property dispatchers dispatchers - * -[REDACTED_AUTHOR] - */ -@Singleton -internal class DefaultMultiQuoteFetcher @Inject constructor( - private val tangemTechApi: TangemTechApi, - private val appCurrencyResponseStore: AppCurrencyResponseStore, - private val quotesStore: QuotesStoreV2, - private val dispatchers: CoroutineDispatcherProvider, -) : MultiQuoteFetcher { - - private val quotesUnsupportedCurrenciesAdapter = QuotesUnsupportedCurrenciesIdAdapter() - - override suspend fun invoke(params: MultiQuoteFetcher.Params) = Either.catchOn(dispatchers.default) { - if (params.currenciesIds.isEmpty()) { - Timber.d("No currencies to fetch quotes for") - return@catchOn - } - - quotesStore.refresh(currenciesIds = params.currenciesIds) - - val replacementIdsResult = quotesUnsupportedCurrenciesAdapter.replaceUnsupportedCurrencies( - currenciesIds = params.currenciesIds.mapTo( - destination = hashSetOf(), - transform = CryptoCurrency.RawID::value, - ), - ) - - val appCurrencyId = getAppCurrencyId(params = params) - val coinIds = replacementIdsResult.idsForRequest.joinToString(separator = ",") - - val response = safeApiCallWithTimeout( - call = { - withContext(dispatchers.io) { - tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds).bind() - } - }, - onError = { error -> throw error }, - ) - - val updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies( - response = response, - filteredIds = replacementIdsResult.idsFiltered, - ) - - quotesStore.storeActual(values = updatedResponse.quotes) - } - .onLeft { - Timber.e(it) - quotesStore.storeError(currenciesIds = params.currenciesIds) - } - - private suspend fun getAppCurrencyId(params: MultiQuoteFetcher.Params): String { - val appCurrencyId = params.appCurrencyId - ?: appCurrencyResponseStore.getSyncOrNull()?.id - - if (appCurrencyId.isNullOrBlank()) { - val exception = IllegalStateException("Unable to get AppCurrency for updating quotes") - Timber.e(exception) - - throw exception - } - - return appCurrencyId - } -} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcher.kt b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcher.kt new file mode 100644 index 0000000000..618f75178d --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcher.kt @@ -0,0 +1,87 @@ +package com.tangem.data.quotes.multi + +import arrow.core.Either +import arrow.core.getOrElse +import com.tangem.data.common.quote.QuotesFetcher +import com.tangem.data.common.quote.QuotesFetcher.Field +import com.tangem.data.quotes.store.QuotesStatusesStore +import com.tangem.data.quotes.store.setSourceAsCache +import com.tangem.data.quotes.store.setSourceAsOnlyCache +import com.tangem.data.quotes.utils.QuotesUnsupportedCurrenciesIdAdapter +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.domain.core.utils.catchOn +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Default implementation of [MultiQuoteStatusFetcher] + * + * @property quotesFetcher quotes fetcher + * @property appCurrencyResponseStore app currency response store + * @property quotesStatusesStore quotes statuses store + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +@Singleton +internal class DefaultMultiQuoteStatusFetcher @Inject constructor( + private val quotesFetcher: QuotesFetcher, + private val appCurrencyResponseStore: AppCurrencyResponseStore, + private val quotesStatusesStore: QuotesStatusesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : MultiQuoteStatusFetcher { + + override suspend fun invoke(params: MultiQuoteStatusFetcher.Params) = Either.catchOn(dispatchers.default) { + if (params.currenciesIds.isEmpty()) { + Timber.d("No currencies to fetch quotes for") + return@catchOn + } + + quotesStatusesStore.setSourceAsCache(currenciesIds = params.currenciesIds) + + val replacementIdsResult = QuotesUnsupportedCurrenciesIdAdapter.replaceUnsupportedCurrencies( + currenciesIds = params.currenciesIds.mapTo( + destination = hashSetOf(), + transform = CryptoCurrency.RawID::value, + ), + ) + + val appCurrencyId = getAppCurrencyId(params = params) + + val response = quotesFetcher.fetch( + fiatCurrencyId = appCurrencyId, + currenciesIds = replacementIdsResult.idsForRequest, + fields = setOf(Field.PRICE, Field.PRICE_CHANGE_24H), + ) + .getOrElse { error("Cause: $it") } + + val updatedResponse = QuotesUnsupportedCurrenciesIdAdapter.getResponseWithUnsupportedCurrencies( + response = response, + filteredIds = replacementIdsResult.idsFiltered, + ) + + quotesStatusesStore.store(values = updatedResponse.quotes) + } + .onLeft { + Timber.e(it) + quotesStatusesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds) + } + + private suspend fun getAppCurrencyId(params: MultiQuoteStatusFetcher.Params): String { + val appCurrencyId = params.appCurrencyId + ?: appCurrencyResponseStore.getSyncOrNull()?.id + + if (appCurrencyId.isNullOrBlank()) { + val exception = IllegalStateException("Unable to get AppCurrency for updating quotes") + Timber.e(exception) + + throw exception + } + + return appCurrencyId + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt index 06e492857e..73e2446946 100644 --- a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt +++ b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt @@ -2,12 +2,12 @@ package com.tangem.data.quotes.multi import androidx.annotation.VisibleForTesting import arrow.core.left -import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.datasource.appcurrency.AppCurrencyResponseStore import com.tangem.domain.core.utils.EitherFlow -import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.quotes.multi.MultiQuoteUpdater -import com.tangem.domain.tokens.model.Quote import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -17,24 +17,21 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton /** * Default implementation of [MultiQuoteUpdater] which updates quotes when the app currency changes * * @property appCurrencyResponseStore app currency response store - * @property quotesStore quotes store - * @property multiQuoteFetcher multi quote fetcher + * @property quotesStatusesStore quotes store + * @property multiQuoteStatusFetcher multi quote status fetcher * @param dispatchers dispatchers * [REDACTED_AUTHOR] */ -@Singleton -internal class DefaultMultiQuoteUpdater @Inject constructor( +internal class DefaultMultiQuoteUpdater( private val appCurrencyResponseStore: AppCurrencyResponseStore, - private val quotesStore: QuotesStoreV2, - private val multiQuoteFetcher: MultiQuoteFetcher, + private val quotesStatusesStore: QuotesStatusesStore, + private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, dispatchers: CoroutineDispatcherProvider, ) : MultiQuoteUpdater { @@ -60,11 +57,14 @@ internal class DefaultMultiQuoteUpdater @Inject constructor( .distinctUntilChanged() .filterNotNull() .mapLatest { appCurrency -> - val currenciesIds = quotesStore.getAllSyncOrNull().orEmpty() - .mapTo(destination = hashSetOf(), transform = Quote::rawCurrencyId) + val currenciesIds = quotesStatusesStore.getAllSyncOrNull().orEmpty() + .mapTo(destination = hashSetOf(), transform = QuoteStatus::rawCurrencyId) - multiQuoteFetcher( - params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrency.id), + multiQuoteStatusFetcher( + params = MultiQuoteStatusFetcher.Params( + currenciesIds = currenciesIds, + appCurrencyId = appCurrency.id, + ), ) .onLeft(Timber::e) } diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/repository/DefaultQuotesRepository.kt b/data/quotes/src/main/java/com/tangem/data/quotes/repository/DefaultQuotesRepository.kt new file mode 100644 index 0000000000..d170b82c5c --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/repository/DefaultQuotesRepository.kt @@ -0,0 +1,34 @@ +package com.tangem.data.quotes.repository + +import com.tangem.data.quotes.store.QuotesStatusesStore +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.quotes.QuotesRepository +import timber.log.Timber + +/** + * Default implementation of [QuotesRepository] + * + * @property quotesStatusesStore quotes statuses store + * +[REDACTED_AUTHOR] + */ +internal class DefaultQuotesRepository( + private val quotesStatusesStore: QuotesStatusesStore, +) : QuotesRepository { + + override suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set): Set { + if (currenciesIds.isEmpty()) { + Timber.e("currenciesIds are empty") + return emptySet() + } + + val storedQuotes = quotesStatusesStore.getAllSyncOrNull() + ?.filter { it.rawCurrencyId in currenciesIds } + + return currenciesIds.mapTo(hashSetOf()) { currencyId -> + storedQuotes?.firstOrNull { it.rawCurrencyId == currencyId } + ?: QuoteStatus(rawCurrencyId = currencyId) + } + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteFetcher.kt b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteFetcher.kt deleted file mode 100644 index 9cd56dacc2..0000000000 --- a/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteFetcher.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.data.quotes.single - -import com.tangem.domain.quotes.multi.MultiQuoteFetcher -import com.tangem.domain.quotes.single.SingleQuoteFetcher -import javax.inject.Inject - -internal class DefaultSingleQuoteFetcher @Inject constructor( - private val multiQuoteFetcher: MultiQuoteFetcher, -) : SingleQuoteFetcher { - - override suspend fun invoke(params: SingleQuoteFetcher.Params) = multiQuoteFetcher.invoke( - MultiQuoteFetcher.Params( - currenciesIds = setOf(params.rawCurrencyId), - appCurrencyId = params.appCurrencyId, - ), - ) -} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducer.kt b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducer.kt deleted file mode 100644 index d8450243b6..0000000000 --- a/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducer.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.tangem.data.quotes.single - -import com.tangem.data.quotes.store.QuotesStoreV2 -import com.tangem.domain.quotes.single.SingleQuoteProducer -import com.tangem.domain.tokens.model.Quote -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.mapNotNull - -/** - * Default implementation of [SingleQuoteProducer] - * - * @property params params - * @property quotesStore quotes store - */ -internal class DefaultSingleQuoteProducer @AssistedInject constructor( - @Assisted val params: SingleQuoteProducer.Params, - private val quotesStore: QuotesStoreV2, - private val dispatchers: CoroutineDispatcherProvider, -) : SingleQuoteProducer { - - override val fallback: Quote = Quote.Empty(rawCurrencyId = params.rawCurrencyId) - - override fun produce(): Flow { - return quotesStore.get() - .mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } } - .distinctUntilChanged() - .flowOn(dispatchers.default) - } - - @AssistedFactory - interface Factory : SingleQuoteProducer.Factory { - override fun create(params: SingleQuoteProducer.Params): DefaultSingleQuoteProducer - } -} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusFetcher.kt b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusFetcher.kt new file mode 100644 index 0000000000..44dc7157d8 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusFetcher.kt @@ -0,0 +1,22 @@ +package com.tangem.data.quotes.single + +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher +import javax.inject.Inject + +/** + * Default implementation of [SingleQuoteStatusFetcher] + * + * @property multiQuoteStatusFetcher fetcher of quotes statuses + */ +internal class DefaultSingleQuoteStatusFetcher @Inject constructor( + private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, +) : SingleQuoteStatusFetcher { + + override suspend fun invoke(params: SingleQuoteStatusFetcher.Params) = multiQuoteStatusFetcher.invoke( + MultiQuoteStatusFetcher.Params( + currenciesIds = setOf(params.rawCurrencyId), + appCurrencyId = params.appCurrencyId, + ), + ) +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducer.kt b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducer.kt new file mode 100644 index 0000000000..692d5cb24a --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducer.kt @@ -0,0 +1,40 @@ +package com.tangem.data.quotes.single + +import com.tangem.data.quotes.store.QuotesStatusesStore +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.quotes.single.SingleQuoteStatusProducer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull + +/** + * Default implementation of [SingleQuoteStatusProducer] + * + * @property params params + * @property quotesStatusesStore quotes store + */ +internal class DefaultSingleQuoteStatusProducer @AssistedInject constructor( + @Assisted val params: SingleQuoteStatusProducer.Params, + private val quotesStatusesStore: QuotesStatusesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleQuoteStatusProducer { + + override val fallback: QuoteStatus = QuoteStatus(rawCurrencyId = params.rawCurrencyId) + + override fun produce(): Flow { + return quotesStatusesStore.get() + .mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } } + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : SingleQuoteStatusProducer.Factory { + override fun create(params: SingleQuoteStatusProducer.Params): DefaultSingleQuoteStatusProducer + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStoreV2.kt b/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStatusesStore.kt similarity index 54% rename from data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStoreV2.kt rename to data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStatusesStore.kt index ccad728a92..52b464efa0 100644 --- a/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStoreV2.kt +++ b/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStatusesStore.kt @@ -1,12 +1,12 @@ package com.tangem.data.quotes.store import androidx.datastore.core.DataStore +import com.tangem.data.quotes.converter.QuoteStatusConverter import com.tangem.datasource.api.tangemTech.models.QuotesResponse import com.tangem.datasource.local.datastore.RuntimeSharedStore -import com.tangem.datasource.local.quote.converter.QuoteConverter import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.models.quote.QuoteStatus import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.addOrReplace import kotlinx.coroutines.CoroutineScope @@ -15,21 +15,22 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch +import timber.log.Timber internal typealias CurrencyIdWithQuote = Map /** - * Default implementation of [QuotesStoreV2] + * Default implementation of [QuotesStatusesStore] * * @property runtimeStore runtime store * @property persistenceDataStore persistence store * @param dispatchers dispatchers */ -internal class DefaultQuotesStoreV2( - private val runtimeStore: RuntimeSharedStore>, +internal class DefaultQuotesStatusesStore( + private val runtimeStore: RuntimeSharedStore>, private val persistenceDataStore: DataStore, dispatchers: CoroutineDispatcherProvider, -) : QuotesStoreV2 { +) : QuotesStatusesStore { private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) @@ -40,62 +41,68 @@ internal class DefaultQuotesStoreV2( if (cachedStatuses.isNullOrEmpty()) return@launch runtimeStore.store( - value = QuoteConverter(isCached = true).convertSet(input = cachedStatuses.entries), + value = QuoteStatusConverter(source = StatusSource.CACHE).convertSet(input = cachedStatuses.entries), ) } } - override fun get(): Flow> = runtimeStore.get() + override fun get(): Flow> = runtimeStore.get() - override suspend fun getAllSyncOrNull(): Set? = runtimeStore.getSyncOrNull() + override suspend fun getAllSyncOrNull(): Set? = runtimeStore.getSyncOrNull() - override suspend fun refresh(currenciesIds: Set) { - updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.CACHE) - } - - override suspend fun storeActual(values: Map) { - coroutineScope { - launch { - val quotes = QuoteConverter(isCached = false).convertSet(input = values.entries) - storeInRuntimeStore(values = quotes) - } - launch { storeInPersistenceStore(values = values) } - } - } - - override suspend fun storeError(currenciesIds: Set) { - updateStatusSourceInRuntime( - currenciesIds = currenciesIds, - ifNotFound = Quote::Empty, - source = StatusSource.ONLY_CACHE, + override suspend fun updateStatusSource( + currencyId: CryptoCurrency.RawID, + source: StatusSource, + ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus?, + ) { + updateStatusSource( + currenciesIds = setOf(currencyId), + source = source, + ifNotFound = ifNotFound, ) } - private suspend fun updateStatusSourceInRuntime( + override suspend fun updateStatusSource( currenciesIds: Set, - ifNotFound: (CryptoCurrency.RawID) -> Quote? = { null }, source: StatusSource, + ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus?, ) { + if (currenciesIds.isEmpty()) { + Timber.d("Nothing to update: currencies ids are empty") + return + } + runtimeStore.update(default = emptySet()) { stored -> val updatedQuotes = currenciesIds.mapNotNullTo(hashSetOf()) { id -> val quote = stored.firstOrNull { it.rawCurrencyId == id } ?: ifNotFound(id) ?: return@mapNotNullTo null - quote.copySealed(source = source) + quote.copy(value = quote.value.copySealed(source = source)) } stored.addOrReplace(items = updatedQuotes) { old, new -> old.rawCurrencyId == new.rawCurrencyId } } } - private suspend fun storeInRuntimeStore(values: Set) { - runtimeStore.update(default = emptySet()) { saved -> - saved.addOrReplace(items = values) { prev, new -> prev.rawCurrencyId == new.rawCurrencyId } + override suspend fun store(values: CurrencyIdWithQuote) { + if (values.isEmpty()) return + + coroutineScope { + launch { storeInRuntime(values = values) } + launch { storeInPersistence(values = values) } } } - private suspend fun storeInPersistenceStore(values: Map) { + private suspend fun storeInRuntime(values: CurrencyIdWithQuote) { + val quotes = QuoteStatusConverter(source = StatusSource.ACTUAL).convertSet(input = values.entries) + + runtimeStore.update(default = emptySet()) { saved -> + saved.addOrReplace(items = quotes) { prev, new -> prev.rawCurrencyId == new.rawCurrencyId } + } + } + + private suspend fun storeInPersistence(values: CurrencyIdWithQuote) { persistenceDataStore.updateData { storedQuotes -> storedQuotes + values } } } \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStatusesStore.kt b/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStatusesStore.kt new file mode 100644 index 0000000000..a9dfe1fac2 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStatusesStore.kt @@ -0,0 +1,46 @@ +package com.tangem.data.quotes.store + +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import kotlinx.coroutines.flow.Flow + +/** Store of [QuoteStatus]'es set */ +internal interface QuotesStatusesStore { + + /** Get flow of quotes */ + fun get(): Flow> + + /** Get all quotes synchronously or null */ + suspend fun getAllSyncOrNull(): Set? + + /** + * Update [source] of [QuoteStatus] by [currencyId]. + * If the status is not found, create a new one by [ifNotFound]. + */ + suspend fun updateStatusSource( + currencyId: CryptoCurrency.RawID, + source: StatusSource, + ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus? = { null }, + ) + + /** + * Update [source] of [QuoteStatus]es by [currenciesIds]. + * If the status is not found, create a new one by [ifNotFound]. + */ + suspend fun updateStatusSource( + currenciesIds: Set, + source: StatusSource, + ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus? = { null }, + ) + + /** + * Store quotes statuses + * + * @param values map of currency ids and quotes + * + * See complex methods in `QuotesStatusesStoreExt`. + */ + suspend fun store(values: Map) +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStatusesStoreExt.kt b/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStatusesStoreExt.kt new file mode 100644 index 0000000000..eb860d2de9 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStatusesStoreExt.kt @@ -0,0 +1,22 @@ +package com.tangem.data.quotes.store + +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus + +/** Set [StatusSource] of quotes statuses as [StatusSource.CACHE] for [currenciesIds] */ +internal suspend fun QuotesStatusesStore.setSourceAsCache(currenciesIds: Set) { + updateStatusSource(currenciesIds = currenciesIds, source = StatusSource.CACHE) +} + +/** + * Set [StatusSource] of quotes statuses as [StatusSource.ONLY_CACHE] for [currenciesIds]. + * If the stored status is not found, store a default [QuoteStatus.Empty] status. + */ +internal suspend fun QuotesStatusesStore.setSourceAsOnlyCache(currenciesIds: Set) { + updateStatusSource( + currenciesIds = currenciesIds, + source = StatusSource.ONLY_CACHE, + ifNotFound = ::QuoteStatus, + ) +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStoreV2.kt b/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStoreV2.kt deleted file mode 100644 index 1fb13a9d5e..0000000000 --- a/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStoreV2.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.data.quotes.store - -import com.tangem.datasource.api.tangemTech.models.QuotesResponse -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.model.Quote -import kotlinx.coroutines.flow.Flow - -/** Store of [Quote]'es set */ -internal interface QuotesStoreV2 { - - /** Get flow of quotes */ - fun get(): Flow> - - /** Get all quotes synchronously or null */ - suspend fun getAllSyncOrNull(): Set? - - /** Refresh status of [currenciesIds] */ - suspend fun refresh(currenciesIds: Set) - - /** Store actual map of currency ids and quotes [values] */ - suspend fun storeActual(values: Map) - - /** Store error for [currenciesIds] */ - suspend fun storeError(currenciesIds: Set) -} \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt b/data/quotes/src/main/java/com/tangem/data/quotes/utils/QuotesUnsupportedCurrenciesIdAdapter.kt similarity index 70% rename from data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt rename to data/quotes/src/main/java/com/tangem/data/quotes/utils/QuotesUnsupportedCurrenciesIdAdapter.kt index bf7779378a..72c9a1f66c 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt +++ b/data/quotes/src/main/java/com/tangem/data/quotes/utils/QuotesUnsupportedCurrenciesIdAdapter.kt @@ -1,23 +1,27 @@ -package com.tangem.data.tokens.utils +package com.tangem.data.quotes.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 -/** - * Adapter to replace unsupported currencies for quotes request if it necessary - */ -class QuotesUnsupportedCurrenciesIdAdapter { +/** Adapter to replace unsupported currencies for quotes request if it necessary */ +internal object QuotesUnsupportedCurrenciesIdAdapter { - /** - * Replaces unsupported currencies id to it replacements for request - */ + /** Ethereum coin ID */ + private val ethCoinId = Blockchain.Ethereum.toCoinId() + + /** Map that contains unsupported currencies and their replacement for request */ + private val UNSUPPORTED_IDS_WITH_REPLACEMENTS = l2BlockchainsCoinIds.associateWith { ethCoinId } + + /** Replaces unsupported currencies id [currenciesIds] to it replacements for request */ fun replaceUnsupportedCurrencies(currenciesIds: Set): ReplacementResult { val idsForRequest = mutableSetOf() val idsFiltered = mutableSetOf() + currenciesIds.forEach { currencyId -> val replacementId = UNSUPPORTED_IDS_WITH_REPLACEMENTS[currencyId] + if (replacementId != null) { idsFiltered.add(currencyId) idsForRequest.add(replacementId) @@ -25,16 +29,17 @@ class QuotesUnsupportedCurrenciesIdAdapter { idsForRequest.add(currencyId) } } + return ReplacementResult(idsForRequest = idsForRequest, idsFiltered = idsFiltered) } - /** - * Recover previously replaced currencies to response that uses in application - */ + /** Recover previously replaced currencies [filteredIds] to [response] that uses in application */ fun getResponseWithUnsupportedCurrencies(response: QuotesResponse, filteredIds: Set): QuotesResponse { val updatedQuotes = mutableMapOf() + filteredIds.forEach { filteredId -> val replacementId = UNSUPPORTED_IDS_WITH_REPLACEMENTS[filteredId] + if (replacementId != null) { val quoteReplacement = response.quotes[replacementId] if (quoteReplacement != null) { @@ -42,16 +47,9 @@ class QuotesUnsupportedCurrenciesIdAdapter { } } } + return response.copy(quotes = updatedQuotes + response.quotes) } data class ReplacementResult(val idsForRequest: Set, val idsFiltered: Set) - - companion object { - /** - * Map that contains unsupported currencies and their replacement for request - */ - private val ethCoinId = Blockchain.Ethereum.toCoinId() - private val UNSUPPORTED_IDS_WITH_REPLACEMENTS = l2BlockchainsCoinIds.associateWith { ethCoinId } - } } \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/converter/QuoteStatusConverterTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/converter/QuoteStatusConverterTest.kt new file mode 100644 index 0000000000..e6cb1e498c --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/converter/QuoteStatusConverterTest.kt @@ -0,0 +1,118 @@ +package com.tangem.data.quotes.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.data.quote.toDomain +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class QuoteStatusConverterTest { + + private val ethQuoteDM = "ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE) + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertTestModel) { + // Act + val actual = QuoteStatusConverter(source = model.source).convert(value = model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + ConvertTestModel( + source = StatusSource.CACHE, + value = createEntry(value = ethQuoteDM), + expected = ethQuoteDM.toDomain(source = StatusSource.CACHE), + ), + ConvertTestModel( + source = StatusSource.ONLY_CACHE, + value = createEntry(value = ethQuoteDM), + expected = ethQuoteDM.toDomain(source = StatusSource.ONLY_CACHE), + ), + ConvertTestModel( + source = StatusSource.ACTUAL, + value = createEntry(value = ethQuoteDM), + expected = ethQuoteDM.toDomain(source = StatusSource.ACTUAL), + ), + ConvertTestModel( + source = StatusSource.ACTUAL, + value = createEntry( + value = "ETH" to QuotesResponse.Quote( + price = null, + priceChange24h = null, + priceChange1w = null, + priceChange30d = null, + ), + ), + expected = QuoteStatus( + rawCurrencyId = CryptoCurrency.RawID("ETH"), + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ZERO, + priceChange = BigDecimal("0.00"), + ), + ), + ), + ConvertTestModel( + source = StatusSource.ACTUAL, + value = createEntry( + value = "ETH" to QuotesResponse.Quote( + price = null, + priceChange24h = null, + priceChange1w = BigDecimal.ZERO, + priceChange30d = BigDecimal.ZERO, + ), + ), + expected = QuoteStatus( + rawCurrencyId = CryptoCurrency.RawID("ETH"), + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ZERO, + priceChange = BigDecimal("0.00"), + ), + ), + ), + ConvertTestModel( + source = StatusSource.ACTUAL, + value = createEntry( + value = "ETH" to QuotesResponse.Quote( + price = BigDecimal.ONE, + priceChange24h = BigDecimal.ONE, + priceChange1w = null, + priceChange30d = null, + ), + ), + expected = QuoteStatus( + rawCurrencyId = CryptoCurrency.RawID("ETH"), + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal("0.01"), + ), + ), + ), + ) + + private fun createEntry(value: Pair): Map.Entry { + return mapOf(value).entries.first() + } + + data class ConvertTestModel( + val source: StatusSource, + val value: Map.Entry, + val expected: QuoteStatus, + ) +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcherTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcherTest.kt deleted file mode 100644 index 63fec8ab17..0000000000 --- a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcherTest.kt +++ /dev/null @@ -1,214 +0,0 @@ -package com.tangem.data.quotes.multi - -import com.google.common.truth.Truth -import com.tangem.common.test.data.quote.MockQuoteResponseFactory -import com.tangem.data.quotes.store.QuotesStoreV2 -import com.tangem.datasource.api.common.response.ApiResponse -import com.tangem.datasource.api.common.response.ApiResponseError -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse -import com.tangem.datasource.api.tangemTech.models.QuotesResponse -import com.tangem.datasource.appcurrency.AppCurrencyResponseStore -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.quotes.multi.MultiQuoteFetcher -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.coVerifyOrder -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.Test -import java.math.BigDecimal - -/** -[REDACTED_AUTHOR] - */ -internal class DefaultMultiQuoteFetcherTest { - - private val tangemTechApi = mockk(relaxed = true) - private val appCurrencyResponseStore = mockk(relaxed = true) - private val quotesStore = mockk(relaxed = true) - - private val fetcher = DefaultMultiQuoteFetcher( - tangemTechApi = tangemTechApi, - appCurrencyResponseStore = appCurrencyResponseStore, - quotesStore = quotesStore, - dispatchers = TestingCoroutineDispatcherProvider(), - ) - - @Test - fun `fetch quotes successfully`() = runTest { - val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) - - coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency - - val coinIds = "BTC,ETH" - coEvery { - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - } returns ApiResponse.Success(successResponse) - - val actual = fetcher(params) - - coVerifyOrder { - quotesStore.refresh(currenciesIds = params.currenciesIds) - - appCurrencyResponseStore.getSyncOrNull() - - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - - quotesStore.storeActual(values = successResponse.quotes) - } - - coVerify(inverse = true) { - quotesStore.storeError(currenciesIds = any()) - } - - Truth.assertThat(actual.isRight()).isTrue() - } - - @Test - fun `fetch quotes successfully if currenciesIds from params is empty`() = runTest { - val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = null) - - val actual = fetcher(params) - - coVerify(inverse = true) { - quotesStore.refresh(currenciesIds = any()) - appCurrencyResponseStore.getSyncOrNull() - tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) - quotesStore.storeActual(values = any()) - quotesStore.storeError(currenciesIds = any()) - } - - Truth.assertThat(actual.isRight()).isTrue() - } - - @Test - fun `fetch quotes successfully if appCurrencyId from params is not null`() = runTest { - val appCurrencyId = "usd" - val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId) - - val coinIds = "BTC,ETH" - coEvery { - tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds) - } returns ApiResponse.Success(successResponse) - - val actual = fetcher(params) - - coVerifyOrder { - quotesStore.refresh(currenciesIds = params.currenciesIds) - - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - - quotesStore.storeActual(values = successResponse.quotes) - } - - coVerify(inverse = true) { - appCurrencyResponseStore.getSyncOrNull() - quotesStore.storeError(currenciesIds = any()) - } - - Truth.assertThat(actual.isRight()).isTrue() - } - - @Test - fun `fetch quotes failure because appCurrencyId from params is blank`() = runTest { - val appCurrencyId = "" - val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId) - - val actual = fetcher(params) - - coVerifyOrder { - quotesStore.refresh(currenciesIds = params.currenciesIds) - quotesStore.storeError(currenciesIds = params.currenciesIds) - } - - coVerify(inverse = true) { - appCurrencyResponseStore.getSyncOrNull() - tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) - quotesStore.storeActual(values = any()) - } - - Truth.assertThat(actual.isLeft()).isTrue() - Truth.assertThat(actual.leftOrNull()).isInstanceOf(IllegalStateException::class.java) - Truth.assertThat(actual.leftOrNull()).hasMessageThat() - .isEqualTo("Unable to get AppCurrency for updating quotes") - } - - @Test - fun `fetch quotes failure because api request failed`() = runTest { - val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) - - coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency - - val coinIds = "BTC,ETH" - - @Suppress("UNCHECKED_CAST") - val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse - coEvery { tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) } returns errorResponse - - val actual = fetcher(params) - - coVerifyOrder { - quotesStore.refresh(currenciesIds = params.currenciesIds) - - appCurrencyResponseStore.getSyncOrNull() - - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - - quotesStore.storeError(currenciesIds = params.currenciesIds) - } - - coVerify(inverse = true) { - quotesStore.storeActual(values = any()) - } - - Truth.assertThat(actual.isLeft()).isTrue() - } - - @Test - fun `fetch quotes failure because app currency not found`() = runTest { - val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) - - coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null - - val actual = fetcher(params) - - coVerifyOrder { - quotesStore.refresh(currenciesIds = params.currenciesIds) - appCurrencyResponseStore.getSyncOrNull() - quotesStore.storeError(currenciesIds = params.currenciesIds) - } - - coVerify(inverse = true) { - tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) - quotesStore.storeActual(values = any()) - } - - Truth.assertThat(actual.isLeft()).isTrue() - } - - private companion object { - - val currenciesIds = setOf( - CryptoCurrency.RawID(value = "BTC"), - CryptoCurrency.RawID(value = "ETH"), - ) - - val usdAppCurrency = CurrenciesResponse.Currency( - id = "USD".lowercase(), - code = "USD", - name = "US Dollar", - unit = "$", - type = "fiat", - rateBTC = "", - ) - - val successResponse = QuotesResponse( - quotes = mapOf( - "BTC" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE), - "ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN), - ), - ) - } -} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcherTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcherTest.kt new file mode 100644 index 0000000000..d522ca5ba9 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcherTest.kt @@ -0,0 +1,243 @@ +package com.tangem.data.quotes.multi + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.utils.assertEither +import com.tangem.data.common.quote.QuotesFetcher +import com.tangem.data.quotes.store.QuotesStatusesStore +import com.tangem.data.quotes.store.setSourceAsCache +import com.tangem.data.quotes.store.setSourceAsOnlyCache +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultMultiQuoteStatusFetcherTest { + + private val quotesFetcher = mockk() + private val appCurrencyResponseStore = mockk() + private val quotesStore = mockk(relaxed = true) + + private val fetcher = DefaultMultiQuoteStatusFetcher( + quotesFetcher = quotesFetcher, + appCurrencyResponseStore = appCurrencyResponseStore, + quotesStatusesStore = quotesStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @BeforeEach + fun resetMocks() { + clearMocks(quotesFetcher, appCurrencyResponseStore, quotesStore) + } + + @Test + fun `fetch successfully`() = runTest { + // Arrange + val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency + + val currenciesIds = setOf("BTC", "ETH") + + coEvery { + quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields) + } returns successResponse.right() + + // Act + val actual = fetcher(params) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds) + appCurrencyResponseStore.getSyncOrNull() + quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields) + quotesStore.store(values = successResponse.quotes) + } + + coVerify(inverse = true) { + quotesStore.setSourceAsOnlyCache(currenciesIds = any()) + } + } + + @Test + fun `fetch successfully if currenciesIds from params is empty`() = runTest { + // Arrange + val params = MultiQuoteStatusFetcher.Params(currenciesIds = emptySet(), appCurrencyId = null) + + // Act + val actual = fetcher(params) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerify(inverse = true) { + quotesStore.setSourceAsCache(currenciesIds = any()) + appCurrencyResponseStore.getSyncOrNull() + quotesFetcher.fetch(fiatCurrencyId = any(), currenciesIds = any(), fields = any()) + quotesStore.store(values = any()) + quotesStore.setSourceAsOnlyCache(currenciesIds = any()) + } + } + + @Test + fun `fetch successfully if appCurrencyId from params is not null`() = runTest { + // Arrange + val appCurrencyId = "usd" + val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId) + + val currenciesIds = setOf("BTC", "ETH") + + coEvery { + quotesFetcher.fetch(fiatCurrencyId = appCurrencyId, currenciesIds = currenciesIds, fields = fields) + } returns successResponse.right() + + // Act + val actual = fetcher(params) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds) + quotesFetcher.fetch(fiatCurrencyId = appCurrencyId, currenciesIds = currenciesIds, fields = fields) + quotesStore.store(values = successResponse.quotes) + } + + coVerify(inverse = true) { + appCurrencyResponseStore.getSyncOrNull() + quotesStore.setSourceAsOnlyCache(currenciesIds = any()) + } + } + + @Test + fun `fetch failure because appCurrencyId from params is blank`() = runTest { + // Arrange + val appCurrencyId = "" + val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId) + + // Act + val actual = fetcher(params) + + // Assert + val expected = IllegalStateException("Unable to get AppCurrency for updating quotes").left() + + assertEither(actual, expected) + + coVerifyOrder { + quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds) + quotesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds) + } + + coVerify(inverse = true) { + appCurrencyResponseStore.getSyncOrNull() + quotesFetcher.fetch(fiatCurrencyId = any(), currenciesIds = any(), fields = any()) + quotesStore.store(values = any()) + } + } + + @Test + fun `fetch failure because api request failed`() = runTest { + // Arrange + val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) + + val currenciesIds = setOf("BTC", "ETH") + val error = QuotesFetcher.Error.ApiOperationError(ApiResponseError.NetworkException) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency + + coEvery { + quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields) + } returns error.left() + + // Act + val actual = fetcher(params) + + // Assert + val expected = IllegalStateException("Cause: ApiOperationError(apiError=NetworkException)").left() + assertEither(actual, expected) + + coVerifyOrder { + quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds) + appCurrencyResponseStore.getSyncOrNull() + quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields) + quotesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds) + } + + coVerify(inverse = true) { + quotesStore.store(values = any()) + } + } + + @Test + fun `fetch failure because app currency not found`() = runTest { + // Arrange + val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null + + // Act + val actual = fetcher(params) + + // Assert + val expected = IllegalStateException("Unable to get AppCurrency for updating quotes").left() + + assertEither(actual, expected) + + coVerifyOrder { + quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds) + appCurrencyResponseStore.getSyncOrNull() + quotesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds) + } + + coVerify(inverse = true) { + quotesFetcher.fetch(fiatCurrencyId = any(), currenciesIds = any(), fields = any()) + quotesStore.store(values = any()) + } + } + + private companion object { + + val currenciesIds = setOf( + CryptoCurrency.RawID(value = "BTC"), + CryptoCurrency.RawID(value = "ETH"), + ) + + val usdAppCurrency = CurrenciesResponse.Currency( + id = "USD".lowercase(), + code = "USD", + name = "US Dollar", + unit = "$", + type = "fiat", + rateBTC = "", + ) + + val successResponse = QuotesResponse( + quotes = mapOf( + "BTC" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE), + "ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN), + ), + ) + + val fields = setOf(QuotesFetcher.Field.PRICE, QuotesFetcher.Field.PRICE_CHANGE_24H) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdaterTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusUpdaterTest.kt similarity index 79% rename from data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdaterTest.kt rename to data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusUpdaterTest.kt index f14962f52b..9d47ec8857 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdaterTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusUpdaterTest.kt @@ -3,10 +3,10 @@ package com.tangem.data.quotes.multi import arrow.core.right import com.google.common.truth.Truth import com.tangem.common.test.utils.getEmittedValues -import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse import com.tangem.datasource.appcurrency.AppCurrencyResponseStore -import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.flow.* @@ -16,16 +16,16 @@ import org.junit.Test /** [REDACTED_AUTHOR] */ -internal class DefaultMultiQuoteUpdaterTest { +internal class DefaultMultiQuoteStatusUpdaterTest { private val appCurrencyResponseStore: AppCurrencyResponseStore = mockk() - private val quotesStore: QuotesStoreV2 = mockk() - private val multiQuoteFetcher: MultiQuoteFetcher = mockk() + private val quotesStore: QuotesStatusesStore = mockk() + private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher = mockk() private val multiQuoteUpdater = DefaultMultiQuoteUpdater( appCurrencyResponseStore = appCurrencyResponseStore, - quotesStore = quotesStore, - multiQuoteFetcher = multiQuoteFetcher, + quotesStatusesStore = quotesStore, + multiQuoteStatusFetcher = multiQuoteStatusFetcher, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -36,8 +36,8 @@ internal class DefaultMultiQuoteUpdaterTest { every { appCurrencyResponseStore.get() } returns appCurrencyFlow coEvery { quotesStore.getAllSyncOrNull() } returns emptySet() - val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id) - coEvery { multiQuoteFetcher(params) } returns Unit.right() + val params = MultiQuoteStatusFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id) + coEvery { multiQuoteStatusFetcher(params) } returns Unit.right() val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow() @@ -48,7 +48,7 @@ internal class DefaultMultiQuoteUpdaterTest { coVerifyOrder { quotesStore.getAllSyncOrNull() - multiQuoteFetcher(params) + multiQuoteStatusFetcher(params) } } @@ -59,8 +59,8 @@ internal class DefaultMultiQuoteUpdaterTest { every { appCurrencyResponseStore.get() } returns appCurrencyFlow coEvery { quotesStore.getAllSyncOrNull() } returns emptySet() - val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id) - coEvery { multiQuoteFetcher(params) } returns Unit.right() + val params = MultiQuoteStatusFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id) + coEvery { multiQuoteStatusFetcher(params) } returns Unit.right() val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow() @@ -71,7 +71,7 @@ internal class DefaultMultiQuoteUpdaterTest { coVerifyOrder { quotesStore.getAllSyncOrNull() - multiQuoteFetcher(params) + multiQuoteStatusFetcher(params) } } @@ -90,7 +90,7 @@ internal class DefaultMultiQuoteUpdaterTest { coVerify(inverse = true) { quotesStore.getAllSyncOrNull() - multiQuoteFetcher(any()) + multiQuoteStatusFetcher(any()) } } @@ -111,8 +111,8 @@ internal class DefaultMultiQuoteUpdaterTest { every { appCurrencyResponseStore.get() } returns appCurrencyFlow coEvery { quotesStore.getAllSyncOrNull() } returns emptySet() - val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id) - coEvery { multiQuoteFetcher(params) } returns Unit.right() + val params = MultiQuoteStatusFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id) + coEvery { multiQuoteStatusFetcher(params) } returns Unit.right() val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow() @@ -126,7 +126,7 @@ internal class DefaultMultiQuoteUpdaterTest { coVerify(inverse = true) { quotesStore.getAllSyncOrNull() - multiQuoteFetcher(any()) + multiQuoteStatusFetcher(any()) } innerFlow.emit(value = true) @@ -136,7 +136,7 @@ internal class DefaultMultiQuoteUpdaterTest { coVerifyOrder { quotesStore.getAllSyncOrNull() - multiQuoteFetcher(params) + multiQuoteStatusFetcher(params) } } diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/repository/DefaultQuotesRepositoryTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/repository/DefaultQuotesRepositoryTest.kt new file mode 100644 index 0000000000..07c29dad8f --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/repository/DefaultQuotesRepositoryTest.kt @@ -0,0 +1,108 @@ +package com.tangem.data.quotes.repository + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.data.quotes.store.QuotesStatusesStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.quotes.QuotesRepository +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultQuotesRepositoryTest { + + private val quotesStatusesStore = mockk() + private val repository: QuotesRepository = DefaultQuotesRepository(quotesStatusesStore = quotesStatusesStore) + + @BeforeEach + fun resetMocks() { + clearMocks(quotesStatusesStore) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetMultiQuoteSyncOrNull { + + private val btcRawId = CryptoCurrency.RawID(value = "BTC") + private val ethRawId = CryptoCurrency.RawID(value = "ETH") + + private val ethQuote = QuoteStatus( + rawCurrencyId = ethRawId, + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ZERO, + priceChange = BigDecimal.ZERO, + ), + ) + + @ParameterizedTest + @ProvideTestModels + fun getMultiQuoteSyncOrNull(model: GetMultiQuoteSyncOrNullModel) = runTest { + // Arrange + coEvery { quotesStatusesStore.getAllSyncOrNull() } returns model.initialStore + + // Act + val actual = repository.getMultiQuoteSyncOrNull(currenciesIds = model.currencyIds) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + GetMultiQuoteSyncOrNullModel( + initialStore = null, + currencyIds = emptySet(), + expected = emptySet(), + ), + GetMultiQuoteSyncOrNullModel( + initialStore = null, + currencyIds = setOf(ethRawId), + expected = setOf(QuoteStatus(rawCurrencyId = ethRawId)), + ), + GetMultiQuoteSyncOrNullModel( + initialStore = emptySet(), + currencyIds = emptySet(), + expected = emptySet(), + ), + GetMultiQuoteSyncOrNullModel( + initialStore = emptySet(), + currencyIds = setOf(ethRawId), + expected = setOf(QuoteStatus(rawCurrencyId = ethRawId)), + ), + GetMultiQuoteSyncOrNullModel( + initialStore = setOf(ethQuote), + currencyIds = setOf(ethRawId), + expected = setOf(ethQuote), + ), + GetMultiQuoteSyncOrNullModel( + initialStore = setOf(QuoteStatus(rawCurrencyId = btcRawId)), + currencyIds = setOf(ethRawId), + expected = setOf(QuoteStatus(rawCurrencyId = ethRawId)), + ), + GetMultiQuoteSyncOrNullModel( + initialStore = setOf(ethQuote, QuoteStatus(rawCurrencyId = btcRawId)), + currencyIds = setOf(ethRawId), + expected = setOf(ethQuote), + ), + ) + } + + data class GetMultiQuoteSyncOrNullModel( + val initialStore: Set?, + val currencyIds: Set, + val expected: Set?, + ) +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteFetcherTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteFetcherTest.kt deleted file mode 100644 index a16f80b3d7..0000000000 --- a/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteFetcherTest.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.tangem.data.quotes.single - -import com.google.common.truth.Truth -import com.tangem.common.test.data.quote.MockQuoteResponseFactory -import com.tangem.data.quotes.multi.DefaultMultiQuoteFetcher -import com.tangem.data.quotes.store.QuotesStoreV2 -import com.tangem.datasource.api.common.response.ApiResponse -import com.tangem.datasource.api.common.response.ApiResponseError -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse -import com.tangem.datasource.api.tangemTech.models.QuotesResponse -import com.tangem.datasource.appcurrency.AppCurrencyResponseStore -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.quotes.single.SingleQuoteFetcher -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.coVerifyOrder -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.Test -import java.math.BigDecimal - -internal class DefaultSingleQuoteFetcherTest { - - private val tangemTechApi = mockk(relaxed = true) - private val appCurrencyResponseStore = mockk(relaxed = true) - private val quotesStore = mockk(relaxed = true) - - private val multiFetcher = DefaultMultiQuoteFetcher( - tangemTechApi = tangemTechApi, - appCurrencyResponseStore = appCurrencyResponseStore, - quotesStore = quotesStore, - dispatchers = TestingCoroutineDispatcherProvider(), - ) - - private val singleFetcher = DefaultSingleQuoteFetcher(multiFetcher) - - @Test - fun `fetch single quote successfully`() = runTest { - val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null) - - coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency - - val coinIds = "BTC" - coEvery { - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - } returns ApiResponse.Success(successResponse) - - val actual = singleFetcher(params) - - coVerifyOrder { - quotesStore.refresh(currenciesIds = setOf(params.rawCurrencyId)) - appCurrencyResponseStore.getSyncOrNull() - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - quotesStore.storeActual(values = successResponse.quotes) - } - - coVerify(inverse = true) { - quotesStore.storeError(currenciesIds = any()) - } - - Truth.assertThat(actual.isRight()).isTrue() - } - - @Test - fun `fetch single quote successfully if appCurrencyId from params is not null`() = runTest { - val appCurrencyId = "usd" - val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = appCurrencyId) - - val coinIds = "BTC" - coEvery { - tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds) - } returns ApiResponse.Success(successResponse) - - val actual = singleFetcher(params) - - coVerifyOrder { - quotesStore.refresh(currenciesIds = setOf(currenciesId)) - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - quotesStore.storeActual(values = successResponse.quotes) - } - - Truth.assertThat(actual.isRight()).isTrue() - } - - @Test - fun `fetch single quote failure because appCurrencyId from params is blank`() = runTest { - val appCurrencyId = "" - val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = appCurrencyId) - - val actual = singleFetcher(params) - - coVerifyOrder { - quotesStore.refresh(currenciesIds = setOf(currenciesId)) - quotesStore.storeError(currenciesIds = setOf(currenciesId)) - } - - Truth.assertThat(actual.isLeft()).isTrue() - Truth.assertThat(actual.leftOrNull()).isInstanceOf(IllegalStateException::class.java) - Truth.assertThat(actual.leftOrNull()).hasMessageThat() - .isEqualTo("Unable to get AppCurrency for updating quotes") - } - - @Test - fun `fetch single quote failure because api request failed`() = runTest { - val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null) - - coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency - - val coinIds = "BTC" - - @Suppress("UNCHECKED_CAST") - val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse - coEvery { tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) } returns errorResponse - - val actual = singleFetcher(params) - - coVerifyOrder { - quotesStore.refresh(currenciesIds = setOf(currenciesId)) - appCurrencyResponseStore.getSyncOrNull() - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - quotesStore.storeError(currenciesIds = setOf(currenciesId)) - } - - coVerify(inverse = true) { - quotesStore.storeActual(values = any()) - } - - Truth.assertThat(actual.isLeft()).isTrue() - } - - @Test - fun `fetch single quote failure because app currency not found`() = runTest { - val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null) - - coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null - - val actual = singleFetcher(params) - - coVerifyOrder { - quotesStore.refresh(currenciesIds = setOf(currenciesId)) - appCurrencyResponseStore.getSyncOrNull() - quotesStore.storeError(currenciesIds = setOf(currenciesId)) - } - - coVerify(inverse = true) { - tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) - quotesStore.storeActual(values = any()) - } - - Truth.assertThat(actual.isLeft()).isTrue() - } - - private companion object { - - val currenciesId = CryptoCurrency.RawID(value = "BTC") - - val usdAppCurrency = CurrenciesResponse.Currency( - id = "USD".lowercase(), - code = "USD", - name = "US Dollar", - unit = "$", - type = "fiat", - rateBTC = "", - ) - - val successResponse = QuotesResponse( - quotes = mapOf( - "BTC" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE), - ), - ) - } -} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusFetcherTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusFetcherTest.kt new file mode 100644 index 0000000000..c13faf11c6 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusFetcherTest.kt @@ -0,0 +1,79 @@ +package com.tangem.data.quotes.single + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultSingleQuoteStatusFetcherTest { + + private val multiFetcher = mockk() + private val singleFetcher = DefaultSingleQuoteStatusFetcher(multiFetcher) + + @BeforeEach + fun resetMocks() { + clearMocks(multiFetcher) + } + + @Test + fun `fetch successfully if multiFetcher returns success`() = runTest { + // Arrange + val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currencyId, appCurrencyId = null) + + val multiFetcherParams = MultiQuoteStatusFetcher.Params( + currenciesIds = setOf(params.rawCurrencyId), + appCurrencyId = params.appCurrencyId, + ) + + coEvery { multiFetcher(multiFetcherParams) } returns Unit.right() + + // Act + val actual = singleFetcher(params) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerify(exactly = 1) { multiFetcher(multiFetcherParams) } + } + + @Test + fun `fetch failure if multiFetcher returns erro`() = runTest { + // Arrange + val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currencyId, appCurrencyId = null) + + val multiFetcherParams = MultiQuoteStatusFetcher.Params( + currenciesIds = setOf(params.rawCurrencyId), + appCurrencyId = params.appCurrencyId, + ) + + val multiFetcherError = IllegalStateException("").left() + + coEvery { multiFetcher(multiFetcherParams) } returns multiFetcherError + + // Act + val actual = singleFetcher(params) + + // Assert + val expected = multiFetcherError + Truth.assertThat(actual).isEqualTo(expected) + + coVerify(exactly = 1) { multiFetcher(multiFetcherParams) } + } + + private companion object { + + val currencyId = CryptoCurrency.RawID(value = "BTC") + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducerTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducerTest.kt similarity index 73% rename from data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducerTest.kt rename to data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducerTest.kt index ce3be77d28..ef5fccaf7f 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducerTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducerTest.kt @@ -2,11 +2,11 @@ package com.tangem.data.quotes.single import com.google.common.truth.Truth import com.tangem.common.test.utils.getEmittedValues -import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.quotes.single.SingleQuoteProducer -import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.quotes.single.SingleQuoteStatusProducer import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every import io.mockk.mockk @@ -19,27 +19,27 @@ import java.math.BigDecimal /** [REDACTED_AUTHOR] */ -internal class DefaultSingleQuoteProducerTest { +internal class DefaultSingleQuoteStatusProducerTest { - private val params = SingleQuoteProducer.Params( + private val params = SingleQuoteStatusProducer.Params( rawCurrencyId = CryptoCurrency.RawID(value = "BTC"), ) - private val quotesStore = mockk() + private val quotesStore = mockk() - private val producer = DefaultSingleQuoteProducer( + private val producer = DefaultSingleQuoteStatusProducer( params = params, - quotesStore = quotesStore, + quotesStatusesStore = quotesStore, dispatchers = TestingCoroutineDispatcherProvider(), ) @Test fun `test that flow is mapped for network from params`() = runTest { - val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId) val storeQuote = flowOf( setOf( status, - Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + QuoteStatus(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), ), ) @@ -57,7 +57,7 @@ internal class DefaultSingleQuoteProducerTest { @Test fun `test that flow is updated if quote is updated`() = runTest { - val storeQuote = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + val storeQuote = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) every { quotesStore.get() } returns storeQuote @@ -66,7 +66,7 @@ internal class DefaultSingleQuoteProducerTest { verify { quotesStore.get() } // first emit - val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId) storeQuote.emit(value = setOf(status)) val values1 = getEmittedValues(flow = actual) @@ -75,11 +75,13 @@ internal class DefaultSingleQuoteProducerTest { Truth.assertThat(values1).isEqualTo(listOf(status)) // second emit - val updatedStatus = Quote.Value( + val updatedStatus = QuoteStatus( rawCurrencyId = params.rawCurrencyId, - fiatRate = BigDecimal.ONE, - priceChange = BigDecimal.ZERO, - source = StatusSource.ACTUAL, + value = QuoteStatus.Data( + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ), ) storeQuote.emit(value = setOf(updatedStatus)) @@ -91,7 +93,7 @@ internal class DefaultSingleQuoteProducerTest { @Test fun `test that flow is filtered the same status`() = runTest { - val storeQuote = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + val storeQuote = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) every { quotesStore.get() } returns storeQuote @@ -100,7 +102,7 @@ internal class DefaultSingleQuoteProducerTest { verify { quotesStore.get() } // first emit - val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId) storeQuote.emit(value = setOf(status)) val values1 = getEmittedValues(flow = actual) @@ -120,11 +122,13 @@ internal class DefaultSingleQuoteProducerTest { @Test fun `test if flow throws exception`() = runTest { val exception = IllegalStateException() - val status = Quote.Value( + val status = QuoteStatus( rawCurrencyId = params.rawCurrencyId, - fiatRate = BigDecimal.ONE, - priceChange = BigDecimal.ZERO, - source = StatusSource.ACTUAL, + value = QuoteStatus.Data( + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ), ) val innerFlow = MutableStateFlow(value = false) @@ -146,7 +150,7 @@ internal class DefaultSingleQuoteProducerTest { val values1 = getEmittedValues(flow = actual) Truth.assertThat(values1.size).isEqualTo(1) - val fallbackStatus = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + val fallbackStatus = QuoteStatus(rawCurrencyId = params.rawCurrencyId) Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus)) innerFlow.emit(value = true) @@ -160,7 +164,7 @@ internal class DefaultSingleQuoteProducerTest { fun `test if flow doesn't contain network from params`() = runTest { val storeFlow = flowOf( setOf( - Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + QuoteStatus(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), ), ) diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreExtTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreExtTest.kt new file mode 100644 index 0000000000..93b3bb2077 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreExtTest.kt @@ -0,0 +1,133 @@ +package com.tangem.data.quotes.store + +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.data.quote.toDomain +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal +import kotlin.properties.Delegates + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class QuotesStatusesStoreExtTest { + + private var runtimeStore: RuntimeSharedStore> by Delegates.notNull() + private var persistenceStore: MockStateDataStore by Delegates.notNull() + private var store: DefaultQuotesStatusesStore by Delegates.notNull() + + private val btcQuoteDM = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + private val btcQuote = btcQuoteDM.toDomain() + + @BeforeEach + fun resetMocks() { + runtimeStore = RuntimeSharedStore() + persistenceStore = MockStateDataStore(default = emptyMap()) + + store = DefaultQuotesStatusesStore( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class SetSourceAsCache { + + @ParameterizedTest + @ProvideTestModels + fun setSourceAsCache(model: SetSourceTestModel) = runTest { + // Arrange + if (model.initialRuntime != null) { + runtimeStore.store(value = model.initialRuntime) + } + + // Act + store.setSourceAsCache(model.currenciesIds) + val actual = store.getAllSyncOrNull() + + // Assert + val expected = model.expected + + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + SetSourceTestModel( + initialRuntime = null, + currenciesIds = setOf(btcQuote.rawCurrencyId), + expected = emptySet(), + ), + SetSourceTestModel( + initialRuntime = setOf(btcQuote), + currenciesIds = setOf(btcQuote.rawCurrencyId), + expected = setOf(btcQuoteDM.toDomain(source = StatusSource.CACHE)), + ), + SetSourceTestModel( + initialRuntime = setOf(btcQuoteDM.toDomain(source = StatusSource.CACHE)), + currenciesIds = setOf(btcQuote.rawCurrencyId), + expected = setOf(btcQuoteDM.toDomain(source = StatusSource.CACHE)), + ), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class SetSourceAsOnlyCache { + + @ParameterizedTest + @ProvideTestModels + fun setSourceAsOnlyCache(model: SetSourceTestModel) = runTest { + // Arrange + if (model.initialRuntime != null) { + runtimeStore.store(value = model.initialRuntime) + } + + // Act + store.setSourceAsOnlyCache(model.currenciesIds) + val actual = store.getAllSyncOrNull() + + // Assert + val expected = model.expected + + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + SetSourceTestModel( + initialRuntime = null, + currenciesIds = setOf(btcQuote.rawCurrencyId), + expected = setOf(QuoteStatus(btcQuote.rawCurrencyId)), + ), + SetSourceTestModel( + initialRuntime = setOf(btcQuote), + currenciesIds = setOf(btcQuote.rawCurrencyId), + expected = setOf(btcQuoteDM.toDomain(source = StatusSource.ONLY_CACHE)), + ), + SetSourceTestModel( + initialRuntime = setOf(btcQuoteDM.toDomain(source = StatusSource.ONLY_CACHE)), + currenciesIds = setOf(btcQuote.rawCurrencyId), + expected = setOf(btcQuoteDM.toDomain(source = StatusSource.ONLY_CACHE)), + ), + ) + } + + data class SetSourceTestModel( + val initialRuntime: Set?, + val currenciesIds: Set, + val expected: Set?, + ) +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreTest.kt new file mode 100644 index 0000000000..b9b3c6ee60 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreTest.kt @@ -0,0 +1,565 @@ +package com.tangem.data.quotes.store + +import androidx.datastore.core.DataStore +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.data.quote.toDomain +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal +import kotlin.properties.Delegates + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class QuotesStatusesStoreTest { + + private var runtimeStore: RuntimeSharedStore> by Delegates.notNull() + private var persistenceStore: MockStateDataStore by Delegates.notNull() + private var store: DefaultQuotesStatusesStore by Delegates.notNull() + + // region Data models + private val btcQuoteDM = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + private val ethQuoteDM = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + // endregion + + // region Domain models + private val btcQuote = btcQuoteDM.toDomain() + private val ethQuote = ethQuoteDM.toDomain() + private val adaEmptyQuote = QuoteStatus(rawCurrencyId = CryptoCurrency.RawID(value = "ADA")) + // endregion + + @BeforeEach + fun resetMocks() { + runtimeStore = RuntimeSharedStore() + persistenceStore = MockStateDataStore(default = emptyMap()) + + store = DefaultQuotesStatusesStore( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Initialization { + + @Test + fun `initialization if cache store is empty`() = runTest { + // Arrange + val runtimeStore = RuntimeSharedStore>() + val persistenceStore: DataStore = mockk() + + every { persistenceStore.data } returns emptyFlow() + + // Act + DefaultQuotesStatusesStore( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + val actual = runtimeStore.getSyncOrNull() + + // Assert + val expected = null + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `initialization if cache store contains empty map`() = runTest { + // Arrange + val runtimeStore = RuntimeSharedStore>() + val persistenceStore = MockStateDataStore(default = emptyMap()) + + // Act + DefaultQuotesStatusesStore( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + val actual = runtimeStore.getSyncOrNull() + + // Assert + val expected = null + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `initialization if cache store is not empty`() = runTest { + // Arrange + val runtimeStore = RuntimeSharedStore>() + val persistenceStore = MockStateDataStore(default = emptyMap()) + + persistenceStore.updateData { + it.toMutableMap().apply { + this += btcQuoteDM + this += ethQuoteDM + } + } + + // Act + DefaultQuotesStatusesStore( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + val actual = runtimeStore.getSyncOrNull() + + // Assert + val expected = setOf( + btcQuoteDM.toDomain(source = StatusSource.CACHE), + ethQuoteDM.toDomain(source = StatusSource.CACHE), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Get { + + @ParameterizedTest + @ProvideTestModels + fun get(model: GetTestModel) = runTest { + // Arrange + if (model.initialRuntime != null) { + runtimeStore.store(value = model.initialRuntime) + } + + // Act + val actual = getEmittedValues(flow = store.get()) + + // Assert + val expected = listOfNotNull(model.expected) + + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + GetTestModel(initialRuntime = null, expected = null), + GetTestModel(initialRuntime = emptySet(), expected = emptySet()), + GetTestModel(initialRuntime = setOf(btcQuote, ethQuote), expected = setOf(btcQuote, ethQuote)), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetAllSyncOrNull { + + @ParameterizedTest + @ProvideTestModels + fun getAllSyncOrNull(model: GetTestModel) = runTest { + // Arrange + if (model.initialRuntime != null) { + runtimeStore.store(value = model.initialRuntime) + } + + // Act + val actual = store.getAllSyncOrNull() + + // Assert + val expected = model.expected + + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + GetTestModel(initialRuntime = null, expected = null), + GetTestModel(initialRuntime = emptySet(), expected = emptySet()), + GetTestModel(initialRuntime = setOf(btcQuote, ethQuote), expected = setOf(btcQuote, ethQuote)), + ) + } + + data class GetTestModel(val initialRuntime: Set?, val expected: Set?) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class SingleUpdateStatusSource { + + @ParameterizedTest + @ProvideTestModels + fun updateStatusSource(model: UpdateStatusSourceModel.Single) = runTest { + // Arrange + if (model.initialRuntime != null) { + runtimeStore.store(value = model.initialRuntime) + } + + // Act + store.updateStatusSource( + currencyId = model.currencyId, + source = model.source, + ifNotFound = model.ifNotFound, + ) + + val actual = store.getAllSyncOrNull() + + // Assert + val expected = model.expected + + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + // region runtime store is null + UpdateStatusSourceModel.Single( + initialRuntime = null, + currencyId = btcQuote.rawCurrencyId, + source = StatusSource.CACHE, // never-mind + expected = emptySet(), + ), + UpdateStatusSourceModel.Single( + initialRuntime = null, + currencyId = btcQuote.rawCurrencyId, + source = StatusSource.ONLY_CACHE, // never-mind + ifNotFound = ::QuoteStatus, + expected = setOf( + QuoteStatus(rawCurrencyId = btcQuote.rawCurrencyId), + ), + ), + // endregion + + // region runtime store is empty + UpdateStatusSourceModel.Single( + initialRuntime = emptySet(), + currencyId = btcQuote.rawCurrencyId, + source = StatusSource.CACHE, // never-mind + expected = emptySet(), + ), + UpdateStatusSourceModel.Single( + initialRuntime = emptySet(), + currencyId = btcQuote.rawCurrencyId, + source = StatusSource.ONLY_CACHE, // never-mind + ifNotFound = ::QuoteStatus, + expected = setOf( + QuoteStatus(rawCurrencyId = btcQuote.rawCurrencyId), + ), + ), + // endregion + + // region runtime store contains statuses + UpdateStatusSourceModel.Single( + initialRuntime = setOf(btcQuote, ethQuote, adaEmptyQuote), + currencyId = btcQuote.rawCurrencyId, + source = StatusSource.CACHE, + expected = setOf( + btcQuote.copy(value = btcQuote.value.copySealed(source = StatusSource.CACHE)), + ethQuote, + adaEmptyQuote, + ), + ), + UpdateStatusSourceModel.Single( + initialRuntime = setOf(btcQuote, ethQuote), + currencyId = btcQuote.rawCurrencyId, + source = StatusSource.ONLY_CACHE, + expected = setOf( + btcQuote.copy(value = btcQuote.value.copySealed(source = StatusSource.ONLY_CACHE)), + ethQuote, + ), + ), + UpdateStatusSourceModel.Single( + initialRuntime = setOf(btcQuote, ethQuote), + currencyId = btcQuote.rawCurrencyId, + source = StatusSource.ACTUAL, + expected = setOf(btcQuote, ethQuote), + ), + UpdateStatusSourceModel.Single( + initialRuntime = setOf(btcQuote), + currencyId = adaEmptyQuote.rawCurrencyId, + source = StatusSource.ACTUAL, + ifNotFound = ::QuoteStatus, + expected = setOf(btcQuote, adaEmptyQuote), + ), + // endregion + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class MultiUpdateStatusSource { + + @ParameterizedTest + @ProvideTestModels + fun updateStatusSource(model: UpdateStatusSourceModel.Multi) = runTest { + // Arrange + if (model.initialRuntime != null) { + runtimeStore.store(value = model.initialRuntime) + } + + // Act + store.updateStatusSource( + currenciesIds = model.currenciesIds, + source = model.source, + ifNotFound = model.ifNotFound, + ) + + val actual = store.getAllSyncOrNull() + + // Assert + val expected = model.expected + + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + // region runtime store is null + UpdateStatusSourceModel.Multi( + initialRuntime = null, + currenciesIds = setOf(), + source = StatusSource.CACHE, // never-mind + expected = null, + ), + UpdateStatusSourceModel.Multi( + initialRuntime = null, + currenciesIds = setOf(btcQuote.rawCurrencyId), + source = StatusSource.ONLY_CACHE, // never-mind + expected = emptySet(), + ), + UpdateStatusSourceModel.Multi( + initialRuntime = null, + currenciesIds = setOf( + btcQuote.rawCurrencyId, + CryptoCurrency.RawID(value = "ETH"), + ), + source = StatusSource.ACTUAL, // never-mind + expected = emptySet(), + ), + UpdateStatusSourceModel.Multi( + initialRuntime = null, + currenciesIds = setOf(btcQuote.rawCurrencyId), + source = StatusSource.ONLY_CACHE, // never-mind + ifNotFound = ::QuoteStatus, + expected = setOf( + QuoteStatus(rawCurrencyId = btcQuote.rawCurrencyId), + ), + ), + // endregion + + // region runtime store is empty + UpdateStatusSourceModel.Multi( + initialRuntime = emptySet(), + currenciesIds = setOf(), + source = StatusSource.CACHE, // never-mind + expected = emptySet(), + ), + UpdateStatusSourceModel.Multi( + initialRuntime = emptySet(), + currenciesIds = setOf(btcQuote.rawCurrencyId), + source = StatusSource.ONLY_CACHE, // never-mind + expected = emptySet(), + ), + UpdateStatusSourceModel.Multi( + initialRuntime = emptySet(), + currenciesIds = setOf( + btcQuote.rawCurrencyId, + CryptoCurrency.RawID(value = "ETH"), + ), + source = StatusSource.ACTUAL, // never-mind + expected = emptySet(), + ), + UpdateStatusSourceModel.Multi( + initialRuntime = emptySet(), + currenciesIds = setOf(btcQuote.rawCurrencyId), + source = StatusSource.ONLY_CACHE, // never-mind + ifNotFound = ::QuoteStatus, + expected = setOf( + QuoteStatus(rawCurrencyId = btcQuote.rawCurrencyId), + ), + ), + // endregion + + // region runtime store contains statuses + UpdateStatusSourceModel.Multi( + initialRuntime = setOf(btcQuote, adaEmptyQuote), + currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId), + source = StatusSource.CACHE, + expected = setOf( + btcQuote.copy(value = btcQuote.value.copySealed(source = StatusSource.CACHE)), + adaEmptyQuote, + ), + ), + UpdateStatusSourceModel.Multi( + initialRuntime = setOf(btcQuote, adaEmptyQuote), + currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId), + source = StatusSource.ONLY_CACHE, + expected = setOf( + btcQuote.copy(value = btcQuote.value.copySealed(source = StatusSource.ONLY_CACHE)), + adaEmptyQuote, + ), + ), + UpdateStatusSourceModel.Multi( + initialRuntime = setOf(btcQuote, adaEmptyQuote), + currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId), + source = StatusSource.ACTUAL, + expected = setOf(btcQuote, adaEmptyQuote), + ), + UpdateStatusSourceModel.Multi( + initialRuntime = setOf(btcQuote), + currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId), + source = StatusSource.ACTUAL, + expected = setOf(btcQuote), + ), + UpdateStatusSourceModel.Multi( + initialRuntime = setOf(btcQuote), + currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId), + source = StatusSource.ACTUAL, + ifNotFound = ::QuoteStatus, + expected = setOf(btcQuote, adaEmptyQuote), + ), + // endregion + ) + } + + sealed interface UpdateStatusSourceModel { + + val initialRuntime: Set? + val source: StatusSource + val ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus? + val expected: Set? + + data class Single( + override val initialRuntime: Set?, + val currencyId: CryptoCurrency.RawID, + override val source: StatusSource, + override val ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus? = { null }, + override val expected: Set?, + ) : UpdateStatusSourceModel + + data class Multi( + override val initialRuntime: Set?, + val currenciesIds: Set, + override val source: StatusSource, + override val ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus? = { null }, + override val expected: Set?, + ) : UpdateStatusSourceModel + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Store { + + @ParameterizedTest + @ProvideTestModels + fun store(model: StoreTestModel) = runTest { + // Arrange + if (model.initialRuntime != null) { + runtimeStore.store(value = model.initialRuntime) + } + + if (model.initialPersistence != null) { + persistenceStore.updateData { model.initialPersistence } + } + + // Act + store.store(values = model.values) + + val runtimeActual = runtimeStore.getSyncOrNull() + val persistenceActual = getEmittedValues(persistenceStore.data) + + // Assert + val runtimeExpected = model.runtimeExpected + val persistenceExpected = listOf(model.persistenceExpected) + + Truth.assertThat(runtimeActual).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceActual).isEqualTo(persistenceExpected) + } + + private fun provideTestModels() = listOf( + // region stores are null + StoreTestModel( + initialRuntime = null, + initialPersistence = null, + values = emptyMap(), + runtimeExpected = null, + persistenceExpected = emptyMap(), + ), + StoreTestModel( + initialRuntime = null, + initialPersistence = null, + values = mapOf(btcQuoteDM), + runtimeExpected = setOf(btcQuote), + persistenceExpected = mapOf(btcQuoteDM), + ), + // endregion + + // region persistence store is null + StoreTestModel( + initialRuntime = setOf(btcQuote), + initialPersistence = null, + values = emptyMap(), + runtimeExpected = setOf(btcQuote), + persistenceExpected = emptyMap(), + ), + StoreTestModel( + initialRuntime = setOf(ethQuote), + initialPersistence = null, + values = mapOf(btcQuoteDM), + runtimeExpected = setOf(ethQuote, btcQuote), + persistenceExpected = mapOf(btcQuoteDM), + ), + // endregion + + // region runtime store is null + StoreTestModel( + initialRuntime = null, + initialPersistence = mapOf(btcQuoteDM), + values = emptyMap(), + runtimeExpected = null, + persistenceExpected = mapOf(btcQuoteDM), + ), + StoreTestModel( + initialRuntime = null, + initialPersistence = mapOf(ethQuoteDM), + values = mapOf(btcQuoteDM), + runtimeExpected = setOf(btcQuote), + persistenceExpected = mapOf(ethQuoteDM, btcQuoteDM), + ), + // endregion + + // region stores contain data + StoreTestModel( + initialRuntime = setOf(btcQuote), + initialPersistence = mapOf(btcQuoteDM), + values = emptyMap(), + runtimeExpected = setOf(btcQuote), + persistenceExpected = mapOf(btcQuoteDM), + ), + StoreTestModel( + initialRuntime = setOf(btcQuote), + initialPersistence = mapOf(btcQuoteDM), + values = mapOf(ethQuoteDM), + runtimeExpected = setOf(ethQuote, btcQuote), + persistenceExpected = mapOf(ethQuoteDM, btcQuoteDM), + ), + // endregion + ) + } + + data class StoreTestModel( + val initialRuntime: Set?, + val initialPersistence: CurrencyIdWithQuote?, + val values: CurrencyIdWithQuote, + val persistenceExpected: CurrencyIdWithQuote?, + val runtimeExpected: Set?, + ) +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreGetMethodTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreGetMethodTest.kt deleted file mode 100644 index d5a517f5e2..0000000000 --- a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreGetMethodTest.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.tangem.data.quotes.store - -import com.google.common.truth.Truth -import com.tangem.common.test.data.quote.MockQuoteResponseFactory -import com.tangem.common.test.data.quote.toDomain -import com.tangem.common.test.datastore.MockStateDataStore -import com.tangem.common.test.utils.getEmittedValues -import com.tangem.datasource.local.datastore.RuntimeSharedStore -import com.tangem.domain.tokens.model.Quote -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import kotlinx.coroutines.test.runTest -import org.junit.Test -import java.math.BigDecimal - -/** -[REDACTED_AUTHOR] - */ -internal class QuotesStoreGetMethodTest { - - private val runtimeStore = RuntimeSharedStore>() - private val persistenceStore = MockStateDataStore(default = emptyMap()) - - private val store = DefaultQuotesStoreV2( - runtimeStore = runtimeStore, - persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), - ) - - @Test - fun `test get if runtime store is empty`() = runTest { - val actual = store.get() - - val values = getEmittedValues(flow = actual) - - Truth.assertThat(values).isEqualTo(emptyList>()) - } - - @Test - fun `test get if runtime store contains empty set`() = runTest { - runtimeStore.store(value = emptySet()) - - val actual = store.get() - - val values = getEmittedValues(flow = actual) - - Truth.assertThat(values).isEqualTo(listOf(emptySet())) - } - - @Test - fun `test get if runtime store is not empty`() = runTest { - val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) - val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) - - runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain())) - - val actual = store.get() - - val values = getEmittedValues(flow = actual) - - Truth.assertThat(values.size).isEqualTo(1) - Truth.assertThat(values).isEqualTo(listOf(setOf(btcQuote.toDomain(), ethQuote.toDomain()))) - } - - @Test - fun `test getAllSyncOrNull if runtime store is empty`() = runTest { - val actual = store.getAllSyncOrNull() - - Truth.assertThat(actual).isEqualTo(null) - } - - @Test - fun `test getAllSyncOrNull if runtime store contains empty set`() = runTest { - runtimeStore.store(value = emptySet()) - - val actual = store.getAllSyncOrNull() - - Truth.assertThat(actual).isEqualTo(emptySet()) - } - - @Test - fun `test getAllSyncOrNull if runtime store is not empty`() = runTest { - val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) - val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) - - runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain())) - - val actual = store.getAllSyncOrNull() - - val expected = setOf(btcQuote.toDomain(), ethQuote.toDomain()) - Truth.assertThat(actual).isEqualTo(expected) - } -} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreInitializationTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreInitializationTest.kt deleted file mode 100644 index 2452a66004..0000000000 --- a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreInitializationTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.tangem.data.quotes.store - -import androidx.datastore.core.DataStore -import com.google.common.truth.Truth -import com.tangem.common.test.data.quote.MockQuoteResponseFactory -import com.tangem.common.test.data.quote.toDomain -import com.tangem.common.test.datastore.MockStateDataStore -import com.tangem.datasource.local.datastore.RuntimeSharedStore -import com.tangem.domain.models.StatusSource -import com.tangem.domain.tokens.model.Quote -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.test.runTest -import org.junit.Test -import java.math.BigDecimal - -/** -[REDACTED_AUTHOR] - */ -internal class QuotesStoreInitializationTest { - - @Test - fun `test initialization if cache store is empty`() = runTest { - val runtimeStore = RuntimeSharedStore>() - val persistenceStore: DataStore = mockk() - - every { persistenceStore.data } returns emptyFlow() - - DefaultQuotesStoreV2( - runtimeStore = runtimeStore, - persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), - ) - - Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null) - } - - @Test - fun `test initialization if cache store contains empty map`() = runTest { - val runtimeStore = RuntimeSharedStore>() - val persistenceStore = MockStateDataStore(default = emptyMap()) - - DefaultQuotesStoreV2( - runtimeStore = runtimeStore, - persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), - ) - - Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null) - } - - @Test - fun `test initialization if cache store is not empty`() = runTest { - val runtimeStore = RuntimeSharedStore>() - val persistenceStore = MockStateDataStore(default = emptyMap()) - - val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) - val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) - - persistenceStore.updateData { - it.toMutableMap().apply { - this += btcQuote - this += ethQuote - } - } - - DefaultQuotesStoreV2( - runtimeStore = runtimeStore, - persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), - ) - - val expected = setOf( - btcQuote.toDomain(source = StatusSource.CACHE), - ethQuote.toDomain(source = StatusSource.CACHE), - ) - - Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected) - } -} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreUpdateMethodsTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreUpdateMethodsTest.kt deleted file mode 100644 index e935753dcf..0000000000 --- a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreUpdateMethodsTest.kt +++ /dev/null @@ -1,129 +0,0 @@ -package com.tangem.data.quotes.store - -import com.google.common.truth.Truth -import com.tangem.common.test.data.quote.MockQuoteResponseFactory -import com.tangem.common.test.data.quote.toDomain -import com.tangem.common.test.datastore.MockStateDataStore -import com.tangem.datasource.local.datastore.RuntimeSharedStore -import com.tangem.domain.models.StatusSource -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.model.Quote -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.test.runTest -import org.junit.Test -import java.math.BigDecimal - -/** -[REDACTED_AUTHOR] - */ -internal class QuotesStoreUpdateMethodsTest { - - private val runtimeStore = RuntimeSharedStore>() - private val persistenceStore = MockStateDataStore(default = emptyMap()) - - private val store = DefaultQuotesStoreV2( - runtimeStore = runtimeStore, - persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), - ) - - @Test - fun `refresh if runtime store is empty`() = runTest { - val currenciesIds = setOf( - CryptoCurrency.RawID(value = "BTC"), - CryptoCurrency.RawID(value = "ETH"), - ) - - store.refresh(currenciesIds = currenciesIds) - - Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptySet()) - Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) - } - - @Test - fun `refresh if runtime store contains quote with this id`() = runTest { - val quote = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE) - .toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL) - - runtimeStore.store(value = setOf(quote)) - - store.refresh(currenciesIds = setOf(quote.rawCurrencyId)) - - val runtimeExpected = setOf(quote.copySealed(source = StatusSource.CACHE)) - - Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) - Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) - } - - @Test - fun `store actual if runtime and cache stores contain quotes with this id`() = runTest { - val prevStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE) - - runtimeStore.store( - value = setOf( - prevStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE), - ), - ) - - persistenceStore.updateData { - it.toMutableMap().apply { - put("BTC", prevStatus) - } - } - - val newStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN) - - store.storeActual(values = mapOf("BTC" to newStatus)) - - val runtimeExpected = setOf( - newStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL), - ) - val persistenceExpected = mapOf("BTC" to newStatus) - - Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) - Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected) - } - - @Test - fun `store error if runtime store is empty`() = runTest { - val currenciesIds = setOf( - CryptoCurrency.RawID(value = "BTC"), - CryptoCurrency.RawID(value = "ETH"), - ) - - store.storeError(currenciesIds = currenciesIds) - - val runtimeExpected = currenciesIds.map(Quote::Empty).toSet() - - Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) - Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) - } - - @Test - fun `store error if runtime store contains status with this network`() = runTest { - val status = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE) - - runtimeStore.store( - value = setOf( - status.toDomain(rawCurrencyId = "BTC", source = StatusSource.CACHE), - Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), - ), - ) - - store.storeError( - currenciesIds = setOf( - CryptoCurrency.RawID(value = "BTC"), - CryptoCurrency.RawID(value = "ETH"), - ), - ) - - val runtimeExpected = setOf( - status.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE), - Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), - ) - - Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) - Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) - } -} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/utils/QuotesUnsupportedCurrenciesIdAdapterTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/utils/QuotesUnsupportedCurrenciesIdAdapterTest.kt new file mode 100644 index 0000000000..3a4f3abb14 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/utils/QuotesUnsupportedCurrenciesIdAdapterTest.kt @@ -0,0 +1,144 @@ +package com.tangem.data.quotes.utils + +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.compatibility.l2BlockchainsCoinIds +import com.tangem.blockchainsdk.utils.toCoinId +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.data.quotes.utils.QuotesUnsupportedCurrenciesIdAdapter.ReplacementResult +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.api.tangemTech.models.QuotesResponse.Quote +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class QuotesUnsupportedCurrenciesIdAdapterTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ReplaceUnsupportedCurrencies { + + @ParameterizedTest + @ProvideTestModels + fun replaceUnsupportedCurrencies(model: ReplaceUnsupportedCurrenciesModel) { + // Act + val actual = QuotesUnsupportedCurrenciesIdAdapter.replaceUnsupportedCurrencies( + currenciesIds = model.currenciesIds, + ) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + ReplaceUnsupportedCurrenciesModel( + currenciesIds = l2BlockchainsCoinIds.toSet(), + expected = ReplacementResult( + idsForRequest = l2BlockchainsCoinIds.mapTo(hashSetOf()) { + Blockchain.Ethereum.toCoinId() + }, + idsFiltered = l2BlockchainsCoinIds.toSet(), + ), + ), + ReplaceUnsupportedCurrenciesModel( + currenciesIds = notL2BlockchainsCoinIds, + expected = ReplacementResult(idsForRequest = notL2BlockchainsCoinIds, idsFiltered = emptySet()), + ), + ReplaceUnsupportedCurrenciesModel( + currenciesIds = blockchainCoinIds, + expected = ReplacementResult( + idsForRequest = Blockchain.entries + .filterNot(Blockchain::isTestnet) + .mapTo(hashSetOf()) { + if (it.isL2EthereumNetwork()) { + Blockchain.Ethereum.toCoinId() + } else { + it.toCoinId() + } + }, + idsFiltered = l2BlockchainsCoinIds.toSet(), + ), + ), + ) + } + + data class ReplaceUnsupportedCurrenciesModel( + val currenciesIds: Set, + val expected: ReplacementResult, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetResponseWithUnsupportedCurrencies { + + private val zeroQuote = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ZERO) + private val ethQuote = "ethereum" to zeroQuote + + @ParameterizedTest + @ProvideTestModels + fun getResponseWithUnsupportedCurrencies(model: GetResponseWithUnsupportedCurrenciesModel) { + // Act + val actual = QuotesUnsupportedCurrenciesIdAdapter.getResponseWithUnsupportedCurrencies( + response = model.response, + filteredIds = model.filteredIds, + ) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + GetResponseWithUnsupportedCurrenciesModel( + response = QuotesResponse(quotes = emptyMap()), + filteredIds = emptySet(), + expected = QuotesResponse(quotes = emptyMap()), + ), + GetResponseWithUnsupportedCurrenciesModel( + response = createQuotesResponse(ethQuote), + filteredIds = emptySet(), + expected = createQuotesResponse(ethQuote), + ), + GetResponseWithUnsupportedCurrenciesModel( + response = createQuotesResponse( + "arbitrum-one" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE), + ethQuote, + ), + filteredIds = blockchainCoinIds, + expected = createQuotesResponse( + *l2BlockchainsCoinIds.map { it to zeroQuote }.toTypedArray(), + "arbitrum-one" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE), + ethQuote, + ), + ), + ) + + private fun createQuotesResponse(vararg quotes: Pair): QuotesResponse { + return QuotesResponse(quotes = quotes.toMap()) + } + } + + data class GetResponseWithUnsupportedCurrenciesModel( + val response: QuotesResponse, + val filteredIds: Set, + val expected: QuotesResponse, + ) + + private companion object { + + val blockchainCoinIds = Blockchain.entries + .filterNot(Blockchain::isTestnet) + .mapTo(destination = hashSetOf(), transform = Blockchain::toCoinId) + + val notL2BlockchainsCoinIds = Blockchain.entries + .filterNot { it.isTestnet() || it.isL2EthereumNetwork() } + .mapTo(hashSetOf()) { it.toCoinId() } + } +} \ No newline at end of file diff --git a/features/fee-selector/impl/.gitignore b/data/swap/.gitignore similarity index 100% rename from features/fee-selector/impl/.gitignore rename to data/swap/.gitignore diff --git a/data/swap/build.gradle.kts b/data/swap/build.gradle.kts new file mode 100644 index 0000000000..26eb11f40e --- /dev/null +++ b/data/swap/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + alias(deps.plugins.ksp) + id("configuration") +} + +android { + namespace = "com.tangem.data.swap" +} + +dependencies { + /** Core */ + implementation(projects.core.datasource) + implementation(projects.core.utils) + + /** Data */ + implementation(projects.data.common) + implementation(projects.data.express) + + /** Domain */ + implementation(projects.domain.express.models) + implementation(projects.domain.express) + implementation(projects.domain.swap.models) + implementation(projects.domain.swap) + implementation(projects.domain.wallets.models) + implementation(projects.domain.wallets) + implementation(projects.domain.tokens.models) + implementation(projects.domain.tokens) + implementation(projects.domain.legacy) + implementation(projects.domain.models) + + /** Tangem SDK */ + implementation(tangemDeps.blockchain) { + exclude(module = "joda-time") + } + + /** Libs */ + implementation(projects.libs.blockchainSdk) + + /** Other */ + implementation(deps.androidx.datastore) + implementation(deps.jodatime) + implementation(deps.kotlin.coroutines) + implementation(deps.moshi) + implementation(deps.moshi.kotlin) + implementation(deps.timber) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapErrorResolver.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapErrorResolver.kt new file mode 100644 index 0000000000..8827a8bc7c --- /dev/null +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapErrorResolver.kt @@ -0,0 +1,19 @@ +package com.tangem.data.swap + +import com.tangem.data.express.converter.ExpressErrorConverter +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.swap.SwapErrorResolver + +internal class DefaultSwapErrorResolver( + private val expressErrorConverter: ExpressErrorConverter, +) : SwapErrorResolver { + override fun resolve(throwable: Throwable): ExpressError { + return when (throwable) { + is ApiResponseError.HttpException -> { + expressErrorConverter.convert(throwable.errorBody.orEmpty()) + } + else -> ExpressError.UnknownError + } + } +} \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt new file mode 100644 index 0000000000..9adca6af89 --- /dev/null +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt @@ -0,0 +1,238 @@ +package com.tangem.data.swap + +import com.tangem.data.common.api.safeApiCall +import com.tangem.data.swap.converter.TokenInfoConverter +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.express.TangemExpressApi +import com.tangem.datasource.api.express.models.request.PairsRequestBody +import com.tangem.datasource.exchangeservice.swap.ExpressUtils +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.express.ExpressRepository +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.SwapRepositoryV2 +import com.tangem.domain.swap.models.SwapPairModel +import com.tangem.domain.swap.models.SwapQuoteModel +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.math.BigDecimal +import javax.inject.Inject + +@Suppress("LongParameterList") +internal class DefaultSwapRepositoryV2 @Inject constructor( + private val tangemExpressApi: TangemExpressApi, + private val expressRepository: ExpressRepository, + private val coroutineDispatcher: CoroutineDispatcherProvider, + private val appPreferencesStore: AppPreferencesStore, + private val currencyStatusOperations: BaseCurrencyStatusOperations, +) : SwapRepositoryV2 { + + private val tokenInfoConverter = TokenInfoConverter() + + override suspend fun getPairs( + userWallet: UserWallet, + initialCurrency: CryptoCurrency, + cryptoCurrencyStatusList: List, + ): List = withContext(coroutineDispatcher.io) { + val cryptoCurrencyList = cryptoCurrencyStatusList.map { it.currency } + + val allPairs = getPairsInternal( + userWallet = userWallet, + initialCurrency = initialCurrency, + cryptoCurrencyList = cryptoCurrencyList, + ) + + val providers = expressRepository.getProviders(userWallet = userWallet) + val mappedProviders = providers.associateBy(ExpressProvider::providerId) + + allPairs.map { pair -> + async { + val statusFrom = cryptoCurrencyStatusList + .firstOrNull { + it.currency.getContractAddress() == pair.from.contractAddress && + it.currency.network.backendId == pair.from.network + } + val statusTo = cryptoCurrencyStatusList + .firstOrNull { + it.currency.getContractAddress() == pair.to.contractAddress && + it.currency.network.backendId == pair.to.network + } + + if (statusFrom != null && statusTo != null) { + SwapPairModel( + from = statusFrom, + to = statusTo, + providers = pair.providers.mapNotNull { + mappedProviders[it.providerId] + }, + ) + } else { + null + } + } + }.awaitAll().filterNotNull() + } + + override suspend fun getPairsOnly( + userWallet: UserWallet, + initialCurrency: CryptoCurrency, + cryptoCurrencyList: List, + ): List = withContext(coroutineDispatcher.io) { + val allPairs = getPairsInternal( + userWallet = userWallet, + initialCurrency = initialCurrency, + cryptoCurrencyList = cryptoCurrencyList, + ) + + allPairs.map { pair -> + async { + val statusFromDeferred = async { + cryptoCurrencyList + .firstOrNull { + it.getContractAddress() == pair.from.contractAddress && + it.network.backendId == pair.from.network + } + } + val statusToDeferred = async { + cryptoCurrencyList + .firstOrNull { + it.getContractAddress() == pair.to.contractAddress && + it.network.backendId == pair.to.network + } + } + + createPairModelOnly( + currencyFrom = statusFromDeferred.await(), + currencyTo = statusToDeferred.await(), + userWalletId = userWallet.walletId, + ) + } + }.awaitAll().filterNotNull() + } + + override suspend fun getSwapQuote( + userWallet: UserWallet, + fromCryptoCurrency: CryptoCurrency, + toCryptoCurrency: CryptoCurrency, + fromAmount: BigDecimal, + provider: ExpressProvider, + rateType: ExpressRateType, + ): SwapQuoteModel = withContext(coroutineDispatcher.io) { + val response = tangemExpressApi.getExchangeQuote( + fromAmount = fromAmount.movePointRight(fromCryptoCurrency.decimals).toString(), + fromNetwork = fromCryptoCurrency.network.backendId, + fromContractAddress = fromCryptoCurrency.getContractAddress(), + fromDecimals = fromCryptoCurrency.decimals, + toNetwork = toCryptoCurrency.network.backendId, + toContractAddress = toCryptoCurrency.getContractAddress(), + toDecimals = toCryptoCurrency.decimals, + providerId = provider.providerId, + rateType = rateType.name.lowercase(), + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + ).getOrThrow() + + val toTokenAmount = requireNotNull(response.toAmount.toBigDecimalOrNull()?.movePointLeft(response.toDecimals)) + + return@withContext SwapQuoteModel( + provider = provider, + toTokenAmount = toTokenAmount, + allowanceContract = response.allowanceContract, + ) + } + + private suspend fun CoroutineScope.getPairsInternal( + userWallet: UserWallet, + initialCurrency: CryptoCurrency, + cryptoCurrencyList: List, + ) = awaitAll( + // original pairs + async { + invokePairRequest( + userWallet = userWallet, + from = arrayListOf(initialCurrency), + to = cryptoCurrencyList, + ) + }, + // reversed pairs + async { + invokePairRequest( + userWallet = userWallet, + from = cryptoCurrencyList, + to = arrayListOf(initialCurrency), + ) + }, + ).flatten() + + private suspend fun invokePairRequest( + userWallet: UserWallet, + from: List, + to: List, + ) = safeApiCall( + call = { + tangemExpressApi.getPairs( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + body = PairsRequestBody( + from = tokenInfoConverter.convertList(from), + to = tokenInfoConverter.convertList(to), + ), + ).getOrThrow() + }, + onError = { + Timber.w(it, "Unable to get pairs") + throw it + }, + ) + + private suspend fun CoroutineScope.createPairModelOnly( + currencyFrom: CryptoCurrency?, + currencyTo: CryptoCurrency?, + userWalletId: UserWalletId, + ): SwapPairModel? { + return if (currencyFrom != null && currencyTo != null) { + val statusFrom = currencyStatusOperations.getCurrencyStatusSync( + userWalletId = userWalletId, + cryptoCurrencyId = currencyFrom.id, + ).getOrNull() + val statusTo = currencyStatusOperations.getCurrencyStatusSync( + userWalletId = userWalletId, + cryptoCurrencyId = currencyTo.id, + ).getOrNull() + + if (statusFrom != null && statusTo != null) { + SwapPairModel( + from = statusFrom, + to = statusTo, + providers = emptyList(), + ) + } else { + null + } + } else { + null + } + } + + private fun CryptoCurrency.getContractAddress(): String { + return when (this) { + is CryptoCurrency.Token -> this.contractAddress + is CryptoCurrency.Coin -> "0" + } + } +} \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt new file mode 100644 index 0000000000..5329db524a --- /dev/null +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt @@ -0,0 +1,289 @@ +package com.tangem.data.swap + +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.data.swap.converter.transaction.SavedSwapStatusConverter +import com.tangem.data.swap.converter.transaction.SavedSwapTransactionConverter +import com.tangem.data.swap.converter.transaction.SavedSwapTransactionListConverter +import com.tangem.data.swap.models.LastSwappedCryptoCurrencyDTO +import com.tangem.data.swap.models.SwapStatusDTO +import com.tangem.data.swap.models.SwapTransactionDTO +import com.tangem.data.swap.models.SwapTransactionListDTO +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObjectList +import com.tangem.datasource.local.preferences.utils.getObjectListSync +import com.tangem.datasource.local.preferences.utils.getObjectMapSync +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.SwapTransactionRepository +import com.tangem.domain.swap.models.SwapStatusModel +import com.tangem.domain.swap.models.SwapTransactionListModel +import com.tangem.domain.swap.models.SwapTransactionModel +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.requireColdWallet +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.addOrReplace +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +internal class DefaultSwapTransactionRepository( + private val appPreferencesStore: AppPreferencesStore, + private val dispatchers: CoroutineDispatcherProvider, + excludedBlockchains: ExcludedBlockchains, +) : SwapTransactionRepository { + + private val listConverter by lazy(LazyThreadSafetyMode.NONE) { + SavedSwapTransactionListConverter(excludedBlockchains) + } + private val converter by lazy(LazyThreadSafetyMode.NONE) { + SavedSwapTransactionConverter(excludedBlockchains) + } + private val savedStatusConverter by lazy(LazyThreadSafetyMode.NONE) { + SavedSwapStatusConverter() + } + private val userTokensResponseFactory = UserTokensResponseFactory() + + override suspend fun storeTransaction( + userWalletId: UserWalletId, + fromCryptoCurrency: CryptoCurrency, + toCryptoCurrency: CryptoCurrency, + transaction: SwapTransactionModel, + ) { + transaction.status?.let { + storeTransactionState( + txId = transaction.txId, + status = it, + refundTokenCurrency = null, + ) + } + appPreferencesStore.editData { mutablePreferences -> + val savedTransactions: List? = mutablePreferences.getObjectList( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + ) + val tokenTransactions = savedTransactions + ?.firstOrNull { + it.checkId( + checkUserWalletId = userWalletId, + fromCurrencyId = fromCryptoCurrency.id, + toCurrencyId = toCryptoCurrency.id, + ) + } + ?.transactions + ?.addOrReplace( + item = converter.convert(transaction), + predicate = { it.txId == transaction.txId }, + ) ?: listOf(converter.convert(transaction)) + + mutablePreferences.setObject( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + value = savedTransactions?.updateList( + userWalletId = userWalletId, + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + transactions = tokenTransactions, + ) ?: listOf( + listConverter.default( + userWalletId = userWalletId, + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + tokenTransactions = listOf(converter.convert(transaction)), + ), + ), + ) + } + } + + override suspend fun getTransactions( + userWallet: UserWallet, + cryptoCurrencyId: CryptoCurrency.ID, + ): Flow?> { + return withContext(dispatchers.io) { + val txStatuses = appPreferencesStore.getObjectMapSync( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + ) + appPreferencesStore.getObjectList( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + ).map { savedTransactions -> + val currencyTxs = savedTransactions + ?.filter { + it.userWalletId == userWallet.walletId.stringValue && + ( + it.toCryptoCurrencyId == cryptoCurrencyId.value || + it.fromCryptoCurrencyId == cryptoCurrencyId.value + ) + } + + currencyTxs?.mapNotNull { + listConverter.convertBack( + value = it, + scanResponse = userWallet.requireColdWallet().scanResponse, // TODO [REDACTED_TASK_KEY] + txStatuses = txStatuses, + ) + } + }.flowOn(dispatchers.io) + } + } + + override suspend fun removeTransaction( + userWalletId: UserWalletId, + fromCryptoCurrency: CryptoCurrency, + toCryptoCurrency: CryptoCurrency, + txId: String, + ) { + clearTransactionsStatuses(txId = txId) + appPreferencesStore.editData { mutablePreferences -> + val savedList: List? = mutablePreferences.getObjectList( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + ) + val tokenTransactions = savedList + ?.firstOrNull { + it.checkId( + checkUserWalletId = userWalletId, + fromCurrencyId = fromCryptoCurrency.id, + toCurrencyId = toCryptoCurrency.id, + ) + } + ?.transactions + ?.filterNot { it.txId == txId } + + val editedList = + if (tokenTransactions.isNullOrEmpty()) { + savedList?.filterNot { + it.checkId( + checkUserWalletId = userWalletId, + fromCurrencyId = fromCryptoCurrency.id, + toCurrencyId = toCryptoCurrency.id, + ) + } + } else { + savedList.updateList( + userWalletId = userWalletId, + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + transactions = tokenTransactions, + ) + } + + if (editedList.isNullOrEmpty()) { + mutablePreferences.remove(key = PreferencesKeys.SWAP_TRANSACTIONS_KEY) + } else { + mutablePreferences.setObject( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + value = editedList, + ) + } + } + } + + override suspend fun storeTransactionState( + txId: String, + status: SwapStatusModel, + refundTokenCurrency: CryptoCurrency?, + ) { + appPreferencesStore.editData { mutablePreferences -> + val savedMap = mutablePreferences.getObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + ) + + val updatesMap = savedMap.toMutableMap() + updatesMap[txId] = savedStatusConverter.convertBack( + status.copy( + refundTokensResponse = refundTokenCurrency?.let { + userTokensResponseFactory.createResponseToken(refundTokenCurrency) + }, + ), + ) + + mutablePreferences.setObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + value = updatesMap, + ) + } + } + + override suspend fun getLastSwappedCryptoCurrencyId(userWalletId: UserWalletId): String? { + val lastSwappedCurrencies = appPreferencesStore.getObjectListSync( + key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY, + ) + + return lastSwappedCurrencies.find { userWalletId.stringValue == it.userWalletId }?.cryptoCurrencyId + } + + override suspend fun storeLastSwappedCryptoCurrencyId( + userWalletId: UserWalletId, + cryptoCurrencyId: CryptoCurrency.ID, + ) { + appPreferencesStore.editData { mutablePreferences -> + val lastSwappedCryptoCurrencies: List? = mutablePreferences.getObjectList( + key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY, + ) + + val newList = if (lastSwappedCryptoCurrencies != null) { + lastSwappedCryptoCurrencies.filter { + it.userWalletId != userWalletId.stringValue + } + LastSwappedCryptoCurrencyDTO(userWalletId.stringValue, cryptoCurrencyId.value) + } else { + listOf(LastSwappedCryptoCurrencyDTO(userWalletId.stringValue, cryptoCurrencyId.value)) + } + + mutablePreferences.setObjectList( + key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY, + value = newList, + ) + } + } + + private fun SwapTransactionListDTO.checkId( + checkUserWalletId: UserWalletId, + fromCurrencyId: CryptoCurrency.ID, + toCurrencyId: CryptoCurrency.ID, + ): Boolean { + return userWalletId == checkUserWalletId.stringValue && + toCryptoCurrencyId == toCurrencyId.value && + fromCryptoCurrencyId == fromCurrencyId.value + } + + private fun List.updateList( + userWalletId: UserWalletId, + fromCryptoCurrency: CryptoCurrency, + toCryptoCurrency: CryptoCurrency, + transactions: List, + ): List { + return addOrReplace( + item = listConverter.default( + userWalletId = userWalletId, + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + tokenTransactions = transactions, + ), + predicate = { + it.checkId( + checkUserWalletId = userWalletId, + fromCurrencyId = fromCryptoCurrency.id, + toCurrencyId = toCryptoCurrency.id, + ) + }, + ) + } + + private suspend fun clearTransactionsStatuses(txId: String) { + appPreferencesStore.editData { mutablePreferences -> + val savedList = mutablePreferences.getObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + ) + val editedList = savedList.filterNot { it.key == txId } + + if (editedList.isEmpty()) { + mutablePreferences.remove(key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY) + } else { + mutablePreferences.setObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + value = editedList, + ) + } + } + } +} \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/SwapStatusConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/SwapStatusConverter.kt new file mode 100644 index 0000000000..33ae4fa064 --- /dev/null +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/SwapStatusConverter.kt @@ -0,0 +1,24 @@ +package com.tangem.data.swap.converter + +import com.tangem.datasource.api.express.models.response.ExchangeStatusResponse +import com.tangem.domain.swap.models.SwapStatus +import com.tangem.domain.swap.models.SwapStatusModel +import com.tangem.utils.converter.Converter + +internal class SwapStatusConverter : Converter { + override fun convert(value: ExchangeStatusResponse): SwapStatusModel { + return SwapStatusModel( + providerId = value.providerId, + status = SwapStatus.entries.firstOrNull { + it.name.lowercase() == value.status.name.lowercase() + }, + txId = value.externalTxId, + txExternalUrl = value.externalTxUrl, + txExternalId = value.externalTxId, + refundNetwork = value.refundNetwork, + refundContractAddress = value.refundContractAddress, + createdAt = value.createdAt, + averageDuration = value.averageDuration, + ) + } +} \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/TokenInfoConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/TokenInfoConverter.kt new file mode 100644 index 0000000000..04e3203f28 --- /dev/null +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/TokenInfoConverter.kt @@ -0,0 +1,23 @@ +package com.tangem.data.swap.converter + +import com.tangem.datasource.api.express.models.request.LeastTokenInfo +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.TokenInfo +import com.tangem.utils.converter.Converter + +class TokenInfoConverter : Converter { + + override fun convert(value: CryptoCurrency): LeastTokenInfo { + return LeastTokenInfo( + contractAddress = (value as? CryptoCurrency.Token)?.contractAddress ?: "0", + network = value.network.backendId, + ) + } + + fun convert(value: TokenInfo): LeastTokenInfo { + return LeastTokenInfo( + contractAddress = value.contractAddress, + network = value.network, + ) + } +} \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapStatusConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapStatusConverter.kt new file mode 100644 index 0000000000..c3d8f36903 --- /dev/null +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapStatusConverter.kt @@ -0,0 +1,38 @@ +package com.tangem.data.swap.converter.transaction + +import com.tangem.data.swap.models.SavedSwapStatus +import com.tangem.data.swap.models.SwapStatusDTO +import com.tangem.domain.swap.models.SwapStatus +import com.tangem.domain.swap.models.SwapStatusModel +import com.tangem.utils.converter.TwoWayConverter + +internal class SavedSwapStatusConverter : TwoWayConverter { + + override fun convert(value: SwapStatusDTO) = SwapStatusModel( + providerId = value.providerId, + status = SwapStatus.entries.firstOrNull { + it.name.lowercase() == value.status?.name?.lowercase() + }, + txId = value.txExternalId, + txExternalUrl = value.txExternalUrl, + txExternalId = value.txExternalId, + refundNetwork = value.refundNetwork, + refundContractAddress = value.refundContractAddress, + createdAt = value.createdAt, + averageDuration = value.averageDuration, + ) + + override fun convertBack(value: SwapStatusModel) = SwapStatusDTO( + providerId = value.providerId, + status = SavedSwapStatus.entries.firstOrNull { + it.name.lowercase() == value.status?.name?.lowercase() + }, + txId = value.txExternalId, + txExternalUrl = value.txExternalUrl, + txExternalId = value.txExternalId, + refundNetwork = value.refundNetwork, + refundContractAddress = value.refundContractAddress, + createdAt = value.createdAt, + averageDuration = value.averageDuration, + ) +} \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionConverter.kt new file mode 100644 index 0000000000..ab7b21e61d --- /dev/null +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionConverter.kt @@ -0,0 +1,60 @@ +package com.tangem.data.swap.converter.transaction + +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.swap.models.SwapStatusDTO +import com.tangem.data.swap.models.SwapTransactionDTO +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.swap.models.SwapTransactionModel +import com.tangem.utils.converter.TwoWayConverter + +internal class SavedSwapTransactionConverter( + excludedBlockchains: ExcludedBlockchains, +) : TwoWayConverter { + private val responseCryptoCurrenciesFactory = ResponseCryptoCurrenciesFactory(excludedBlockchains) + private val statusConverter by lazy(LazyThreadSafetyMode.NONE) { + SavedSwapStatusConverter() + } + + override fun convert(value: SwapTransactionModel) = SwapTransactionDTO( + txId = value.txId, + timestamp = value.timestamp, + fromCryptoAmount = value.fromCryptoAmount, + toCryptoAmount = value.toCryptoAmount, + provider = value.provider, + status = value.status, + ) + + override fun convertBack(value: SwapTransactionDTO) = SwapTransactionModel( + txId = value.txId, + timestamp = value.timestamp, + fromCryptoAmount = value.fromCryptoAmount, + toCryptoAmount = value.toCryptoAmount, + provider = value.provider, + status = value.status, + ) + + fun convertBack( + value: SwapTransactionDTO, + scanResponse: ScanResponse, + txStatuses: Map, + ): SwapTransactionModel { + val status = txStatuses[value.txId] + val refundCurrency = status?.refundTokensResponse?.let { id -> + responseCryptoCurrenciesFactory.createCurrency( + responseToken = id, + scanResponse = scanResponse, + ) + } + val statusWithRefundCurrency = status?.copy(refundCurrency = refundCurrency) + + return SwapTransactionModel( + txId = value.txId, + timestamp = value.timestamp, + fromCryptoAmount = value.fromCryptoAmount, + toCryptoAmount = value.toCryptoAmount, + provider = value.provider, + status = statusWithRefundCurrency?.let(statusConverter::convert), + ) + } +} \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt new file mode 100644 index 0000000000..176c1eb8f6 --- /dev/null +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt @@ -0,0 +1,83 @@ +package com.tangem.data.swap.converter.transaction + +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.data.swap.models.SwapStatusDTO +import com.tangem.data.swap.models.SwapTransactionDTO +import com.tangem.data.swap.models.SwapTransactionListDTO +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.swap.models.SwapTransactionListModel +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.converter.Converter + +internal class SavedSwapTransactionListConverter( + excludedBlockchains: ExcludedBlockchains, +) : Converter { + + private val responseCryptoCurrenciesFactory = ResponseCryptoCurrenciesFactory(excludedBlockchains) + private val userTokensResponseFactory = UserTokensResponseFactory() + private val savedSwapTransactionConverter by lazy(LazyThreadSafetyMode.NONE) { + SavedSwapTransactionConverter(excludedBlockchains) + } + + override fun convert(value: SwapTransactionListModel) = SwapTransactionListDTO( + userWalletId = value.userWalletId, + fromCryptoCurrencyId = value.fromCryptoCurrencyId, + toCryptoCurrencyId = value.toCryptoCurrencyId, + fromTokensResponse = userTokensResponseFactory.createResponseToken( + value.fromCryptoCurrency, + ), + toTokensResponse = userTokensResponseFactory.createResponseToken( + value.toCryptoCurrency, + ), + transactions = savedSwapTransactionConverter.convertList(value.transactions), + ) + + fun convertBack( + value: SwapTransactionListDTO, + scanResponse: ScanResponse, + txStatuses: Map, + ): SwapTransactionListModel? { + val fromToken = value.fromTokensResponse + val toToken = value.toTokensResponse + return if (fromToken == null || toToken == null) { + null + } else { + val fromCryptoCurrency = responseCryptoCurrenciesFactory.createCurrency( + responseToken = fromToken, + scanResponse = scanResponse, + ) ?: return null + val toCryptoCurrency = responseCryptoCurrenciesFactory.createCurrency( + responseToken = toToken, + scanResponse = scanResponse, + ) ?: return null + + return SwapTransactionListModel( + transactions = value.transactions.map { tx -> + savedSwapTransactionConverter.convertBack(tx, scanResponse, txStatuses) + }, + userWalletId = value.userWalletId, + fromCryptoCurrencyId = value.fromCryptoCurrencyId, + toCryptoCurrencyId = value.toCryptoCurrencyId, + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + ) + } + } + + fun default( + userWalletId: UserWalletId, + fromCryptoCurrency: CryptoCurrency, + toCryptoCurrency: CryptoCurrency, + tokenTransactions: List, + ) = SwapTransactionListDTO( + userWalletId = userWalletId.stringValue, + fromCryptoCurrencyId = fromCryptoCurrency.id.value, + toCryptoCurrencyId = toCryptoCurrency.id.value, + fromTokensResponse = userTokensResponseFactory.createResponseToken(fromCryptoCurrency), + toTokensResponse = userTokensResponseFactory.createResponseToken(toCryptoCurrency), + transactions = tokenTransactions, + ) +} \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/di/SwapDataModule.kt b/data/swap/src/main/java/com/tangem/data/swap/di/SwapDataModule.kt new file mode 100644 index 0000000000..bd75b9d1ad --- /dev/null +++ b/data/swap/src/main/java/com/tangem/data/swap/di/SwapDataModule.kt @@ -0,0 +1,69 @@ +package com.tangem.data.swap.di + +import com.squareup.moshi.Moshi +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.express.converter.ExpressErrorConverter +import com.tangem.data.swap.DefaultSwapErrorResolver +import com.tangem.data.swap.DefaultSwapRepositoryV2 +import com.tangem.data.swap.DefaultSwapTransactionRepository +import com.tangem.datasource.api.express.TangemExpressApi +import com.tangem.datasource.api.express.models.response.ExpressErrorResponse +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.express.ExpressRepository +import com.tangem.domain.swap.SwapErrorResolver +import com.tangem.domain.swap.SwapRepositoryV2 +import com.tangem.domain.swap.SwapTransactionRepository +import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations +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 + +@InstallIn(SingletonComponent::class) +@Module +internal object SwapDataModule { + + @Provides + @Singleton + fun provideSwapErrorResolver(@NetworkMoshi moshi: Moshi): SwapErrorResolver { + val jsonAdapter = moshi.adapter(ExpressErrorResponse::class.java) + return DefaultSwapErrorResolver( + ExpressErrorConverter(jsonAdapter), + ) + } + + @Provides + @Singleton + fun provideSwapRepository( + tangemExpressApi: TangemExpressApi, + expressRepository: ExpressRepository, + coroutineDispatcher: CoroutineDispatcherProvider, + appPreferencesStore: AppPreferencesStore, + currencyStatusOperations: BaseCurrencyStatusOperations, + ): SwapRepositoryV2 { + return DefaultSwapRepositoryV2( + tangemExpressApi = tangemExpressApi, + expressRepository = expressRepository, + coroutineDispatcher = coroutineDispatcher, + appPreferencesStore = appPreferencesStore, + currencyStatusOperations = currencyStatusOperations, + ) + } + + @Provides + @Singleton + fun provideSwapTransactionRepository( + coroutineDispatcher: CoroutineDispatcherProvider, + appPreferencesStore: AppPreferencesStore, + excludedBlockchains: ExcludedBlockchains, + ): SwapTransactionRepository { + return DefaultSwapTransactionRepository( + appPreferencesStore = appPreferencesStore, + dispatchers = coroutineDispatcher, + excludedBlockchains = excludedBlockchains, + ) + } +} \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/models/LastSwappedCryptoCurrencyDTO.kt b/data/swap/src/main/java/com/tangem/data/swap/models/LastSwappedCryptoCurrencyDTO.kt new file mode 100644 index 0000000000..61d738acd0 --- /dev/null +++ b/data/swap/src/main/java/com/tangem/data/swap/models/LastSwappedCryptoCurrencyDTO.kt @@ -0,0 +1,12 @@ +package com.tangem.data.swap.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class LastSwappedCryptoCurrencyDTO( + @Json(name = "userWalletId") + val userWalletId: String, + @Json(name = "cryptoCurrencyId") + val cryptoCurrencyId: String, +) \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/models/SwapStatusDTO.kt b/data/swap/src/main/java/com/tangem/data/swap/models/SwapStatusDTO.kt new file mode 100644 index 0000000000..45c37a2005 --- /dev/null +++ b/data/swap/src/main/java/com/tangem/data/swap/models/SwapStatusDTO.kt @@ -0,0 +1,78 @@ +package com.tangem.data.swap.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.currency.CryptoCurrency +import org.joda.time.DateTime + +@JsonClass(generateAdapter = true) +internal data class SwapStatusDTO( + @Json(name = "providerId") + val providerId: String, + @Json(name = "status") + val status: SavedSwapStatus? = null, + @Json(name = "txId") + val txId: String? = null, + @Json(name = "txExternalUrl") + val txExternalUrl: String? = null, + @Json(name = "txExternalId") + val txExternalId: String? = null, + @Json(name = "refundNetwork") + val refundNetwork: String? = null, + @Json(name = "refundContractAddress") + val refundContractAddress: String? = null, + @Json(name = "refundTokensResponse") + val refundTokensResponse: UserTokensResponse.Token? = null, + @Json(ignore = true) + val refundCurrency: CryptoCurrency? = null, + @Json(name = "createdAt") + val createdAt: DateTime? = null, + @Json(name = "averageDuration") + val averageDuration: Int? = null, +) + +@JsonClass(generateAdapter = false) +internal enum class SavedSwapStatus { + @Json(name = "New") + New, + + @Json(name = "Waiting") + Waiting, + + @Json(name = "WaitingTxHash") + WaitingTxHash, + + @Json(name = "Confirming") + Confirming, + + @Json(name = "Verifying") + Verifying, + + @Json(name = "Exchanging") + Exchanging, + + @Json(name = "Failed") + Failed, + + @Json(name = "Sending") + Sending, + + @Json(name = "Finished") + Finished, + + @Json(name = "Refunded") + Refunded, + + @Json(name = "Cancelled") + Cancelled, + + @Json(name = "TxFailed") + TxFailed, + + @Json(name = "Unknown") + Unknown, + + @Json(name = "Paused") + Paused, +} \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt b/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt new file mode 100644 index 0000000000..e5249da91f --- /dev/null +++ b/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt @@ -0,0 +1,40 @@ +package com.tangem.data.swap.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.swap.models.SwapStatusModel +import java.math.BigDecimal + +@JsonClass(generateAdapter = true) +internal data class SwapTransactionListDTO( + @Json(name = "userWalletId") + val userWalletId: String, + @Json(name = "fromCryptoCurrencyId") + val fromCryptoCurrencyId: String, + @Json(name = "toCryptoCurrencyId") + val toCryptoCurrencyId: String, + @Json(name = "fromTokensResponse") + val fromTokensResponse: UserTokensResponse.Token? = null, + @Json(name = "toTokensResponse") + val toTokensResponse: UserTokensResponse.Token? = null, + @Json(name = "transactions") + val transactions: List, +) + +@JsonClass(generateAdapter = true) +internal data class SwapTransactionDTO( + @Json(name = "txId") + val txId: String, + @Json(name = "timestamp") + val timestamp: Long, + @Json(name = "fromCryptoAmount") + val fromCryptoAmount: BigDecimal, + @Json(name = "toCryptoAmount") + val toCryptoAmount: BigDecimal, + @Json(name = "provider") + val provider: ExpressProvider, + @Json(name = "status") + val status: SwapStatusModel? = null, +) \ 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 d1de414bbc..30d55b6061 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 @@ -83,6 +83,7 @@ internal class DefaultTransactionRepository( destination: String, userWalletId: UserWalletId, network: Network, + nonce: BigInteger?, ): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) { val blockchain = network.toBlockchain() val walletManager = walletManagersFacade.getOrCreateWalletManager( @@ -101,7 +102,7 @@ internal class DefaultTransactionRepository( createTransactionDataExtras( callData = callData, network = network, - nonce = null, + nonce = nonce, gasLimit = null, ) } else { diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index f8999494d2..6740dd9dde 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { /** Project - Data */ implementation(projects.core.datasource) + implementation(projects.core.error) implementation(projects.data.common) /** Project - Domain */ diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultKycRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultKycRepository.kt new file mode 100644 index 0000000000..8df8da505d --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultKycRepository.kt @@ -0,0 +1,70 @@ +package com.tangem.data.pay + +import arrow.core.Either +import com.squareup.moshi.Moshi +import com.tangem.core.error.UniversalError +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.domain.pay.KycStartInfo +import com.tangem.domain.pay.repository.KycRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.withContext + +@Suppress("UnusedPrivateMember") +class DefaultKycRepository @AssistedInject constructor( + @Assisted userWalletId: UserWalletId, + @NetworkMoshi moshi: Moshi, + private val tangemPayApi: TangemPayApi, + private val dispatcherProvider: CoroutineDispatcherProvider, +) : KycRepository { + + private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi) + + override suspend fun getKycStartInfo(): Either = withContext(dispatcherProvider.io) { + val authTokenForSpecificWallet = "get from userWalletId" + + request { + tangemPayApi.getKycAccess( + authHeader = authTokenForSpecificWallet, + ).getOrThrow().result + }.map { + KycStartInfo( + token = it.token, + locale = it.locale, + ) + } + } + + private suspend fun request(requestBlock: suspend () -> T): Either { + return runCatching { + Either.Right(requestBlock()) + }.getOrElse { responseError -> + if (responseError is ApiResponseError.HttpException && + responseError.errorBody != null + ) { + return runCatching { + visaErrorAdapter.fromJson(responseError.errorBody!!)?.error?.code ?: responseError.code.numericCode + }.map { + Either.Left(VisaApiError.fromBackendError(it)) + }.getOrElse { + Either.Left(VisaApiError.UnknownWithoutCode) + } + } + + return Either.Left(VisaApiError.UnknownWithoutCode) + } + } + + @AssistedFactory + interface Factory : KycRepository.Factory { + override fun create(userWalletId: UserWalletId): DefaultKycRepository + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt new file mode 100644 index 0000000000..d0de7d5c31 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -0,0 +1,18 @@ +package com.tangem.data.pay.di + +import com.tangem.data.pay.DefaultKycRepository +import com.tangem.domain.pay.repository.KycRepository +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 TangemPayDataModule { + + @Binds + @Singleton + fun bindKycRepositoryFactory(factory: DefaultKycRepository.Factory): KycRepository.Factory +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaActivationRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaActivationRepository.kt index 40c5d49fe6..8d10a750ff 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaActivationRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaActivationRepository.kt @@ -10,9 +10,9 @@ import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow -import com.tangem.datasource.api.visa.TangemVisaApi -import com.tangem.datasource.api.visa.models.request.* -import com.tangem.datasource.api.visa.models.response.VisaErrorResponseJsonAdapter +import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.models.request.* +import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.domain.visa.error.VisaApiError @@ -29,7 +29,7 @@ import kotlinx.coroutines.withContext internal class DefaultVisaActivationRepository @AssistedInject constructor( @Assisted private val visaCardId: VisaCardId, @NetworkMoshi private val moshi: Moshi, - private val visaApi: TangemVisaApi, + private val visaApi: TangemPayApi, private val dispatcherProvider: CoroutineDispatcherProvider, private val visaAuthTokenStorage: VisaAuthTokenStorage, private val visaAuthRepository: VisaAuthRepository, @@ -162,7 +162,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor( } override suspend fun getPinCodeRsaEncryptionPublicKey(): String { - val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemVisa).environment + val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment val rsaPublicKey = visaLibLoader.getOrCreateConfig().rsaPublicKey return when (env) { ApiEnvironment.DEV, diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt index a0b6429f32..c9c07624e5 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt @@ -4,9 +4,9 @@ import arrow.core.Either import com.squareup.moshi.Moshi import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow -import com.tangem.datasource.api.visa.TangemVisaApi -import com.tangem.datasource.api.visa.models.request.* -import com.tangem.datasource.api.visa.models.response.VisaErrorResponseJsonAdapter +import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.models.request.* +import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter import com.tangem.datasource.di.NetworkMoshi import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.model.VisaAuthChallenge @@ -21,7 +21,7 @@ import javax.inject.Inject @Suppress("UnusedPrivateMember") internal class DefaultVisaAuthRepository @Inject constructor( @NetworkMoshi private val moshi: Moshi, - private val visaAuthApi: TangemVisaApi, + private val visaAuthApi: TangemPayApi, private val dispatchers: CoroutineDispatcherProvider, ) : VisaAuthRepository { @@ -47,12 +47,14 @@ internal class DefaultVisaAuthRepository @Inject constructor( } override suspend fun getCardWalletAuthChallenge( + cardId: String, cardWalletAddress: String, ): Either = withContext(dispatchers.io) { request { visaAuthApi.generateNonceByCardWallet( GenerateNoneByCardWalletRequest( cardWalletAddress = cardWalletAddress, + cardId = cardId, ), ).getOrThrow() }.map { response -> @@ -82,6 +84,7 @@ internal class DefaultVisaAuthRepository @Inject constructor( GetAccessTokenByCardWalletRequest( sessionId = signedChallenge.challenge.session.sessionId, signature = signedChallenge.signature, + salt = signedChallenge.salt, ), ).getOrThrow() } diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaRepository.kt index 3e3faade06..55d8eae097 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaRepository.kt @@ -3,6 +3,7 @@ package com.tangem.data.visa import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData +import arrow.core.getOrElse import arrow.fx.coroutines.parZip import com.tangem.blockchain.common.address.Address import com.tangem.blockchain.common.address.AddressType @@ -10,12 +11,11 @@ import com.tangem.common.card.EllipticCurve import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.toHexString import com.tangem.data.common.cache.CacheRegistry +import com.tangem.data.common.quote.QuotesFetcher import com.tangem.data.visa.config.VisaLibLoader import com.tangem.data.visa.utils.* -import com.tangem.datasource.api.common.response.getOrThrow -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.visa.TangemVisaApi -import com.tangem.datasource.api.visa.models.response.VisaTxHistoryResponse +import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.visa.model.VisaCurrency @@ -38,12 +38,12 @@ import javax.inject.Singleton @Singleton internal class DefaultVisaRepository @Inject constructor( private val visaLibLoader: VisaLibLoader, - private val tangemTechApi: TangemTechApi, + private val quotesFetcher: QuotesFetcher, private val cacheRegistry: CacheRegistry, private val userWalletsStore: UserWalletsStore, private val dispatchers: CoroutineDispatcherProvider, private val visaApiRequestMaker: VisaApiRequestMaker, - private val visaApi: TangemVisaApi, + private val visaApi: TangemPayApi, private val visaCurrencyFactory: VisaCurrencyFactory, ) : VisaRepository { @@ -190,10 +190,13 @@ internal class DefaultVisaRepository @Inject constructor( private suspend fun getFiatRate(): BigDecimal { val fiatCurrencyId = VisaConstants.fiatCurrency.code.lowercase() - val quotes = tangemTechApi.getQuotes( - currencyId = fiatCurrencyId, - coinIds = VisaConstants.TOKEN_ID, - ).getOrThrow() + + val quotes = quotesFetcher.fetch( + fiatCurrencyId = fiatCurrencyId, + currencyId = VisaConstants.TOKEN_ID, + field = QuotesFetcher.Field.PRICE, + ) + .getOrElse { error("Cause: $it") } return quotes.quotes[VisaConstants.TOKEN_ID]?.price ?: error("No price found") } diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverterWithState.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverterWithState.kt index cc00430f80..09128b4324 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverterWithState.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverterWithState.kt @@ -1,6 +1,6 @@ package com.tangem.data.visa.converter -import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse +import com.tangem.datasource.api.pay.models.response.CardActivationRemoteStateResponse import com.tangem.domain.visa.model.VisaActivationOrderInfo import com.tangem.domain.visa.model.VisaActivationRemoteState import com.tangem.utils.converter.Converter diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaApiRequestMaker.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaApiRequestMaker.kt index 147f558037..792ad89620 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaApiRequestMaker.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaApiRequestMaker.kt @@ -5,8 +5,8 @@ import com.tangem.data.visa.model.AccessCodeData import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow -import com.tangem.datasource.api.visa.TangemVisaApi -import com.tangem.datasource.api.visa.models.request.RefreshTokenByCardWalletRequest +import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.models.request.RefreshTokenByCardWalletRequest import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.visa.exception.RefreshTokenExpiredException @@ -25,7 +25,7 @@ typealias VisaAuthorizationHeader = String internal class VisaApiRequestMaker @Inject constructor( private val userWalletsStore: UserWalletsStore, - private val visaAuthApi: TangemVisaApi, + private val visaAuthApi: TangemPayApi, private val accessCodeDataConverter: AccessCodeDataConverter, private val dispatcherProvider: CoroutineDispatcherProvider, ) { diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxDetailsFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxDetailsFactory.kt index 8f9b41148f..b33a0cafb9 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxDetailsFactory.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxDetailsFactory.kt @@ -2,7 +2,7 @@ package com.tangem.data.visa.utils import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.externallinkprovider.TxExploreState -import com.tangem.datasource.api.visa.models.response.VisaTxHistoryResponse +import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse import com.tangem.domain.visa.model.VisaTxDetails internal class VisaTxDetailsFactory { diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryItemFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryItemFactory.kt index 75a8c4eefe..d7be726e36 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryItemFactory.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryItemFactory.kt @@ -1,6 +1,6 @@ package com.tangem.data.visa.utils -import com.tangem.datasource.api.visa.models.response.VisaTxHistoryResponse +import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse import com.tangem.domain.visa.model.VisaTxHistoryItem internal class VisaTxHistoryItemFactory { diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryPagingSource.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryPagingSource.kt index 4393d4f372..c37c9f9762 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryPagingSource.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxHistoryPagingSource.kt @@ -3,7 +3,7 @@ package com.tangem.data.visa.utils import androidx.paging.PagingSource import androidx.paging.PagingState import com.tangem.data.common.cache.CacheRegistry -import com.tangem.datasource.api.visa.models.response.VisaTxHistoryResponse +import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse import com.tangem.domain.visa.model.VisaTxHistoryItem import com.tangem.domain.wallets.models.UserWallet import com.tangem.utils.coroutines.CoroutineDispatcherProvider diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt index 2eb8bb69d2..2d585f4c9b 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt @@ -111,9 +111,10 @@ internal object WalletConnectDataModule { @Provides @Singleton - fun defaultWcRequestService(diHelperBox: DiHelperBox): DefaultWcRequestService { + fun defaultWcRequestService(diHelperBox: DiHelperBox, respondService: WcRespondService): DefaultWcRequestService { return DefaultWcRequestService( requestConverters = diHelperBox.handlers, + respondService = respondService, ) } diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt index f5ef109750..4b80dbfbd4 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt @@ -3,7 +3,7 @@ package com.tangem.data.walletconnect.network.ethereum import arrow.core.left import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.blockchains.ethereum.tokenmethods.ApprovalERC20TokenCallData -import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Amount as BlockchainAmount import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.extensions.formatHex @@ -15,7 +15,9 @@ import com.tangem.data.walletconnect.sign.SignStateConverter.toResult import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate import com.tangem.domain.core.lce.LceFlow +import com.tangem.domain.tokens.model.Amount import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.walletconnect.model.WcApprovedAmount import com.tangem.domain.walletconnect.model.WcEthMethod import com.tangem.domain.walletconnect.usecase.method.* import dagger.assisted.Assisted @@ -35,7 +37,7 @@ internal class WcEthSendTransactionUseCase @AssistedInject constructor( WcApproval, WcMutableFee { - private var approvalAmount: Amount? = null + private var approvalAmount: WcApprovedAmount? = null private var dAppFee = WcEthTxHelper.getDAppFee( network = context.network, txParams = method.transaction, @@ -53,13 +55,19 @@ internal class WcEthSendTransactionUseCase @AssistedInject constructor( val amount = WcEthTxHelper.getApprovedAmount(method.transaction.data, result) ?: return@map BlockAidTransactionCheck.Result.Plain(result) val tokenInfo = amount.tokenInfo - if (!amount.isUnlimited) { - this@WcEthSendTransactionUseCase.approvalAmount = Amount( - currencySymbol = tokenInfo.symbol, - decimals = tokenInfo.decimals, - value = amount.approvedAmount, - ) - } + this@WcEthSendTransactionUseCase.approvalAmount = WcApprovedAmount( + amount = if (!amount.isUnlimited) { + Amount( + currencySymbol = tokenInfo.symbol, + decimals = tokenInfo.decimals, + value = amount.approvedAmount, + ) + } else { + null + }, + logoUrl = tokenInfo.logoUrl, + chainId = tokenInfo.chainId, + ) BlockAidTransactionCheck.Result.Approval( result = result, approval = this, @@ -90,7 +98,9 @@ internal class WcEthSendTransactionUseCase @AssistedInject constructor( val extras = uncompiled.extras as EthereumTransactionExtras val callData = ApprovalERC20TokenCallData( spenderAddress = uncompiled.sourceAddress, - amount = action.amount, + amount = action.amount?.amount?.let { + BlockchainAmount(currencySymbol = it.currencySymbol, decimals = it.decimals, value = it.value) + }, ) approvalAmount = action.amount dAppFee = null @@ -102,7 +112,7 @@ internal class WcEthSendTransactionUseCase @AssistedInject constructor( emit(newState) } - override suspend fun dAppFee(): Fee.Ethereum.Legacy? { + override fun dAppFee(): Fee.Ethereum.Legacy? { return dAppFee } @@ -119,11 +129,11 @@ internal class WcEthSendTransactionUseCase @AssistedInject constructor( emitAll(delegate.invoke(transactionData)) } - override suspend fun getAmount(): Amount? { + override fun getAmount(): WcApprovedAmount? { return approvalAmount } - override fun updateAmount(amount: Amount?) { + override fun updateAmount(amount: WcApprovedAmount?) { middleAction(WcEthTxAction.UpdateApprovalAmount(amount)) } diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSignTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSignTransactionUseCase.kt index e372f35b8a..b86988cbae 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSignTransactionUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSignTransactionUseCase.kt @@ -3,7 +3,7 @@ package com.tangem.data.walletconnect.network.ethereum import arrow.core.left import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.blockchains.ethereum.tokenmethods.ApprovalERC20TokenCallData -import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Amount as BlockchainAmount import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.extensions.formatHex @@ -15,13 +15,11 @@ import com.tangem.data.walletconnect.sign.SignCollector import com.tangem.data.walletconnect.sign.SignStateConverter.toResult import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate +import com.tangem.domain.tokens.model.Amount import com.tangem.domain.transaction.usecase.PrepareForSendUseCase +import com.tangem.domain.walletconnect.model.WcApprovedAmount import com.tangem.domain.walletconnect.model.WcEthMethod -import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck -import com.tangem.domain.walletconnect.usecase.method.WcApproval -import com.tangem.domain.walletconnect.usecase.method.WcMutableFee -import com.tangem.domain.walletconnect.usecase.method.WcSignState -import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase +import com.tangem.domain.walletconnect.usecase.method.* import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -43,7 +41,7 @@ internal class WcEthSignTransactionUseCase @AssistedInject constructor( WcApproval, WcMutableFee { - private var approvalAmount: Amount? = null + private var approvalAmount: WcApprovedAmount? = null private var dAppFee = WcEthTxHelper.getDAppFee( network = context.network, txParams = method.transaction, @@ -61,10 +59,14 @@ internal class WcEthSignTransactionUseCase @AssistedInject constructor( ?: return@map BlockAidTransactionCheck.Result.Plain(result) val tokenInfo = amount.tokenInfo if (!amount.isUnlimited) { - approvalAmount = Amount( - currencySymbol = tokenInfo.symbol, - decimals = tokenInfo.decimals, - value = amount.approvedAmount, + approvalAmount = WcApprovedAmount( + amount = Amount( + currencySymbol = tokenInfo.symbol, + decimals = tokenInfo.decimals, + value = amount.approvedAmount, + ), + logoUrl = tokenInfo.logoUrl, + chainId = tokenInfo.chainId, ) } BlockAidTransactionCheck.Result.Approval( @@ -98,7 +100,9 @@ internal class WcEthSignTransactionUseCase @AssistedInject constructor( val extras = uncompiled.extras as EthereumTransactionExtras val callData = ApprovalERC20TokenCallData( spenderAddress = uncompiled.sourceAddress, - amount = action.amount, + amount = action.amount?.amount?.let { + BlockchainAmount(currencySymbol = it.currencySymbol, decimals = it.decimals, value = it.value) + }, ) approvalAmount = action.amount dAppFee = null @@ -123,15 +127,15 @@ internal class WcEthSignTransactionUseCase @AssistedInject constructor( emitAll(delegate.invoke(transactionData)) } - override suspend fun dAppFee(): Fee.Ethereum.Legacy? { + override fun dAppFee(): Fee.Ethereum.Legacy? { return dAppFee } - override suspend fun getAmount(): Amount? { + override fun getAmount(): WcApprovedAmount? { return approvalAmount } - override fun updateAmount(amount: Amount?) { + override fun updateAmount(amount: WcApprovedAmount?) { middleAction(WcEthTxAction.UpdateApprovalAmount(amount)) } diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthTxHelper.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthTxHelper.kt index fdffc8a26d..268633a213 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthTxHelper.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthTxHelper.kt @@ -16,6 +16,7 @@ import com.tangem.blockchain.extensions.hexToBigDecimal import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.extensions.hexToBytes import com.tangem.domain.models.network.Network +import com.tangem.domain.walletconnect.model.WcApprovedAmount import com.tangem.domain.walletconnect.model.WcEthTransactionParams import java.math.BigDecimal @@ -49,7 +50,9 @@ internal object WcEthTxHelper { .hexToBigDecimal() .movePointLeft(blockchain.decimals()) - val callData = CompiledSmartContractCallData(txParams.data.removePrefix(HEX_PREFIX).hexToBytes()) + val callData = txParams.data?.removePrefix(HEX_PREFIX)?.hexToBytes()?.let { + CompiledSmartContractCallData(it) + } return TransactionData.Uncompiled( amount = Amount(value, blockchain), fee = dAppFee, @@ -62,10 +65,10 @@ internal object WcEthTxHelper { ) } - fun getApprovedAmount(txData: String, result: CheckTransactionResult): ApprovedAmount? { + fun getApprovedAmount(txData: String?, result: CheckTransactionResult): ApprovedAmount? { val approvalMethodId = ApprovalERC20TokenCallData("", null).methodId - val isApprovalWcMethod = txData.startsWith(approvalMethodId) - if (!isApprovalWcMethod) return null + val isApprovalWcMethod = txData?.startsWith(approvalMethodId) + if (isApprovalWcMethod != true) return null val simulation = result.simulation as? SimulationResult.Success ?: return null val approves = (simulation.data as? SimulationData.Approve)?.approvedAmounts @@ -79,5 +82,5 @@ internal object WcEthTxHelper { sealed interface WcEthTxAction { data class UpdateFee(val fee: Fee) : WcEthTxAction - data class UpdateApprovalAmount(val amount: Amount?) : WcEthTxAction + data class UpdateApprovalAmount(val amount: WcApprovedAmount?) : WcEthTxAction } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt index 7208eea4c4..808d399dab 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt @@ -28,7 +28,7 @@ internal class AssociateNetworksDelegate( } } - @Throws(WcPairError.UnsupportedNetworks::class) + @Throws(WcPairError.UnsupportedBlockchains::class) suspend fun associate(sessionProposal: Wallet.Model.SessionProposal): Map { val userWallets = getWallets.invokeSync().filter { it.isMultiCurrency } val requiredNamespaces: Set = sessionProposal.requiredNamespaces.setOfChainId() @@ -74,7 +74,7 @@ internal class AssociateNetworksDelegate( notAdded.add(wcNetwork) } } - if (unknownRequired.isNotEmpty()) throw WcPairError.UnsupportedNetworks(unknownRequired) + if (unknownRequired.isNotEmpty()) throw WcPairError.UnsupportedBlockchains(unknownRequired) return ProposalNetwork( wallet = wallet, missingRequired = missingRequired, diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt index fc52444c3b..77b7c18e93 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt @@ -56,7 +56,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( // check unsupported dApps, just local constant for now, finish if unsupported if (sdkSessionProposal.name in unsupportedDApps) { Timber.tag(WC_TAG).i("Unsupported DApp ${sdkSessionProposal.name}") - val error = WcPairState.Error(WcPairError.UnsupportedDApp) + val error = WcPairState.Error(WcPairError.UnsupportedDomain) emit(error) return@flow } @@ -104,7 +104,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( ) newSession }.onLeft { - analytics.send(WcAnalyticEvents.DAppConnectionFailed(it.message)) + analytics.send(WcAnalyticEvents.DAppConnectionFailed(it.code)) Timber.tag(WC_TAG).e(it, "Failed to approve session ${sdkSessionProposal.name}") } emit(WcPairState.Approving.Result(sessionForApprove, either)) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt index 54f931d604..1a72488035 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt @@ -27,7 +27,11 @@ internal class WcPairSdkDelegate : WcSdkObserver { val pairCall = async { sdkPair(url) } val proposal = async { proposalCallback() } - pairCall.await().map { proposal.await() } + pairCall.await().onLeft { + proposal.cancel() + return@coroutineScope it.left() + } + proposal.await().right() } suspend fun approve( @@ -40,11 +44,14 @@ internal class WcPairSdkDelegate : WcSdkObserver { val approveCall = async { sdkApprove(sessionApprove) } val approveCallback = async { approveCallback() } approveCall.await() - .onLeft { return@coroutineScope it.left() } + .onLeft { + approveCallback.cancel() + return@coroutineScope it.left() + } when (val result = approveCallback.await()) { is Wallet.Model.SettledSessionResponse.Result -> result.right() is Wallet.Model.SettledSessionResponse.Error -> - WcPairError.ExternalApprovalError(result.errorMessage).left() + WcPairError.ApprovalFailed(result.errorMessage).left() } } @@ -77,7 +84,7 @@ internal class WcPairSdkDelegate : WcSdkObserver { WalletKit.approveSession( params = sessionApprove, onSuccess = { continuation.resume(Unit.right()) }, - onError = { continuation.resume(it.throwable.toPairError()) }, + onError = { continuation.resume(it.throwable.toApproveError()) }, ) } } @@ -87,10 +94,20 @@ internal class WcPairSdkDelegate : WcSdkObserver { WalletKit.pair( params = Wallet.Params.Pair(uri), onSuccess = { continuation.resume(Unit.right()) }, - onError = { continuation.resume(it.throwable.toPairError()) }, + onError = { continuation.resume(it.throwable.toPairError().left()) }, ) } } - private fun Throwable.toPairError() = WcPairError.ExternalApprovalError(this.localizedMessage.orEmpty()).left() + private fun Throwable.toPairError() = when { + pairingExpiredMessages.any { message.orEmpty().contains(it) } -> WcPairError.UriAlreadyUsed(message.orEmpty()) + else -> WcPairError.PairingFailed(this.localizedMessage.orEmpty()) + } + + private fun Throwable.toApproveError() = WcPairError.ApprovalFailed(this.localizedMessage.orEmpty()).left() + + companion object { + // com.reown.android.pairing.engine.domain.PairingEngine.pair + private val pairingExpiredMessages = listOf("Pairing URI expired", "Pairing expired") + } } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt index ddfb53cc8e..4c50b189fd 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt @@ -1,9 +1,10 @@ package com.tangem.data.walletconnect.request import com.reown.walletkit.client.Wallet +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.utils.WC_TAG import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.data.walletconnect.utils.WcSdkSessionRequestConverter -import com.tangem.data.walletconnect.utils.WC_TAG import com.tangem.domain.walletconnect.WcRequestService import com.tangem.domain.walletconnect.model.WcMethodName import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest @@ -14,6 +15,7 @@ import timber.log.Timber internal class DefaultWcRequestService( private val requestConverters: Set, + private val respondService: WcRespondService, ) : WcSdkObserver, WcRequestService { private val _wcRequest: Channel> = Channel(Channel.BUFFERED) @@ -29,6 +31,9 @@ internal class DefaultWcRequestService( val name = requestConverters.firstNotNullOfOrNull { it.toWcMethodName(sr) } ?: WcMethodName.Unsupported(sr.request.method) Timber.tag(WC_TAG).i("handle request name $name") + if (name is WcMethodName.Unsupported) { + respondService.rejectRequestNonBlock(sr) + } _wcRequest.trySend(name to sr) } } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt index c6239ed16c..66211e5d83 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt @@ -7,9 +7,9 @@ import com.domain.blockaid.models.dapp.CheckDAppResult import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.data.walletconnect.pair.AssociateNetworksDelegate +import com.tangem.data.walletconnect.utils.WC_TAG import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.data.walletconnect.utils.WcSdkSessionConverter -import com.tangem.data.walletconnect.utils.WC_TAG import com.tangem.datasource.local.walletconnect.WalletConnectStore import com.tangem.domain.walletconnect.model.WcSession import com.tangem.domain.walletconnect.model.WcSessionDTO @@ -18,11 +18,9 @@ import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.coroutines.resume @@ -37,6 +35,7 @@ internal class DefaultWcSessionsManager( private val onSessionDelete = Channel(capacity = Channel.BUFFERED) private val oneTimeMigration = MutableStateFlow(true) + private val oneTimeSessionExtend = MutableStateFlow(true) override val sessions: Flow>> get() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore } @@ -51,6 +50,10 @@ internal class DefaultWcSessionsManager( val associatedSessions: List = associate(inSdk, inStore, wallets) val someRemove = removeUnknownSessions(inStore, associatedSessions) if (someRemove) return@transform // ignore emit, wait next one + if (oneTimeSessionExtend.value) { + oneTimeSessionExtend.value = false + scope.launch { extendAliveSessions(associatedSessions) } + } emit(associatedSessions.groupBy { it.wallet }) } .distinctUntilChanged() @@ -58,6 +61,7 @@ internal class DefaultWcSessionsManager( override fun onWcSdkInit() { oneTimeMigration.value = true + oneTimeSessionExtend.value = true listenOnSessionDelete() } @@ -150,6 +154,11 @@ internal class DefaultWcSessionsManager( return haveSomeUnknown } + private suspend fun extendAliveSessions(sessions: List) = coroutineScope { + val jobs = sessions.map { launch { sdkSessionExtend(it.sdkModel.topic) } } + jobs.joinAll() + } + private suspend fun sdkDisconnectSession(topic: String): Either { return suspendCancellableCoroutine { continuation -> WalletKit.disconnectSession( @@ -160,6 +169,16 @@ internal class DefaultWcSessionsManager( } } + private suspend fun sdkSessionExtend(topic: String): Either { + return suspendCancellableCoroutine { continuation -> + WalletKit.extendSession( + params = Wallet.Params.SessionExtend(topic), + onSuccess = { continuation.resume(Unit.right()) }, + onError = { continuation.resume(it.throwable.left()) }, + ) + } + } + private fun listenOnSessionDelete() { onSessionDelete.receiveAsFlow() .filterIsInstance() diff --git a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt index bd8e58f43b..e493e6b602 100644 --- a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt +++ b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt @@ -196,7 +196,7 @@ internal class DefaultWcPairUseCaseTest { @Test fun `success pair and reject unsupported dApp`() = runTest { coEvery { sdkDelegate.pair(url) } returns unsupportedSdkProposal.right() - val unsupportedDAppError = WcPairState.Error(WcPairError.UnsupportedDApp) + val unsupportedDAppError = WcPairState.Error(WcPairError.UnsupportedDomain) val useCase = useCaseFactory() useCase.invoke().test { @@ -211,7 +211,7 @@ internal class DefaultWcPairUseCaseTest { @Test fun `complete on pair error`() = runTest { - val error = WcPairError.ExternalApprovalError("error") + val error = WcPairError.PairingFailed("error") coEvery { sdkDelegate.pair(url) } returns error.left() val errorState = WcPairState.Error(error) @@ -229,7 +229,7 @@ internal class DefaultWcPairUseCaseTest { @Test fun `complete on approve error`() = runTest { val approveLoading = WcPairState.Approving.Loading(sessionForApprove) - val error = WcPairError.ExternalApprovalError("error").left() + val error = WcPairError.ApprovalFailed("error").left() coEvery { sdkDelegate.pair(url) } returns sdkProposal.right() coEvery { sdkDelegate.approve(sdkApprove) } returns error coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE } diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/CheckTransactionResult.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/CheckTransactionResult.kt index d7244d6023..6080ac8921 100644 --- a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/CheckTransactionResult.kt +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/CheckTransactionResult.kt @@ -8,5 +8,6 @@ package com.domain.blockaid.models.transaction */ data class CheckTransactionResult( val validation: ValidationResult, + val description: String? = null, val simulation: SimulationResult, ) \ No newline at end of file diff --git a/domain/express/.gitignore b/domain/express/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/express/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/express/build.gradle.kts b/domain/express/build.gradle.kts new file mode 100644 index 0000000000..b5a4095dac --- /dev/null +++ b/domain/express/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} + +android { + namespace = "com.tangem.domain.express.models" +} + +dependencies { + /** Core */ + implementation(projects.core.utils) + implementation(projects.core.datasource) + + /** Domain */ + implementation(projects.domain.express.models) + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens.models) + + /** Other */ + implementation(deps.moshi.adapters) +} \ No newline at end of file diff --git a/domain/express/models/.gitignore b/domain/express/models/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/express/models/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/express/models/build.gradle.kts b/domain/express/models/build.gradle.kts new file mode 100644 index 0000000000..a7e2520b52 --- /dev/null +++ b/domain/express/models/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + /** Domain */ + implementation(projects.domain.tokens.models) + + /* Other */ + implementation(deps.moshi.adapters) +} \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt new file mode 100644 index 0000000000..7d153e9bf1 --- /dev/null +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt @@ -0,0 +1,64 @@ +package com.tangem.domain.express.models + +import java.math.BigDecimal + +sealed class ExpressError { + + abstract val code: Int + + data class DataError( + override val code: Int, + val description: String?, + ) : ExpressError() + + data class BadRequest(override val code: Int) : ExpressError() + + data class Forbidden(override val code: Int) : ExpressError() + + data class InternalError(override val code: Int) : ExpressError() + + data class ProviderNotFoundError(override val code: Int) : ExpressError() + + data class ProviderNotActiveError(override val code: Int) : ExpressError() + + data class ProviderNotAvailableError(override val code: Int) : ExpressError() + + data class ProviderInternalError(override val code: Int) : ExpressError() + + data class ExchangeNotPossibleError(override val code: Int) : ExpressError() + + data class NotEnoughBalanceError(override val code: Int) : ExpressError() + + data class InvalidAddressError(override val code: Int) : ExpressError() + + sealed class AmountError : ExpressError() { + abstract val amount: BigDecimal + + data class TooSmallError(override val code: Int, override val amount: BigDecimal) : AmountError() + data class TooBigError(override val code: Int, override val amount: BigDecimal) : AmountError() + data class NotEnoughAllowanceError(override val code: Int, override val amount: BigDecimal) : AmountError() + } + + data class ProviderDifferentAmountError( + override val code: Int, + val fromAmount: BigDecimal, + val fromProviderAmount: BigDecimal, + val decimals: Int, + ) : ExpressError() + + data class InvalidFromDecimalsError( + override val code: Int, + val receivedFromDecimals: Int, + val expressFromDecimals: Int, + ) : ExpressError() + + data class InvalidSignatureError(override val code: Int = 990) : ExpressError() + + data class InvalidRequestIdError(override val code: Int = 991) : ExpressError() + + data class InvalidPayoutAddressError(override val code: Int = 992) : ExpressError() + + data object UnknownError : ExpressError() { + override val code: Int = -1 + } +} \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProvider.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProvider.kt new file mode 100644 index 0000000000..9a26b89580 --- /dev/null +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProvider.kt @@ -0,0 +1,43 @@ +package com.tangem.domain.express.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigDecimal + +/** + * Express provider data model + * + * @property providerId provider id + * @property rateTypes supported rate types + * @property name provider name + * @property type provider type + * @property imageLarge provider large logo image + * @property termsOfUse terms of use link + * @property privacyPolicy privacy policy link + * @property isRecommended flag that indicates if this provider is recommended + * @property slippage provider slippage + * + * Uses to store transaction data in datastore, when extends - should always add default value + * to support backward compatibility + */ +@JsonClass(generateAdapter = true) +data class ExpressProvider( + @Json(name = "providerId") + val providerId: String, + @Json(name = "rateTypes") + val rateTypes: List = emptyList(), + @Json(name = "name") + val name: String, + @Json(name = "type") + val type: ExpressProviderType, + @Json(name = "imageLarge") + val imageLarge: String, + @Json(name = "termsOfUse") + val termsOfUse: String?, + @Json(name = "privacyPolicy") + val privacyPolicy: String?, + @Json(name = "isRecommended") + val isRecommended: Boolean = false, + @Json(name = "slippage") + val slippage: BigDecimal?, +) \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt new file mode 100644 index 0000000000..3c77da80fb --- /dev/null +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.express.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Express provider type + * + * @property typeName provider type name + */ +@JsonClass(generateAdapter = false) +enum class ExpressProviderType(val typeName: String) { + @Json(name = "DEX") + DEX(typeName = "DEX"), + + @Json(name = "CEX") + CEX(typeName = "CEX"), + + @Json(name = "DEX_BRIDGE") + DEX_BRIDGE(typeName = "DEX/Bridge"), + + @Json(name = "onramp") + ONRAMP(typeName = "ONRAMP"), +} \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressRateType.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressRateType.kt new file mode 100644 index 0000000000..4b9efd00b0 --- /dev/null +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressRateType.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.express.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Rate type. + * + * Current implementation contains only float type, fixed will be supported later. + */ +@JsonClass(generateAdapter = false) +enum class ExpressRateType { + @Json(name = "FLOAT") + Float, + + @Json(name = "FIXED") + Fixed, +} \ No newline at end of file diff --git a/domain/express/src/main/java/com/tangem/domain/express/ExpressErrorResolver.kt b/domain/express/src/main/java/com/tangem/domain/express/ExpressErrorResolver.kt new file mode 100644 index 0000000000..05e981033d --- /dev/null +++ b/domain/express/src/main/java/com/tangem/domain/express/ExpressErrorResolver.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.express + +import com.tangem.domain.express.models.ExpressError + +/** + * Common express error resolver + */ +interface ExpressErrorResolver { + + fun resolve(throwable: Throwable): ExpressError +} \ No newline at end of file diff --git a/domain/express/src/main/java/com/tangem/domain/express/ExpressRepository.kt b/domain/express/src/main/java/com/tangem/domain/express/ExpressRepository.kt new file mode 100644 index 0000000000..d7c6518b46 --- /dev/null +++ b/domain/express/src/main/java/com/tangem/domain/express/ExpressRepository.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.express + +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.wallets.models.UserWallet + +/** + * Express repository + */ +interface ExpressRepository { + + /** + * Returns list of express providers + * + * @param userWallet selected user wallet info + */ + suspend fun getProviders(userWallet: UserWallet): List +} \ No newline at end of file diff --git a/domain/legacy/build.gradle.kts b/domain/legacy/build.gradle.kts index e109af45b0..1326610ec7 100644 --- a/domain/legacy/build.gradle.kts +++ b/domain/legacy/build.gradle.kts @@ -52,6 +52,5 @@ dependencies { testImplementation(deps.test.mockk) testImplementation(deps.test.truth) testImplementation(projects.common.test) - androidTestImplementation(deps.test.junit.android) - androidTestImplementation(deps.test.espresso) + } \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/SdkRequirementsConditionConverter.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/SdkRequirementsConditionConverter.kt index 92b365f3f3..f2cb03313a 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/SdkRequirementsConditionConverter.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/SdkRequirementsConditionConverter.kt @@ -8,6 +8,11 @@ internal class SdkRequirementsConditionConverter : Converter AssetRequirementsCondition.PaidTransaction + is SdkRequirementsCondition.RequiredTrustline -> AssetRequirementsCondition.RequiredTrustline( + requiredAmount = requireNotNull(value.amount.value), + currencySymbol = value.amount.currencySymbol, + decimals = value.amount.decimals, + ) is SdkRequirementsCondition.PaidTransactionWithFee -> AssetRequirementsCondition.PaidTransactionWithFee( feeAmount = requireNotNull(value.feeAmount.value), feeCurrencySymbol = value.feeAmount.currencySymbol, diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CreateCryptoCurrencyUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CreateCryptoCurrencyUseCase.kt new file mode 100644 index 0000000000..7cdb98ba3d --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CreateCryptoCurrencyUseCase.kt @@ -0,0 +1,65 @@ +package com.tangem.domain.managetokens + +import arrow.core.Either +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.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Creates cryptocurrency from manage token + */ +class CreateCryptoCurrencyUseCase( + private val customTokensRepository: CustomTokensRepository, +) { + + /** + * Creates currency from default crypto currency + * + * @param token supported crypto currency info + * @param userWalletId selected user wallet id + */ + suspend operator fun invoke( + token: ManagedCryptoCurrency.Token, + userWalletId: UserWalletId, + ): Either> = Either.catch { + token.availableNetworks + .map { sourceNetwork -> + when (sourceNetwork) { + is ManagedCryptoCurrency.SourceNetwork.Default -> customTokensRepository.createToken( + managedCryptoCurrency = token, + sourceNetwork = sourceNetwork, + rawId = CryptoCurrency.RawID(token.id.value), + ) + is ManagedCryptoCurrency.SourceNetwork.Main -> customTokensRepository.createCoin( + userWalletId = userWalletId, + networkId = sourceNetwork.id, + derivationPath = sourceNetwork.network.derivationPath, + ) + } + } + } + + /** + * Creates currency from custom crypto currency + * + * @param userWalletId selected user wallet id + * @param networkId currency network id + * @param derivationPath custom derivation path + * @param formValues token info (e.i. name, contract address, symbol, decimals) + */ + suspend operator fun invoke( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + formValues: AddCustomTokenForm.Validated.All?, + ): Either = Either.catch { + if (formValues == null) { + customTokensRepository.createCoin(userWalletId, networkId, derivationPath) + } else { + customTokensRepository.createCustomToken(userWalletId, networkId, derivationPath, formValues) + } + } +} \ 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 deleted file mode 100644 index 5e8438b839..0000000000 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CreateCurrencyUseCase.kt +++ /dev/null @@ -1,26 +0,0 @@ -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.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network -import com.tangem.domain.wallets.models.UserWalletId - -class CreateCurrencyUseCase( - private val repository: CustomTokensRepository, -) { - - suspend operator fun invoke( - userWalletId: UserWalletId, - networkId: Network.ID, - derivationPath: Network.DerivationPath, - formValues: AddCustomTokenForm.Validated.All?, - ): Either = Either.catch { - if (formValues == null) { - repository.createCoin(userWalletId, networkId, derivationPath) - } else { - repository.createCustomToken(userWalletId, networkId, derivationPath, formValues) - } - } -} \ 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 index b2cedcf6e1..0d15310592 100644 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt @@ -8,7 +8,7 @@ import com.tangem.domain.managetokens.repository.CustomTokensRepository import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher -import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.TokensFeatureToggles @@ -24,7 +24,7 @@ class SaveManagedTokensUseCase( private val derivationsRepository: DerivationsRepository, private val stakingRepository: StakingRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - private val multiQuoteFetcher: MultiQuoteFetcher, + private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -126,8 +126,8 @@ class SaveManagedTokensUseCase( } private suspend fun refreshUpdatedQuotes(addedCurrencies: List) { - multiQuoteFetcher( - params = MultiQuoteFetcher.Params( + multiQuoteStatusFetcher( + params = MultiQuoteStatusFetcher.Params( currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, appCurrencyId = null, ), 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 index d146df5090..ec0e1717c6 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetCurrencyQuotesUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetCurrencyQuotesUseCase.kt @@ -4,29 +4,29 @@ import arrow.core.None import arrow.core.Option import arrow.core.toOption import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.quotes.single.SingleQuoteProducer -import com.tangem.domain.quotes.single.SingleQuoteSupplier -import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.quotes.single.SingleQuoteStatusProducer +import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map class GetCurrencyQuotesUseCase( - private val singleQuoteSupplier: SingleQuoteSupplier, + private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, ) { // TODO apply interval parameter [REDACTED_TASK_KEY] operator fun invoke( currencyID: CryptoCurrency.ID, interval: PriceChangeInterval, refresh: Boolean, - ): Flow> { + ): Flow> { val rawId = currencyID.rawCurrencyId ?: return flowOf(None) - return singleQuoteSupplier( - params = SingleQuoteProducer.Params(rawCurrencyId = rawId), + return singleQuoteStatusSupplier( + params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawId), ) - .map { (it as? Quote.Value).toOption() } + .map { (it.value as? QuoteStatus.Data).toOption() } .catch { emit(None) } } } \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt index 95750fda93..96a60f0cc0 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt @@ -6,7 +6,7 @@ import com.tangem.domain.markets.repositories.MarketsTokenRepository import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher -import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.TokensFeatureToggles @@ -29,7 +29,7 @@ class SaveMarketTokensUseCase( private val currenciesRepository: CurrenciesRepository, private val stakingRepository: StakingRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - private val multiQuoteFetcher: MultiQuoteFetcher, + private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -107,8 +107,8 @@ class SaveMarketTokensUseCase( } private suspend fun refreshUpdatedQuotes(addedCurrencies: List) { - multiQuoteFetcher( - params = MultiQuoteFetcher.Params( + multiQuoteStatusFetcher( + params = MultiQuoteStatusFetcher.Params( currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, appCurrencyId = null, ), diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/quote/QuoteStatus.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/quote/QuoteStatus.kt new file mode 100644 index 0000000000..1aad20046d --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/quote/QuoteStatus.kt @@ -0,0 +1,69 @@ +package com.tangem.domain.models.quote + +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import java.math.BigDecimal + +/** + * Represents the status of a specific currency quote + * + * @property rawCurrencyId the unique identifier of the cryptocurrency for which the financial information is provided + * @property value the specific status value + */ +data class QuoteStatus(val rawCurrencyId: CryptoCurrency.RawID, val value: Value) { + + /** Constructor for creating the [Empty] status of a specific currency quote */ + constructor(rawCurrencyId: CryptoCurrency.RawID) : this(rawCurrencyId = rawCurrencyId, value = Empty) + + /** Represents the various possible statuses of a quote */ + sealed interface Value { + + /** Status source */ + val source: StatusSource + + fun copySealed(source: StatusSource): Value { + return when (this) { + is Empty -> this + is Data -> copy(source = source) + } + } + } + + /** Represents unknown financial information for a specific cryptocurrency */ + data object Empty : Value { + override val source: StatusSource = StatusSource.ACTUAL + } + + /** + * Represents financial information for a specific cryptocurrency, including its fiat exchange rate and + * price change. + * + * @property source status source + * @property fiatRate the current fiat exchange rate for the cryptocurrency + * @property priceChange the price change for the cryptocurrency + */ + data class Data( + override val source: StatusSource, + val fiatRate: BigDecimal, + val priceChange: BigDecimal, + ) : Value +} + +/** Applies the given [function] if [QuoteStatus.Value] is [QuoteStatus.Data] or does nothing */ +inline fun QuoteStatus.mapData(function: QuoteStatus.Data.() -> Unit): QuoteStatus { + if (value is QuoteStatus.Data) value.function() + + return this +} + +/** + * Applies the given [onData] or [onEmpty] functions depending on [QuoteStatus.Data] or [QuoteStatus.Empty] + * + * @param T type of result + */ +inline fun QuoteStatus.fold(onData: QuoteStatus.Data.() -> T, onEmpty: QuoteStatus.Empty.() -> T): T { + return when (value) { + is QuoteStatus.Data -> value.onData() + is QuoteStatus.Empty -> value.onEmpty() + } +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTPriceUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTPriceUseCase.kt index eaf7a5cdea..1193977eb9 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTPriceUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTPriceUseCase.kt @@ -3,11 +3,11 @@ package com.tangem.domain.nft import arrow.core.Either import com.tangem.domain.models.network.Network import com.tangem.domain.nft.repository.NFTRepository -import com.tangem.domain.quotes.single.SingleQuoteFetcher +import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher class FetchNFTPriceUseCase( private val nftRepository: NFTRepository, - private val singleQuoteFetcher: SingleQuoteFetcher, + private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher, ) { suspend operator fun invoke(network: Network, appCurrencyId: String?): Either { @@ -15,8 +15,8 @@ class FetchNFTPriceUseCase( val nftCurrency = nftRepository.getNFTCurrency(network) val rawId = nftCurrency.id.rawCurrencyId ?: error("Invalid nft currency id") - singleQuoteFetcher( - params = SingleQuoteFetcher.Params( + singleQuoteStatusFetcher( + params = SingleQuoteStatusFetcher.Params( rawCurrencyId = rawId, appCurrencyId = appCurrencyId, ), diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTPriceUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTPriceUseCase.kt index 4dc44a7590..a728ce7b73 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTPriceUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTPriceUseCase.kt @@ -1,19 +1,19 @@ package com.tangem.domain.nft import arrow.core.Either +import com.tangem.domain.models.quote.fold import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.nft.models.NFTSalePrice import com.tangem.domain.nft.repository.NFTRepository -import com.tangem.domain.quotes.single.SingleQuoteProducer -import com.tangem.domain.quotes.single.SingleQuoteSupplier -import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.quotes.single.SingleQuoteStatusProducer +import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map class GetNFTPriceUseCase( private val nftRepository: NFTRepository, - private val singleQuoteSupplier: SingleQuoteSupplier, + private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, ) { suspend operator fun invoke(userWalletId: UserWalletId, nftAsset: NFTAsset): Either> { @@ -21,8 +21,8 @@ class GetNFTPriceUseCase( val nftCurrency = nftRepository.getNFTCurrency(nftAsset.network) val rawId = nftCurrency.id.rawCurrencyId ?: error("Invalid nft currency id") - singleQuoteSupplier( - params = SingleQuoteProducer.Params(rawCurrencyId = rawId), + singleQuoteStatusSupplier( + params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawId), ).map { quote -> val nftPrice = nftRepository.getNFTSalePrice( userWalletId = userWalletId, @@ -31,12 +31,15 @@ class GetNFTPriceUseCase( assetId = nftAsset.id, ) - val quoteValue = quote as? Quote.Value - if (nftPrice !is NFTSalePrice.Value) { nftPrice } else { - nftPrice.copy(fiatValue = quoteValue?.fiatRate?.multiply(nftPrice.value)) + nftPrice.copy( + fiatValue = quote.fold( + onData = { fiatRate.multiply(nftPrice.value) }, + onEmpty = { null }, + ), + ) } } } diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/HotCryptoCurrency.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/HotCryptoCurrency.kt index 389cc866e3..4d8415b915 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/HotCryptoCurrency.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/HotCryptoCurrency.kt @@ -1,17 +1,17 @@ package com.tangem.domain.onramp.model import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.models.quote.QuoteStatus /** * Hot crypto currency * * @property cryptoCurrency crypto currency - * @property quote quote + * @property quoteStatus quote status * [REDACTED_AUTHOR] */ data class HotCryptoCurrency( val cryptoCurrency: CryptoCurrency, - val quote: Quote, + val quoteStatus: QuoteStatus, ) \ No newline at end of file diff --git a/domain/quotes/build.gradle.kts b/domain/quotes/build.gradle.kts index cc097e7300..27f51399d9 100644 --- a/domain/quotes/build.gradle.kts +++ b/domain/quotes/build.gradle.kts @@ -6,5 +6,4 @@ plugins { dependencies { api(projects.domain.core) api(projects.domain.models) - api(projects.domain.tokens.models) } \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/QuotesRepositoryV2.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/QuotesRepository.kt similarity index 69% rename from domain/quotes/src/main/java/com/tangem/domain/quotes/QuotesRepositoryV2.kt rename to domain/quotes/src/main/java/com/tangem/domain/quotes/QuotesRepository.kt index f9d517cd42..9431c2b3f2 100644 --- a/domain/quotes/src/main/java/com/tangem/domain/quotes/QuotesRepositoryV2.kt +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/QuotesRepository.kt @@ -1,15 +1,15 @@ package com.tangem.domain.quotes import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.models.quote.QuoteStatus /** * Quotes repository * [REDACTED_AUTHOR] */ -interface QuotesRepositoryV2 { +interface QuotesRepository { /** Get quotes by [currenciesIds] synchronously or null */ - suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set): Set? + suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set): Set? } \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteFetcher.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteStatusFetcher.kt similarity index 75% rename from domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteFetcher.kt rename to domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteStatusFetcher.kt index d75234b93e..4f2ad37795 100644 --- a/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteFetcher.kt +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteStatusFetcher.kt @@ -4,11 +4,11 @@ import com.tangem.domain.core.flow.FlowFetcher import com.tangem.domain.models.currency.CryptoCurrency /** - * Fetcher of quotes + * Fetcher of quotes statuses for specified list of [CryptoCurrency.RawID] * [REDACTED_AUTHOR] */ -interface MultiQuoteFetcher : FlowFetcher { +interface MultiQuoteStatusFetcher : FlowFetcher { /** * Params diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteFetcher.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteStatusFetcher.kt similarity index 61% rename from domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteFetcher.kt rename to domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteStatusFetcher.kt index 5b315bc6f8..062c864638 100644 --- a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteFetcher.kt +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteStatusFetcher.kt @@ -4,16 +4,17 @@ import com.tangem.domain.core.flow.FlowFetcher import com.tangem.domain.models.currency.CryptoCurrency /** - * Fetcher of quote for [CryptoCurrency.RawID] + * Fetcher of quote for specified [CryptoCurrency.RawID] * [REDACTED_AUTHOR] */ -interface SingleQuoteFetcher : FlowFetcher { +interface SingleQuoteStatusFetcher : FlowFetcher { /** * Params * * @property rawCurrencyId crypto currency id + * @property appCurrencyId app currency id, if null then selected app currency will be used */ data class Params( val rawCurrencyId: CryptoCurrency.RawID, diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteProducer.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteStatusProducer.kt similarity index 72% rename from domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteProducer.kt rename to domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteStatusProducer.kt index c7fd024e28..81ff530551 100644 --- a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteProducer.kt +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteStatusProducer.kt @@ -2,16 +2,16 @@ package com.tangem.domain.quotes.single import com.tangem.domain.core.flow.FlowProducer import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.models.quote.QuoteStatus /** * Producer of quote [CryptoCurrency.RawID] * [REDACTED_AUTHOR] */ -interface SingleQuoteProducer : FlowProducer { +interface SingleQuoteStatusProducer : FlowProducer { data class Params(val rawCurrencyId: CryptoCurrency.RawID) - interface Factory : FlowProducer.Factory + interface Factory : FlowProducer.Factory } \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteStatusSupplier.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteStatusSupplier.kt new file mode 100644 index 0000000000..a203be8468 --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteStatusSupplier.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.quotes.single + +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.models.quote.QuoteStatus + +/** + * Supplier of quote [SingleQuoteStatusProducer.Params] + * + * @property factory factory for creating [SingleQuoteStatusProducer] + * @property keyCreator key creator + * +[REDACTED_AUTHOR] + */ +abstract class SingleQuoteStatusSupplier( + override val factory: SingleQuoteStatusProducer.Factory, + override val keyCreator: (SingleQuoteStatusProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteSupplier.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteSupplier.kt deleted file mode 100644 index e65dbb492d..0000000000 --- a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteSupplier.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.domain.quotes.single - -import com.tangem.domain.core.flow.FlowCachingSupplier -import com.tangem.domain.tokens.model.Quote - -/** - * Supplier of quote [SingleQuoteProducer.Params] - * - * @property factory factory for creating [SingleQuoteProducer] - * @property keyCreator key creator - * -[REDACTED_AUTHOR] - */ -abstract class SingleQuoteSupplier( - override val factory: SingleQuoteProducer.Factory, - override val keyCreator: (SingleQuoteProducer.Params) -> String, -) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/swap/.gitignore b/domain/swap/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/swap/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/swap/build.gradle.kts b/domain/swap/build.gradle.kts new file mode 100644 index 0000000000..c0231df3f5 --- /dev/null +++ b/domain/swap/build.gradle.kts @@ -0,0 +1,31 @@ +import org.gradle.kotlin.dsl.projects + +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} + +android { + namespace = "com.tangem.domain.swap" +} + + +dependencies { + + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.express.models) + implementation(projects.domain.swap.models) + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens.models) + + /** Util */ + implementation(projects.core.utils) + + /** Other */ + implementation(deps.arrow.core) + implementation(deps.kotlin.coroutines) + +} \ No newline at end of file diff --git a/domain/swap/models/.gitignore b/domain/swap/models/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/swap/models/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/swap/models/build.gradle.kts b/domain/swap/models/build.gradle.kts new file mode 100644 index 0000000000..0b8ed0f521 --- /dev/null +++ b/domain/swap/models/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} + +android { + namespace = "com.tangem.domain.swap.models" +} + +dependencies { + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.express.models) + implementation(projects.domain.tokens.models) + + /** Core */ + implementation(projects.core.datasource) + + /** Other */ + implementation(deps.jodatime) + implementation(deps.moshi) +} \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencies.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencies.kt new file mode 100644 index 0000000000..ef559c44e9 --- /dev/null +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencies.kt @@ -0,0 +1,46 @@ +package com.tangem.domain.swap.models + +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.tokens.model.CryptoCurrencyStatus + +/** + * Model of currencies available to swap + * + * @param fromGroup group of currencies available swap from + * @param toGroup group of currencies available swap to + */ +data class SwapCurrencies( + val fromGroup: SwapCurrenciesGroup, + val toGroup: SwapCurrenciesGroup, +) + +/** + * Return swap group depending on [swapDirection] + */ +fun SwapCurrencies.getGroupWithReverse(swapDirection: SwapDirection): SwapCurrenciesGroup { + return when (swapDirection) { + SwapDirection.Reverse -> fromGroup + SwapDirection.Direct -> toGroup + } +} + +/** + * Swap group model + * + * @param available list of available currencies to swap + * @param available list of unavailable currencies to swap + * @param afterSearch flag indicates whether user searched token + */ +data class SwapCurrenciesGroup( + val available: List, + val unavailable: List, + val afterSearch: Boolean, +) + +/** + * Crypto currency with providers to swap + */ +data class SwapCryptoCurrency( + val currencyStatus: CryptoCurrencyStatus, + val providers: List, +) \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapDirection.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapDirection.kt new file mode 100644 index 0000000000..9c60cc2ec0 --- /dev/null +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapDirection.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.swap.models + +import com.tangem.domain.swap.models.SwapDirection.Direct +import com.tangem.domain.swap.models.SwapDirection.Reverse + +/** + * Swap direction + * + * Initial currency can be swap to or from. + * If swap being swap from direction is [Direct], + * Otherwise direction is [Reverse] + */ +enum class SwapDirection { + Direct, + Reverse, +} \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapPairModel.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapPairModel.kt new file mode 100644 index 0000000000..5e8e0479f9 --- /dev/null +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapPairModel.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.swap.models + +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.tokens.model.CryptoCurrencyStatus + +/** + * Domain layer representation of SwapPair data network model. + * + * @property from Short information about the token we want to change + * @property to Short information about the token we want to exchange for + * @property providers Exchange providers + */ +data class SwapPairModel( + val from: CryptoCurrencyStatus, + val to: CryptoCurrencyStatus, + val providers: List, +) \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapQuoteModel.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapQuoteModel.kt new file mode 100644 index 0000000000..a808f07e67 --- /dev/null +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapQuoteModel.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.swap.models + +import com.tangem.domain.express.models.ExpressProvider +import java.math.BigDecimal + +/** + * Quote model holds data about current amounts of swaps and fees + * + * @property provider swap provider + * @property toTokenAmount amount of token you want to receive + * @property allowanceContract whether swap occurs via third token + */ +data class SwapQuoteModel( + val provider: ExpressProvider, + val toTokenAmount: BigDecimal, + val allowanceContract: String?, +) \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapStatusModel.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapStatusModel.kt new file mode 100644 index 0000000000..53cfd45e13 --- /dev/null +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapStatusModel.kt @@ -0,0 +1,62 @@ +package com.tangem.domain.swap.models + +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.currency.CryptoCurrency +import org.joda.time.DateTime + +/** + * Swapped status model + */ +data class SwapStatusModel( + val providerId: String, + val status: SwapStatus? = null, + val txId: String? = null, + val txExternalUrl: String? = null, + val txExternalId: String? = null, + val refundNetwork: String? = null, + val refundContractAddress: String? = null, + val refundTokensResponse: UserTokensResponse.Token? = null, + val refundCurrency: CryptoCurrency? = null, + val createdAt: DateTime? = null, + val averageDuration: Int? = null, +) { + val hasLongTime: Boolean + get() = if (createdAt != null && averageDuration != null) { + DateTime.now().minusSeconds(averageDuration * 5) > createdAt + } else { + false + } +} + +enum class SwapStatus { + New, + Waiting, + WaitingTxHash, + Confirming, + Verifying, + Exchanging, + Failed, + Sending, + Finished, + Refunded, + Cancelled, + TxFailed, + Unknown, + Paused, + ; + + val isTerminal: Boolean + get() = this == Refunded || + this == Finished || + this == Cancelled || + this == TxFailed || + this == Paused || + this == Unknown + + val isAutoDisposable: Boolean + get() = this == Finished + + companion object { + fun SwapStatus?.isFailed(): Boolean = this == Failed || this == TxFailed + } +} \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt new file mode 100644 index 0000000000..e296ca6fe3 --- /dev/null +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt @@ -0,0 +1,29 @@ +package com.tangem.domain.swap.models + +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.models.currency.CryptoCurrency +import java.math.BigDecimal + +/** + * List of saved swap transactions + */ +data class SwapTransactionListModel( + val userWalletId: String, + val fromCryptoCurrencyId: String, + val toCryptoCurrencyId: String, + val fromCryptoCurrency: CryptoCurrency, + val toCryptoCurrency: CryptoCurrency, + val transactions: List, +) + +/** + * Saved swap transactions + */ +data class SwapTransactionModel( + val txId: String, + val timestamp: Long, + val fromCryptoAmount: BigDecimal, + val toCryptoAmount: BigDecimal, + val provider: ExpressProvider, + val status: SwapStatusModel? = null, +) \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/TokenInfo.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/TokenInfo.kt new file mode 100644 index 0000000000..9bd09cc984 --- /dev/null +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/TokenInfo.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.swap.models + +data class TokenInfo( + val contractAddress: String, + val network: String, +) \ No newline at end of file diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/SwapErrorResolver.kt b/domain/swap/src/main/java/com/tangem/domain/swap/SwapErrorResolver.kt new file mode 100644 index 0000000000..b9f75cb5c1 --- /dev/null +++ b/domain/swap/src/main/java/com/tangem/domain/swap/SwapErrorResolver.kt @@ -0,0 +1,7 @@ +package com.tangem.domain.swap + +import com.tangem.domain.express.models.ExpressError + +interface SwapErrorResolver { + fun resolve(throwable: Throwable): ExpressError +} \ No newline at end of file diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt b/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt new file mode 100644 index 0000000000..7bb2c091ba --- /dev/null +++ b/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt @@ -0,0 +1,56 @@ +package com.tangem.domain.swap + +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapPairModel +import com.tangem.domain.swap.models.SwapQuoteModel +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +import java.math.BigDecimal + +/** + * Swap repository + */ +interface SwapRepositoryV2 { + + /** + * Express swap pairs, both direct and reversed + * + * @param userWallet selected user wallet + * @param initialCurrency currency being swapped (either to or from) + * @param cryptoCurrencyStatusList list of currencies might be swapped + */ + suspend fun getPairs( + userWallet: UserWallet, + initialCurrency: CryptoCurrency, + cryptoCurrencyStatusList: List, + ): List + + /** Express getPairs request variant without providers request */ + suspend fun getPairsOnly( + userWallet: UserWallet, + initialCurrency: CryptoCurrency, + cryptoCurrencyList: List, + ): List + + /** + * Returns swap quotes on selected pair + * + * @param userWallet selected user wallet + * @param fromCryptoCurrency currency being swapped from + * @param toCryptoCurrency currency being swapped to + * @param fromAmount swap amount + * @param provider selected express provider + * @param rateType rate type + */ + @Suppress("LongParameterList") + suspend fun getSwapQuote( + userWallet: UserWallet, + fromCryptoCurrency: CryptoCurrency, + toCryptoCurrency: CryptoCurrency, + fromAmount: BigDecimal, + provider: ExpressProvider, + rateType: ExpressRateType, + ): SwapQuoteModel +} \ No newline at end of file diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt new file mode 100644 index 0000000000..5efa512937 --- /dev/null +++ b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt @@ -0,0 +1,80 @@ +package com.tangem.domain.swap + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapStatusModel +import com.tangem.domain.swap.models.SwapTransactionListModel +import com.tangem.domain.swap.models.SwapTransactionModel +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.flow.Flow + +/** + * Swap repository for statuses + */ +interface SwapTransactionRepository { + + /** + * Store new swap transaction + * + * @param userWalletId selected user wallet id + * @param fromCryptoCurrency currency swap from + * @param toCryptoCurrency currency swap to + * @param transaction swap transaction + */ + suspend fun storeTransaction( + userWalletId: UserWalletId, + fromCryptoCurrency: CryptoCurrency, + toCryptoCurrency: CryptoCurrency, + transaction: SwapTransactionModel, + ) + + /** + * Get list of swap transactions + * + * @param userWallet selected user wallet + * @param cryptoCurrencyId transactions for specific crypto currency + */ + suspend fun getTransactions( + userWallet: UserWallet, + cryptoCurrencyId: CryptoCurrency.ID, + ): Flow?> + + /** + * Remove stores swap transaction + * + * @param userWalletId selected user wallet id + * @param fromCryptoCurrency currency swap from + * @param toCryptoCurrency currency swap to + * @param txId transaction id to remove + */ + suspend fun removeTransaction( + userWalletId: UserWalletId, + fromCryptoCurrency: CryptoCurrency, + toCryptoCurrency: CryptoCurrency, + txId: String, + ) + + /** + * Update swap transaction + * + * @param txId transaction id to update + * @param status new transaction status + * @param refundTokenCurrency refund token + */ + suspend fun storeTransactionState(txId: String, status: SwapStatusModel, refundTokenCurrency: CryptoCurrency?) + + /** + * Save last swapped crypto currency token + * + * @param userWalletId selected user wallet id + * @param cryptoCurrencyId last swapped currency id + */ + suspend fun storeLastSwappedCryptoCurrencyId(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID) + + /** + * Get last swapped crypto currency token + * + * @param userWalletId selected user wallet id + */ + suspend fun getLastSwappedCryptoCurrencyId(userWalletId: UserWalletId): String? +} \ No newline at end of file diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairsUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairsUseCase.kt new file mode 100644 index 0000000000..d0a1cd0bf2 --- /dev/null +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairsUseCase.kt @@ -0,0 +1,80 @@ +package com.tangem.domain.swap.usecase + +import arrow.core.Either +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.SwapErrorResolver +import com.tangem.domain.swap.SwapRepositoryV2 +import com.tangem.domain.swap.models.SwapCryptoCurrency +import com.tangem.domain.swap.models.SwapCurrencies +import com.tangem.domain.swap.models.SwapCurrenciesGroup +import com.tangem.domain.swap.models.SwapPairModel +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet + +/** + * Get list of swap pairs + */ +class GetSwapPairsUseCase( + private val swapRepositoryV2: SwapRepositoryV2, + private val swapErrorResolver: SwapErrorResolver, +) { + + /** + * @param userWallet selected user wallet + * @param initialCurrency initial currency to swap (to or from) + * @param cryptoCurrencyStatusList list of added cryptocurrencies + */ + suspend operator fun invoke( + userWallet: UserWallet, + initialCurrency: CryptoCurrency, + cryptoCurrencyStatusList: List, + ) = Either.catch { + val pairs = swapRepositoryV2.getPairs( + userWallet = userWallet, + initialCurrency = initialCurrency, + cryptoCurrencyStatusList = cryptoCurrencyStatusList, + ) + + val fromGroup = pairs.groupPairs( + initialCurrency = initialCurrency, + filteringCurrency = { it.from }, + groupingCurrency = { it.to }, + cryptoCurrencyStatusList = cryptoCurrencyStatusList, + ) + val toGroup = pairs.groupPairs( + initialCurrency = initialCurrency, + filteringCurrency = { it.to }, + groupingCurrency = { it.from }, + cryptoCurrencyStatusList = cryptoCurrencyStatusList, + ) + + SwapCurrencies( + fromGroup = fromGroup, + toGroup = toGroup, + ) + }.mapLeft(swapErrorResolver::resolve) + + private fun List.groupPairs( + initialCurrency: CryptoCurrency, + filteringCurrency: (SwapPairModel) -> CryptoCurrencyStatus, + groupingCurrency: (SwapPairModel) -> CryptoCurrencyStatus, + cryptoCurrencyStatusList: List, + ): SwapCurrenciesGroup { + val availableCryptoCurrencies = filter { pair -> filteringCurrency(pair).currency.id == initialCurrency.id } + // Search available to swap currency + .filter { pair -> + cryptoCurrencyStatusList.any { currencyStatus -> + currencyStatus.currency.id == pair.to.currency.id + } + }.map { pair -> SwapCryptoCurrency(groupingCurrency(pair), pair.providers) } + + val unavailableCryptoCurrencies = + cryptoCurrencyStatusList - availableCryptoCurrencies.map { it.currencyStatus }.toSet() + + return SwapCurrenciesGroup( + available = availableCryptoCurrencies, + unavailable = unavailableCryptoCurrencies.map { pair -> SwapCryptoCurrency(pair, emptyList()) }, + afterSearch = false, + ) + } +} \ No newline at end of file diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapQuoteUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapQuoteUseCase.kt new file mode 100644 index 0000000000..f0594c8f02 --- /dev/null +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapQuoteUseCase.kt @@ -0,0 +1,45 @@ +package com.tangem.domain.swap.usecase + +import arrow.core.Either +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.SwapErrorResolver +import com.tangem.domain.swap.SwapRepositoryV2 +import com.tangem.domain.swap.models.SwapQuoteModel +import com.tangem.domain.wallets.models.UserWallet +import java.math.BigDecimal + +/** + * Get swap quote for selected pair + */ +class GetSwapQuoteUseCase( + private val swapRepositoryV2: SwapRepositoryV2, + private val swapErrorResolver: SwapErrorResolver, +) { + + /** + * @param userWallet selected user wallet + * @param fromCryptoCurrency currency swap from + * @param toCryptoCurrency currency swap to + * @param fromAmount swap amount + * @param provider swap provider + */ + suspend operator fun invoke( + userWallet: UserWallet, + fromCryptoCurrency: CryptoCurrency, + toCryptoCurrency: CryptoCurrency, + fromAmount: BigDecimal, + provider: ExpressProvider, + ): Either = Either.catch { + swapRepositoryV2.getSwapQuote( + userWallet = userWallet, + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + fromAmount = fromAmount, + provider = provider, + rateType = ExpressRateType.Float, // todo rate type + ) + }.mapLeft(swapErrorResolver::resolve) +} \ No newline at end of file diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCase.kt new file mode 100644 index 0000000000..1954bc4f85 --- /dev/null +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCase.kt @@ -0,0 +1,85 @@ +package com.tangem.domain.swap.usecase + +import arrow.core.Either +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.SwapErrorResolver +import com.tangem.domain.swap.SwapRepositoryV2 +import com.tangem.domain.swap.models.SwapCryptoCurrency +import com.tangem.domain.swap.models.SwapCurrencies +import com.tangem.domain.swap.models.SwapCurrenciesGroup +import com.tangem.domain.swap.models.SwapPairModel +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet + +/** + * Returns pais + */ +class GetSwapSupportedPairsUseCase( + private val swapRepositoryV2: SwapRepositoryV2, + private val swapErrorResolver: SwapErrorResolver, +) { + + suspend operator fun invoke( + userWallet: UserWallet, + initialCurrency: CryptoCurrency, + cryptoCurrencyList: List, + ) = Either.catch { + val pairs = swapRepositoryV2.getPairsOnly( + userWallet = userWallet, + initialCurrency = initialCurrency, + cryptoCurrencyList = cryptoCurrencyList, + ) + + val filteredOutInitial = cryptoCurrencyList.filterNot { it.id == initialCurrency.id } + + val fromGroup = pairs.groupPairs( + initialCurrency = initialCurrency, + filteringCurrency = { it.from }, + groupingCurrency = { it.to }, + cryptoCurrencyList = filteredOutInitial, + ) + val toGroup = pairs.groupPairs( + initialCurrency = initialCurrency, + filteringCurrency = { it.to }, + groupingCurrency = { it.from }, + cryptoCurrencyList = filteredOutInitial, + ) + + SwapCurrencies( + fromGroup = fromGroup, + toGroup = toGroup, + ) + }.mapLeft(swapErrorResolver::resolve) + + private fun List.groupPairs( + initialCurrency: CryptoCurrency, + filteringCurrency: (SwapPairModel) -> CryptoCurrencyStatus, + groupingCurrency: (SwapPairModel) -> CryptoCurrencyStatus, + cryptoCurrencyList: List, + ): SwapCurrenciesGroup { + val availableCryptoCurrencies = filter { pair -> filteringCurrency(pair).currency.id == initialCurrency.id } + // Search available to swap currency + .filter { pair -> + cryptoCurrencyList.any { currencyStatus -> + currencyStatus.id == pair.to.currency.id + } + }.map { pair -> SwapCryptoCurrency(groupingCurrency(pair), pair.providers) } + + val unavailableCryptoCurrencies = + cryptoCurrencyList - availableCryptoCurrencies.map { it.currencyStatus.currency }.toSet() + + return SwapCurrenciesGroup( + available = availableCryptoCurrencies, + unavailable = unavailableCryptoCurrencies.map { currency -> + SwapCryptoCurrency( + CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, // todo select token unavailable + ), + emptyList(), + ) + }, + afterSearch = false, + ) + } +} \ No newline at end of file diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SelectInitialPairUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SelectInitialPairUseCase.kt new file mode 100644 index 0000000000..f348bdc52a --- /dev/null +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SelectInitialPairUseCase.kt @@ -0,0 +1,71 @@ +package com.tangem.domain.swap.usecase + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.SwapTransactionRepository +import com.tangem.domain.swap.models.SwapCurrencies +import com.tangem.domain.swap.models.SwapCurrenciesGroup +import com.tangem.domain.swap.models.SwapDirection +import com.tangem.domain.swap.models.getGroupWithReverse +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.utils.extensions.orZero + +/** + * Select initial pair to swap + */ +class SelectInitialPairUseCase( + private val swapTransactionRepository: SwapTransactionRepository, +) { + + /** + * @param userWallet selected user wallet + * @param primaryCryptoCurrency initial currency + * @param secondaryCryptoCurrency selected currency + * @param swapCurrencies list of currencies with providers + * @param swapDirection swap direction + */ + suspend operator fun invoke( + userWallet: UserWallet, + primaryCryptoCurrency: CryptoCurrency, + secondaryCryptoCurrency: CryptoCurrency?, + swapCurrencies: SwapCurrencies, + swapDirection: SwapDirection, + ): CryptoCurrencyStatus? { + val swapCurrenciesGroup = swapCurrencies.getGroupWithReverse(swapDirection) + return tryToGetAlreadySelectedCurrency(secondaryCryptoCurrency, swapCurrenciesGroup) + ?: tryGetFromCache(userWallet, primaryCryptoCurrency, swapCurrenciesGroup) + ?: tryGetWithMaxAmount(swapCurrenciesGroup) + ?: swapCurrenciesGroup.available.firstOrNull()?.currencyStatus + } + + private fun tryToGetAlreadySelectedCurrency( + secondaryCryptoCurrency: CryptoCurrency?, + swapCurrenciesGroup: SwapCurrenciesGroup, + ): CryptoCurrencyStatus? { + return secondaryCryptoCurrency?.let { + swapCurrenciesGroup.available.firstOrNull { + secondaryCryptoCurrency.id == it.currencyStatus.currency.id + }?.currencyStatus + } + } + + private suspend fun tryGetFromCache( + userWallet: UserWallet, + primaryCryptoCurrency: CryptoCurrency, + swapCurrenciesGroup: SwapCurrenciesGroup, + ): CryptoCurrencyStatus? { + val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(userWallet.walletId) ?: return null + + return if (id != primaryCryptoCurrency.id.value) { + swapCurrenciesGroup.available.find { it.currencyStatus.currency.id.value == id }?.currencyStatus + } else { + null + } + } + + private fun tryGetWithMaxAmount(swapCurrenciesGroup: SwapCurrenciesGroup): CryptoCurrencyStatus? { + return swapCurrenciesGroup.available.maxByOrNull { + it.currencyStatus.value.fiatAmount.orZero() + }?.currencyStatus + } +} \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt deleted file mode 100644 index a92dfb0ab3..0000000000 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.tangem.domain.tokens.model - -import com.tangem.domain.models.StatusSource -import com.tangem.domain.models.currency.CryptoCurrency -import java.math.BigDecimal - -sealed interface Quote { - - val rawCurrencyId: CryptoCurrency.RawID - - fun copySealed(source: StatusSource): Quote { - return when (this) { - is Empty -> this - is Value -> copy(source = source) - } - } - - /** - * Represents unknown financial information for a specific cryptocurrency. - * - * @property rawCurrencyId The raw cryptocurrency ID. - */ - data class Empty(override val rawCurrencyId: CryptoCurrency.RawID) : Quote - - /** - * Represents financial information for a specific cryptocurrency, including its fiat exchange rate and price change. - * - * @property rawCurrencyId The unique identifier of the cryptocurrency for which the financial information is provided. - * @property fiatRate The current fiat exchange rate for the cryptocurrency. - * @property priceChange The price change for the cryptocurrency. - * @property source source of data - */ - data class Value( - override val rawCurrencyId: CryptoCurrency.RawID, - val fiatRate: BigDecimal, - val priceChange: BigDecimal, - val source: StatusSource, - ) : Quote -} \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/ScenarioUnavailabilityReason.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/ScenarioUnavailabilityReason.kt index 02f95ce1e1..bf816df605 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/ScenarioUnavailabilityReason.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/ScenarioUnavailabilityReason.kt @@ -54,6 +54,8 @@ sealed class ScenarioUnavailabilityReason { data object UnassociatedAsset : ScenarioUnavailabilityReason() + data object TrustlineRequired : ScenarioUnavailabilityReason() + enum class WithdrawalScenario { SELL, SEND // TODO staking create&process STAKING } diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Token.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Token.kt index 9c088e4fce..3e8451b1ed 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Token.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Token.kt @@ -1,12 +1,14 @@ package com.tangem.domain.tokens.model +import com.tangem.domain.models.quote.QuoteStatus + /** * Represents a domain model for a token (as part of a general list of tokens). * * @property id The unique identifier of the token. * @property networks List of networks associated with the token. * @property isAvailable Indicates whether this token is supported in Tangem app. - * @property quote Equivalent prices for the token. + * @property quoteStatus Equivalent prices for the token. * @property name The name of the token. * @property symbol The brief name of the token, e.g., "BTC". * @property iconUrl URL of the token's icon. @@ -15,7 +17,7 @@ data class Token( val id: String, val networks: List, val isAvailable: Boolean, - val quote: Quote?, + val quoteStatus: QuoteStatus?, val name: String, val symbol: String, val iconUrl: String, 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 844923dee0..54eac28f44 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 @@ -167,6 +167,7 @@ sealed class TokenScreenAnalyticsEvent( private const val SINGLE_WALLET = "Single Wallet" private const val LOADING = "Loading" private const val ASSET_REQUIREMENT = "AssetRequirement" + private const val TRUSTLINE_REQUIREMENT = "TrustlineRequirement" @Suppress("CyclomaticComplexMethod") fun ScenarioUnavailabilityReason.toReasonAnalyticsText(): String { @@ -177,6 +178,7 @@ sealed class TokenScreenAnalyticsEvent( is ScenarioUnavailabilityReason.StakingUnavailable, -> UNAVAILABLE ScenarioUnavailabilityReason.UnassociatedAsset -> ASSET_REQUIREMENT + ScenarioUnavailabilityReason.TrustlineRequired -> TRUSTLINE_REQUIREMENT is ScenarioUnavailabilityReason.EmptyBalance -> EMPTY is ScenarioUnavailabilityReason.PendingTransaction -> PENDING ScenarioUnavailabilityReason.UsedOutdatedData -> CACHING diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt index f76e272a6b..083b0aba95 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt @@ -61,4 +61,11 @@ sealed class CryptoCurrencyWarning { ) : CryptoCurrencyWarning() data object UsedOutdatedDataWarning : CryptoCurrencyWarning() + + data class RequiredTrustline( + val currency: CryptoCurrency, + val currencySymbol: String, + val requiredAmount: BigDecimal, + val currencyDecimals: Int, + ) : CryptoCurrencyWarning() } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt index bbb70459eb..290a8de379 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt @@ -7,7 +7,7 @@ import arrow.core.raise.either import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher -import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.staking.single.SingleYieldBalanceFetcher import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -28,7 +28,7 @@ class AddCryptoCurrenciesUseCase( private val currenciesRepository: CurrenciesRepository, private val stakingRepository: StakingRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - private val multiQuoteFetcher: MultiQuoteFetcher, + private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, private val singleYieldBalanceFetcher: SingleYieldBalanceFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -162,8 +162,8 @@ class AddCryptoCurrenciesUseCase( } private suspend fun refreshUpdatedQuotes(currencyToAdd: CryptoCurrency) { - multiQuoteFetcher( - params = MultiQuoteFetcher.Params( + multiQuoteStatusFetcher( + params = MultiQuoteStatusFetcher.Params( currenciesIds = setOfNotNull(currencyToAdd.id.rawCurrencyId), appCurrencyId = null, ), 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 8d69b8ab21..2d45549280 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 @@ -7,7 +7,7 @@ import arrow.core.raise.either import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher -import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.error.TokenListError @@ -22,7 +22,7 @@ class FetchCardTokenListUseCase( private val currenciesRepository: CurrenciesRepository, private val stakingRepository: StakingRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - private val multiQuoteFetcher: MultiQuoteFetcher, + private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -82,8 +82,8 @@ class FetchCardTokenListUseCase( } private suspend fun fetchQuotes(currenciesIds: Set) { - multiQuoteFetcher( - params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null), + multiQuoteStatusFetcher( + params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null), ) } 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 8fea6820d1..c4d88e77f7 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 @@ -8,7 +8,7 @@ import arrow.core.right import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.networks.single.SingleNetworkStatusFetcher -import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.staking.single.SingleYieldBalanceFetcher import com.tangem.domain.tokens.error.CurrencyStatusError @@ -31,7 +31,7 @@ class FetchCurrencyStatusUseCase( private val currenciesRepository: CurrenciesRepository, private val stakingRepository: StakingRepository, private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, - private val multiQuoteFetcher: MultiQuoteFetcher, + private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, private val singleYieldBalanceFetcher: SingleYieldBalanceFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -121,8 +121,8 @@ class FetchCurrencyStatusUseCase( } private suspend fun fetchQuote(currencyId: CryptoCurrency.ID): Either { - return multiQuoteFetcher( - params = MultiQuoteFetcher.Params( + return multiQuoteStatusFetcher( + params = MultiQuoteStatusFetcher.Params( currenciesIds = setOfNotNull(currencyId.rawCurrencyId), appCurrencyId = null, ), 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 782de0af6f..7ca78e63e9 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 @@ -9,7 +9,7 @@ import arrow.core.toNonEmptyListOrNull import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher -import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.error.TokenListError @@ -32,7 +32,7 @@ class FetchTokenListUseCase( private val currenciesRepository: CurrenciesRepository, private val stakingRepository: StakingRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - private val multiQuoteFetcher: MultiQuoteFetcher, + private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -111,8 +111,8 @@ class FetchTokenListUseCase( } private suspend fun Raise.fetchQuotes(currenciesIds: Set) { - multiQuoteFetcher( - params = MultiQuoteFetcher.Params( + multiQuoteStatusFetcher( + params = MultiQuoteStatusFetcher.Params( currenciesIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet(), appCurrencyId = null, ), 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 a6d2f178bb..1226448965 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 @@ -372,12 +372,14 @@ class GetCryptoCurrencyActionsUseCase( } private fun getReceiveScenario(requirements: AssetRequirementsCondition?): ScenarioUnavailabilityReason { - return if (requirements is AssetRequirementsCondition.PaidTransaction || - requirements is AssetRequirementsCondition.PaidTransactionWithFee - ) { - ScenarioUnavailabilityReason.UnassociatedAsset - } else { - ScenarioUnavailabilityReason.None + return when (requirements) { + AssetRequirementsCondition.PaidTransaction, + is AssetRequirementsCondition.PaidTransactionWithFee, + -> ScenarioUnavailabilityReason.UnassociatedAsset + is AssetRequirementsCondition.IncompleteTransaction, + null, + -> ScenarioUnavailabilityReason.None + is AssetRequirementsCondition.RequiredTrustline -> ScenarioUnavailabilityReason.TrustlineRequired } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt index fbc6e1efdc..b02e4b6dda 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt @@ -222,6 +222,12 @@ class GetCurrencyWarningsUseCase( ): CryptoCurrencyWarning? { return when (val requirements = walletManagersFacade.getAssetRequirements(userWalletId, currency)) { is AssetRequirementsCondition.PaidTransaction -> HederaWarnings.AssociateWarning(currency = currency) + is AssetRequirementsCondition.RequiredTrustline -> CryptoCurrencyWarning.RequiredTrustline( + currency = currency, + requiredAmount = requirements.requiredAmount, + currencyDecimals = requirements.decimals, + currencySymbol = requirements.currencySymbol, + ) is AssetRequirementsCondition.PaidTransactionWithFee -> { HederaWarnings.AssociateWarningWithFee( currency = currency, 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 446ba616af..e9b9d3925b 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 @@ -5,7 +5,7 @@ import arrow.core.getOrElse import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.tokens.error.QuotesError import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.wallets.models.UserWalletId @@ -15,7 +15,7 @@ import kotlinx.coroutines.coroutineScope class RefreshMultiCurrencyWalletQuotesUseCase( private val currenciesRepository: CurrenciesRepository, - private val multiQuoteFetcher: MultiQuoteFetcher, + private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, ) { suspend operator fun invoke(userWalletId: UserWalletId): Either { @@ -45,8 +45,8 @@ class RefreshMultiCurrencyWalletQuotesUseCase( } private suspend fun fetchQuotes(currenciesIds: Set) { - multiQuoteFetcher( - params = MultiQuoteFetcher.Params( + multiQuoteStatusFetcher( + params = MultiQuoteStatusFetcher.Params( currenciesIds = currenciesIds.mapNotNullTo(hashSetOf(), CryptoCurrency.ID::rawCurrencyId), appCurrencyId = null, ), diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt index e24d551194..9e8914bfdf 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt @@ -9,13 +9,14 @@ import com.tangem.domain.core.utils.EitherFlow import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.networks.multi.MultiNetworkStatusProducer import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.networks.single.SingleNetworkStatusProducer import com.tangem.domain.networks.single.SingleNetworkStatusSupplier -import com.tangem.domain.quotes.QuotesRepositoryV2 -import com.tangem.domain.quotes.single.SingleQuoteProducer -import com.tangem.domain.quotes.single.SingleQuoteSupplier +import com.tangem.domain.quotes.QuotesRepository +import com.tangem.domain.quotes.single.SingleQuoteStatusProducer +import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList import com.tangem.domain.staking.repositories.StakingRepository @@ -23,7 +24,6 @@ import com.tangem.domain.staking.single.SingleYieldBalanceProducer import com.tangem.domain.staking.single.SingleYieldBalanceSupplier import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.tokens.model.Quote import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.utils.CurrencyStatusProxyCreator @@ -41,18 +41,18 @@ import kotlinx.coroutines.flow.* @Suppress("LargeClass", "LongParameterList") abstract class BaseCurrencyStatusOperations( private val currenciesRepository: CurrenciesRepository, - private val quotesRepositoryV2: QuotesRepositoryV2, + private val quotesRepository: QuotesRepository, private val stakingRepository: StakingRepository, private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, - private val singleQuoteSupplier: SingleQuoteSupplier, + private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, private val singleYieldBalanceSupplier: SingleYieldBalanceSupplier, private val tokensFeatureToggles: TokensFeatureToggles, ) { protected val currencyStatusProxyCreator = CurrencyStatusProxyCreator(stakingRepository) - protected abstract fun getQuotes(id: CryptoCurrency.RawID): Flow>> + protected abstract fun getQuotes(id: CryptoCurrency.RawID): Flow>> protected abstract suspend fun fetchComponents( userWalletId: UserWalletId, @@ -109,7 +109,7 @@ abstract class BaseCurrencyStatusOperations( combine(quoteFlow, statusFlow, yieldBalanceFlow) { maybeQuote, maybeNetworkStatus, maybeYieldBalance -> currencyStatusProxyCreator.createCurrencyStatus( currency = currency, - maybeQuote = maybeQuote, + maybeQuoteStatus = maybeQuote, maybeNetworkStatus = maybeNetworkStatus, maybeYieldBalance = maybeYieldBalance, ) @@ -118,7 +118,7 @@ abstract class BaseCurrencyStatusOperations( combine(quoteFlow, statusFlow) { maybeQuote, maybeNetworkStatus -> currencyStatusProxyCreator.createCurrencyStatus( currency = currency, - maybeQuote = maybeQuote, + maybeQuoteStatus = maybeQuote, maybeNetworkStatus = maybeNetworkStatus, maybeYieldBalance = null, ) @@ -180,7 +180,7 @@ abstract class BaseCurrencyStatusOperations( } val quote = cryptoCurrencyId.rawCurrencyId?.let { rawId -> - singleQuoteSupplier(params = SingleQuoteProducer.Params(rawCurrencyId = rawId)) + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawId)) .firstOrNull() } ?.right() @@ -199,7 +199,7 @@ abstract class BaseCurrencyStatusOperations( return currencyStatusProxyCreator.createCurrencyStatus( currency = currency, - maybeQuote = quote, + maybeQuoteStatus = quote, maybeNetworkStatus = networkStatuses, maybeYieldBalance = yieldBalances, ) @@ -246,7 +246,7 @@ abstract class BaseCurrencyStatusOperations( val (_, currenciesIds) = getIds(nonEmptyCurrencies) val rawIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet() - val quotes = quotesRepositoryV2.getMultiQuoteSyncOrNull(currenciesIds = rawIds)?.right() + val quotes = quotesRepository.getMultiQuoteSyncOrNull(currenciesIds = rawIds)?.right() val networkStatuses = multiNetworkStatusSupplier( params = MultiNetworkStatusProducer.Params(userWalletId = userWalletId), @@ -276,7 +276,7 @@ abstract class BaseCurrencyStatusOperations( ) val quotes = currency.id.rawCurrencyId?.let { - singleQuoteSupplier(params = SingleQuoteProducer.Params(rawCurrencyId = it)) + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(rawCurrencyId = it)) .firstOrNull() } ?.right() @@ -292,7 +292,7 @@ abstract class BaseCurrencyStatusOperations( return currencyStatusProxyCreator.createCurrencyStatus( currency = currency, - maybeQuote = quotes, + maybeQuoteStatus = quotes, maybeNetworkStatus = networkStatus, maybeYieldBalance = yieldBalances, ) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CachedCurrenciesStatusesOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CachedCurrenciesStatusesOperations.kt index bf69424d0c..b6886643dc 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CachedCurrenciesStatusesOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CachedCurrenciesStatusesOperations.kt @@ -13,15 +13,16 @@ import com.tangem.domain.core.utils.lceError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusProducer import com.tangem.domain.networks.single.SingleNetworkStatusSupplier -import com.tangem.domain.quotes.QuotesRepositoryV2 -import com.tangem.domain.quotes.multi.MultiQuoteFetcher -import com.tangem.domain.quotes.single.SingleQuoteProducer -import com.tangem.domain.quotes.single.SingleQuoteSupplier +import com.tangem.domain.quotes.QuotesRepository +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.domain.quotes.single.SingleQuoteStatusProducer +import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher @@ -31,7 +32,6 @@ import com.tangem.domain.staking.single.SingleYieldBalanceSupplier import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.tokens.model.Quote import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.utils.extractAddress @@ -44,25 +44,25 @@ import kotlinx.coroutines.flow.* @Suppress("LongParameterList", "LargeClass") class CachedCurrenciesStatusesOperations( private val currenciesRepository: CurrenciesRepository, - quotesRepositoryV2: QuotesRepositoryV2, + quotesRepository: QuotesRepository, private val stakingRepository: StakingRepository, private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, - private val multiQuoteFetcher: MultiQuoteFetcher, - private val singleQuoteSupplier: SingleQuoteSupplier, + private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, + private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, private val singleYieldBalanceSupplier: SingleYieldBalanceSupplier, private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) : BaseCurrenciesStatusesOperations, BaseCurrencyStatusOperations( currenciesRepository = currenciesRepository, - quotesRepositoryV2 = quotesRepositoryV2, + quotesRepository = quotesRepository, stakingRepository = stakingRepository, multiNetworkStatusSupplier = multiNetworkStatusSupplier, singleNetworkStatusSupplier = singleNetworkStatusSupplier, - singleQuoteSupplier = singleQuoteSupplier, + singleQuoteStatusSupplier = singleQuoteStatusSupplier, singleYieldBalanceSupplier = singleYieldBalanceSupplier, tokensFeatureToggles = tokensFeatureToggles, ) { @@ -123,7 +123,7 @@ class CachedCurrenciesStatusesOperations( val (networks, currenciesIds) = getIds(currencies) fun createCurrenciesStatuses( - maybeQuotes: Either>?, + maybeQuotes: Either>?, maybeNetworkStatuses: Either>?, maybeYieldBalances: Either?, isUpdating: Boolean, @@ -203,8 +203,8 @@ class CachedCurrenciesStatusesOperations( async { val rawCurrenciesIds = currenciesIds.mapNotNullTo(mutableSetOf()) { it.rawCurrencyId } - multiQuoteFetcher( - params = MultiQuoteFetcher.Params(currenciesIds = rawCurrenciesIds, appCurrencyId = null), + multiQuoteStatusFetcher( + params = MultiQuoteStatusFetcher.Params(currenciesIds = rawCurrenciesIds, appCurrencyId = null), ) }, async { @@ -226,7 +226,7 @@ class CachedCurrenciesStatusesOperations( private fun createCurrenciesStatuses( currencies: NonEmptyList, - maybeQuotes: Either>?, + maybeQuotes: Either>?, maybeNetworkStatuses: Either>?, maybeYieldBalances: Either?, isUpdating: Boolean, @@ -252,7 +252,7 @@ class CachedCurrenciesStatusesOperations( val currencyStatus = currencyStatusProxyCreator.createCurrencyStatus( currency = currency, - quote = quote, + quoteStatus = quote, networkStatus = networkStatus, yieldBalance = yieldBalance, ignoreQuote = quotesRetrievingFailed, @@ -286,7 +286,7 @@ class CachedCurrenciesStatusesOperations( .distinctUntilChanged() } - private fun getQuotes(tokensIds: NonEmptySet): Flow>> { + private fun getQuotes(tokensIds: NonEmptySet): Flow>> { return getQuotesUpdates( rawCurrencyIds = tokensIds.mapNotNullTo( destination = hashSetOf(), @@ -295,11 +295,11 @@ class CachedCurrenciesStatusesOperations( ) } - override fun getQuotes(id: CryptoCurrency.RawID): Flow>> { - return singleQuoteSupplier( - params = SingleQuoteProducer.Params(rawCurrencyId = id), + override fun getQuotes(id: CryptoCurrency.RawID): Flow>> { + return singleQuoteStatusSupplier( + params = SingleQuoteStatusProducer.Params(rawCurrencyId = id), ) - .map>> { setOf(it).right() } + .map>> { setOf(it).right() } .distinctUntilChanged() } @@ -355,14 +355,16 @@ class CachedCurrenciesStatusesOperations( } // temporary code because token list is built using networks list - private fun getQuotesUpdates(rawCurrencyIds: Set): EitherFlow> { + private fun getQuotesUpdates( + rawCurrencyIds: Set, + ): EitherFlow> { return channelFlow { - val state = MutableStateFlow(emptySet()) + val state = MutableStateFlow(emptySet()) rawCurrencyIds.onEach { launch { - singleQuoteSupplier( - params = SingleQuoteProducer.Params(rawCurrencyId = it), + singleQuoteStatusSupplier( + params = SingleQuoteStatusProducer.Params(rawCurrencyId = it), ) .onEach { quote -> state.update { loadedStatuses -> @@ -377,7 +379,7 @@ class CachedCurrenciesStatusesOperations( .onEach(::send) .launchIn(scope = this) } - .map, Either>> { it.right() } + .map, Either>> { it.right() } .distinctUntilChanged() } 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 f890486cc2..82c396b43b 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 @@ -3,30 +3,24 @@ package com.tangem.domain.tokens.operations import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.tokens.model.Quote import java.math.BigDecimal internal class CurrencyStatusOperations( private val currency: CryptoCurrency, - private val quote: Quote?, + private val quoteStatus: QuoteStatus?, private val networkStatus: NetworkStatus?, private val yieldBalance: YieldBalance?, private val ignoreQuote: Boolean, ) { - private val Quote?.fiatRate: BigDecimal? - get() = when (this) { - is Quote.Value -> this.fiatRate - is Quote.Empty, null -> null - } + private val QuoteStatus?.fiatRate: BigDecimal? + get() = (this?.value as? QuoteStatus.Data)?.fiatRate - private val Quote?.priceChange: BigDecimal? - get() = when (this) { - is Quote.Value -> this.priceChange - is Quote.Empty, null -> null - } + private val QuoteStatus?.priceChange: BigDecimal? + get() = (this?.value as? QuoteStatus.Data)?.priceChange fun createTokenStatus(): CryptoCurrencyStatus = CryptoCurrencyStatus(currency, createStatus()) @@ -41,12 +35,12 @@ internal class CurrencyStatusOperations( } private fun createMissedDerivationStatus(): CryptoCurrencyStatus.MissedDerivation = - CryptoCurrencyStatus.MissedDerivation(priceChange = quote?.priceChange, fiatRate = quote?.fiatRate) + CryptoCurrencyStatus.MissedDerivation(priceChange = quoteStatus?.priceChange, fiatRate = quoteStatus?.fiatRate) private fun createUnreachableStatus(status: NetworkStatus.Unreachable): CryptoCurrencyStatus.Unreachable { return CryptoCurrencyStatus.Unreachable( - priceChange = quote?.priceChange, - fiatRate = quote?.fiatRate, + priceChange = quoteStatus?.priceChange, + fiatRate = quoteStatus?.fiatRate, networkAddress = status.address, ) } @@ -54,13 +48,13 @@ internal class CurrencyStatusOperations( private fun createNoAccountStatus(status: NetworkStatus.NoAccount): CryptoCurrencyStatus.NoAccount { return CryptoCurrencyStatus.NoAccount( amountToCreateAccount = status.amountToCreateAccount, - fiatAmount = if (quote == null) null else BigDecimal.ZERO, - priceChange = quote?.priceChange, - fiatRate = quote?.fiatRate, + fiatAmount = if (quoteStatus == null) null else BigDecimal.ZERO, + priceChange = quoteStatus?.priceChange, + fiatRate = quoteStatus?.fiatRate, networkAddress = status.address, sources = CryptoCurrencyStatus.Sources( networkSource = status.source, - quoteSource = (quote as? Quote.Value)?.source ?: StatusSource.ACTUAL, + quoteSource = quoteStatus?.value?.source ?: StatusSource.ACTUAL, ), ) } @@ -75,7 +69,10 @@ internal class CurrencyStatusOperations( return CryptoCurrencyStatus.Loading } is NetworkStatus.Amount.NotFound -> { - return CryptoCurrencyStatus.NoAmount(priceChange = quote?.priceChange, fiatRate = quote?.fiatRate) + return CryptoCurrencyStatus.NoAmount( + priceChange = quoteStatus?.priceChange, + fiatRate = quoteStatus?.fiatRate, + ) } is NetworkStatus.Amount.Loaded -> amount.value } @@ -96,24 +93,27 @@ internal class CurrencyStatusOperations( } else { null } + + val quoteValue = quoteStatus?.value + // order is important for correct total balance calculation return when { currency is CryptoCurrency.Token && currency.isCustom -> CryptoCurrencyStatus.Custom( amount = amount, - fiatAmount = calculateFiatAmountOrNull(amount, quote?.fiatRate), - fiatRate = quote?.fiatRate, - priceChange = quote?.priceChange, + fiatAmount = calculateFiatAmountOrNull(amount, quoteStatus?.fiatRate), + fiatRate = quoteStatus?.fiatRate, + priceChange = quoteStatus?.priceChange, hasCurrentNetworkTransactions = hasCurrentNetworkTransactions, pendingTransactions = currentTransactions, networkAddress = networkStatusValue.address, yieldBalance = currentYieldBalance, sources = CryptoCurrencyStatus.Sources( networkSource = networkStatusValue.source, - quoteSource = (quote as? Quote.Value)?.source ?: StatusSource.ACTUAL, + quoteSource = quoteStatus?.value?.source ?: StatusSource.ACTUAL, yieldBalanceSource = currentYieldBalance?.source ?: StatusSource.ACTUAL, ), ) - quote is Quote.Empty || ignoreQuote -> CryptoCurrencyStatus.NoQuote( + quoteValue is QuoteStatus.Empty || ignoreQuote -> CryptoCurrencyStatus.NoQuote( amount = amount, hasCurrentNetworkTransactions = hasCurrentNetworkTransactions, pendingTransactions = currentTransactions, @@ -121,22 +121,22 @@ internal class CurrencyStatusOperations( yieldBalance = currentYieldBalance, sources = CryptoCurrencyStatus.Sources( networkSource = networkStatusValue.source, - quoteSource = (quote as? Quote.Value)?.source ?: StatusSource.ACTUAL, + quoteSource = quoteStatus?.value?.source ?: StatusSource.ACTUAL, yieldBalanceSource = currentYieldBalance?.source ?: StatusSource.ACTUAL, ), ) - quote is Quote.Value -> CryptoCurrencyStatus.Loaded( + quoteValue is QuoteStatus.Data -> CryptoCurrencyStatus.Loaded( amount = amount, - fiatAmount = calculateFiatAmount(amount, quote.fiatRate), - fiatRate = quote.fiatRate, - priceChange = quote.priceChange, + fiatAmount = calculateFiatAmount(amount, quoteValue.fiatRate), + fiatRate = quoteValue.fiatRate, + priceChange = quoteValue.priceChange, hasCurrentNetworkTransactions = hasCurrentNetworkTransactions, pendingTransactions = currentTransactions, networkAddress = networkStatusValue.address, yieldBalance = currentYieldBalance, sources = CryptoCurrencyStatus.Sources( networkSource = networkStatusValue.source, - quoteSource = quote.source, + quoteSource = quoteValue.source, yieldBalanceSource = currentYieldBalance?.source ?: StatusSource.ACTUAL, ), ) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/utils/CurrencyStatusProxyCreator.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/utils/CurrencyStatusProxyCreator.kt index 337ffb44db..0dca67d61a 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/utils/CurrencyStatusProxyCreator.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/utils/CurrencyStatusProxyCreator.kt @@ -6,11 +6,11 @@ import arrow.core.raise.either import arrow.core.toNonEmptySetOrNull import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.tokens.model.Quote import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error import com.tangem.domain.tokens.operations.CurrencyStatusOperations @@ -27,14 +27,14 @@ class CurrencyStatusProxyCreator( fun createCurrencyStatus( currency: CryptoCurrency, - maybeQuote: Either, + maybeQuoteStatus: Either, maybeNetworkStatus: Either, maybeYieldBalance: Either?, ): Either = either { var quoteRetrievingFailed = false val networkStatus = maybeNetworkStatus.bind() - val quote = arrow.core.raise.recover({ maybeQuote.bind() }) { + val quote = arrow.core.raise.recover({ maybeQuoteStatus.bind() }) { quoteRetrievingFailed = true null } @@ -42,7 +42,7 @@ class CurrencyStatusProxyCreator( createCurrencyStatus( currency = currency, - quote = quote, + quoteStatus = quote, networkStatus = networkStatus, ignoreQuote = quoteRetrievingFailed, yieldBalance = yieldBalance, @@ -51,14 +51,14 @@ class CurrencyStatusProxyCreator( fun createCurrenciesStatuses( currencies: NonEmptyList, - maybeQuotes: Either>?, + maybeQuotes: Either>?, maybeNetworkStatuses: Either>?, maybeYieldBalances: Either?, ): Either> = either { var quotesRetrievingFailed = false val networksStatuses = maybeNetworkStatuses?.bind()?.toNonEmptySetOrNull() - val quotes: Set? = maybeQuotes?.fold( + val quoteStatuses: Set? = maybeQuotes?.fold( ifLeft = { quotesRetrievingFailed = true null @@ -74,7 +74,7 @@ class CurrencyStatusProxyCreator( val yieldBalances = maybeYieldBalances?.getOrNull() currencies.map { currency -> - val quote = quotes?.firstOrNull { it.rawCurrencyId == currency.id.rawCurrencyId } + val quote = quoteStatuses?.firstOrNull { it.rawCurrencyId == currency.id.rawCurrencyId } val networkStatus = networksStatuses?.firstOrNull { it.network == currency.network } val address = extractAddress(networkStatus) @@ -89,7 +89,7 @@ class CurrencyStatusProxyCreator( } createCurrencyStatus( currency = currency, - quote = quote, + quoteStatus = quote, networkStatus = networkStatus, ignoreQuote = quotesRetrievingFailed, yieldBalance = yieldBalance, @@ -99,14 +99,14 @@ class CurrencyStatusProxyCreator( fun createCurrencyStatus( currency: CryptoCurrency, - quote: Quote?, + quoteStatus: QuoteStatus?, networkStatus: NetworkStatus?, ignoreQuote: Boolean, yieldBalance: YieldBalance?, ): CryptoCurrencyStatus { val currencyStatusOperations = CurrencyStatusOperations( currency = currency, - quote = quote, + quoteStatus = quoteStatus, networkStatus = networkStatus, ignoreQuote = ignoreQuote, yieldBalance = yieldBalance, diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockQuotes.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockQuotes.kt index 0d786ca54c..12b9de0cbc 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockQuotes.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockQuotes.kt @@ -3,83 +3,103 @@ package com.tangem.domain.tokens.mock import arrow.core.nonEmptySetOf import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.models.quote.QuoteStatus import java.math.BigDecimal @Suppress("MemberVisibilityCanBePrivate") internal object MockQuotes { - val quote1 = Quote.Value( + val quote1 = QuoteStatus( rawCurrencyId = MockTokens.token1.id.rawCurrencyId!!, - fiatRate = BigDecimal("1.23"), - priceChange = BigDecimal("0.01"), - source = StatusSource.ACTUAL, + value = QuoteStatus.Data( + fiatRate = BigDecimal("1.23"), + priceChange = BigDecimal("0.01"), + source = StatusSource.ACTUAL, + ), ) - val quote2 = Quote.Value( + val quote2 = QuoteStatus( rawCurrencyId = MockTokens.token2.id.rawCurrencyId!!, - fiatRate = BigDecimal("2.34"), - priceChange = BigDecimal("-0.02"), - source = StatusSource.ACTUAL, + value = QuoteStatus.Data( + fiatRate = BigDecimal("2.34"), + priceChange = BigDecimal("-0.02"), + source = StatusSource.ACTUAL, + ), ) - val quote3 = Quote.Value( + val quote3 = QuoteStatus( rawCurrencyId = MockTokens.token3.id.rawCurrencyId!!, - fiatRate = BigDecimal("3.45"), - priceChange = BigDecimal("0.03"), - source = StatusSource.ACTUAL, + value = QuoteStatus.Data( + fiatRate = BigDecimal("3.45"), + priceChange = BigDecimal("0.03"), + source = StatusSource.ACTUAL, + ), ) - val quote4 = Quote.Value( + val quote4 = QuoteStatus( rawCurrencyId = MockTokens.token4.id.rawCurrencyId!!, - fiatRate = BigDecimal("4.56"), - priceChange = BigDecimal("-0.04"), - source = StatusSource.ACTUAL, + value = QuoteStatus.Data( + fiatRate = BigDecimal("4.56"), + priceChange = BigDecimal("-0.04"), + source = StatusSource.ACTUAL, + ), ) - val quote5 = Quote.Value( + val quote5 = QuoteStatus( rawCurrencyId = MockTokens.token5.id.rawCurrencyId!!, - fiatRate = BigDecimal("5.67"), - priceChange = BigDecimal("0.05"), - source = StatusSource.ACTUAL, + value = QuoteStatus.Data( + fiatRate = BigDecimal("5.67"), + priceChange = BigDecimal("0.05"), + source = StatusSource.ACTUAL, + ), ) - val quote6 = Quote.Value( + val quote6 = QuoteStatus( rawCurrencyId = MockTokens.token6.id.rawCurrencyId!!, - fiatRate = BigDecimal("6.78"), - priceChange = BigDecimal("-0.06"), - source = StatusSource.ACTUAL, + value = QuoteStatus.Data( + fiatRate = BigDecimal("6.78"), + priceChange = BigDecimal("-0.06"), + source = StatusSource.ACTUAL, + ), ) - val quote7 = Quote.Value( + val quote7 = QuoteStatus( rawCurrencyId = MockTokens.token7.id.rawCurrencyId!!, - fiatRate = BigDecimal("7.89"), - priceChange = BigDecimal("0.07"), - source = StatusSource.ACTUAL, + value = QuoteStatus.Data( + fiatRate = BigDecimal("7.89"), + priceChange = BigDecimal("0.07"), + source = StatusSource.ACTUAL, + ), ) - val quote8 = Quote.Value( + val quote8 = QuoteStatus( rawCurrencyId = MockTokens.token8.id.rawCurrencyId!!, - fiatRate = BigDecimal("8.90"), - priceChange = BigDecimal("-0.08"), - source = StatusSource.ACTUAL, + value = QuoteStatus.Data( + fiatRate = BigDecimal("8.90"), + priceChange = BigDecimal("-0.08"), + source = StatusSource.ACTUAL, + ), ) - val quote9 = Quote.Value( + val quote9 = QuoteStatus( rawCurrencyId = MockTokens.token9.id.rawCurrencyId!!, - fiatRate = BigDecimal("9.01"), - priceChange = BigDecimal("0.09"), - source = StatusSource.ACTUAL, + value = QuoteStatus.Data( + fiatRate = BigDecimal("9.01"), + priceChange = BigDecimal("0.09"), + source = StatusSource.ACTUAL, + ), ) - val quote10 = Quote.Value( + val quote10 = QuoteStatus( rawCurrencyId = MockTokens.token10.id.rawCurrencyId!!, - fiatRate = BigDecimal("10.12"), - priceChange = BigDecimal("-0.10"), - source = StatusSource.ACTUAL, + value = QuoteStatus.Data( + fiatRate = BigDecimal("10.12"), + priceChange = BigDecimal("-0.10"), + source = StatusSource.ACTUAL, + ), ) - val quote11 = Quote.Empty(CryptoCurrency.RawID("null")) + val quote11 = QuoteStatus(rawCurrencyId = CryptoCurrency.RawID("null"), value = QuoteStatus.Empty) val quotes = nonEmptySetOf( quote1, quote2, quote3, quote4, quote5, quote6, quote7, quote8, quote9, quote10, 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 c3f573af01..fddd277f37 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 @@ -3,8 +3,9 @@ package com.tangem.domain.tokens.mock import arrow.core.nonEmptyListOf import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.quote.fold import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.tokens.model.Quote import java.math.BigDecimal @Suppress("MemberVisibilityCanBePrivate") @@ -13,8 +14,8 @@ internal object MockTokensStates { val tokenState1 = CryptoCurrencyStatus( currency = MockTokens.token1, value = CryptoCurrencyStatus.Unreachable( - priceChange = MockQuotes.quote1.priceChange, - fiatRate = MockQuotes.quote1.fiatRate, + priceChange = MockQuotes.quote1.getPriceChange(), + fiatRate = MockQuotes.quote1.getFiatRate(), networkAddress = NetworkAddress.Single( defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), ), @@ -24,8 +25,8 @@ internal object MockTokensStates { val tokenState2 = CryptoCurrencyStatus( currency = MockTokens.token2, value = CryptoCurrencyStatus.Unreachable( - priceChange = MockQuotes.quote2.priceChange, - fiatRate = MockQuotes.quote2.fiatRate, + priceChange = MockQuotes.quote2.getPriceChange(), + fiatRate = MockQuotes.quote2.getFiatRate(), networkAddress = NetworkAddress.Single( defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), ), @@ -35,8 +36,8 @@ internal object MockTokensStates { val tokenState3 = CryptoCurrencyStatus( currency = MockTokens.token3, value = CryptoCurrencyStatus.Unreachable( - priceChange = MockQuotes.quote3.priceChange, - fiatRate = MockQuotes.quote3.fiatRate, + priceChange = MockQuotes.quote3.getPriceChange(), + fiatRate = MockQuotes.quote3.getFiatRate(), networkAddress = NetworkAddress.Single( defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), ), @@ -46,24 +47,24 @@ internal object MockTokensStates { val tokenState4 = CryptoCurrencyStatus( currency = MockTokens.token4, value = CryptoCurrencyStatus.MissedDerivation( - priceChange = MockQuotes.quote4.priceChange, - fiatRate = MockQuotes.quote4.fiatRate, + priceChange = MockQuotes.quote4.getPriceChange(), + fiatRate = MockQuotes.quote4.getFiatRate(), ), ) val tokenState5 = CryptoCurrencyStatus( currency = MockTokens.token5, value = CryptoCurrencyStatus.MissedDerivation( - priceChange = MockQuotes.quote5.priceChange, - fiatRate = MockQuotes.quote5.fiatRate, + priceChange = MockQuotes.quote5.getPriceChange(), + fiatRate = MockQuotes.quote5.getFiatRate(), ), ) val tokenState6 = CryptoCurrencyStatus( currency = MockTokens.token6, value = CryptoCurrencyStatus.MissedDerivation( - priceChange = MockQuotes.quote6.priceChange, - fiatRate = MockQuotes.quote6.fiatRate, + priceChange = MockQuotes.quote6.getPriceChange(), + fiatRate = MockQuotes.quote6.getFiatRate(), ), ) @@ -71,8 +72,8 @@ internal object MockTokensStates { currency = MockTokens.token7, value = CryptoCurrencyStatus.NoAccount( fiatAmount = BigDecimal.ZERO, - priceChange = MockQuotes.quote7.priceChange, - fiatRate = MockQuotes.quote7.fiatRate, + priceChange = MockQuotes.quote7.getPriceChange(), + fiatRate = MockQuotes.quote7.getFiatRate(), amountToCreateAccount = MockNetworks.amountToCreateAccount, networkAddress = NetworkAddress.Single( defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), @@ -85,8 +86,8 @@ internal object MockTokensStates { currency = MockTokens.token8, value = CryptoCurrencyStatus.NoAccount( fiatAmount = BigDecimal.ZERO, - priceChange = MockQuotes.quote8.priceChange, - fiatRate = MockQuotes.quote8.fiatRate, + priceChange = MockQuotes.quote8.getPriceChange(), + fiatRate = MockQuotes.quote8.getFiatRate(), amountToCreateAccount = MockNetworks.amountToCreateAccount, networkAddress = NetworkAddress.Single( defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), @@ -99,8 +100,8 @@ internal object MockTokensStates { currency = MockTokens.token9, value = CryptoCurrencyStatus.NoAccount( fiatAmount = BigDecimal.ZERO, - priceChange = MockQuotes.quote9.priceChange, - fiatRate = MockQuotes.quote9.fiatRate, + priceChange = MockQuotes.quote9.getPriceChange(), + fiatRate = MockQuotes.quote9.getFiatRate(), amountToCreateAccount = MockNetworks.amountToCreateAccount, networkAddress = NetworkAddress.Single( defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), @@ -113,8 +114,8 @@ internal object MockTokensStates { currency = MockTokens.token10, value = CryptoCurrencyStatus.NoAccount( fiatAmount = BigDecimal.ZERO, - priceChange = MockQuotes.quote10.priceChange, - fiatRate = MockQuotes.quote10.fiatRate, + priceChange = MockQuotes.quote10.getPriceChange(), + fiatRate = MockQuotes.quote10.getFiatRate(), amountToCreateAccount = MockNetworks.amountToCreateAccount, networkAddress = NetworkAddress.Single( defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), @@ -145,8 +146,8 @@ internal object MockTokensStates { )?.value ?: BigDecimal.ZERO val quote = MockQuotes.quotes.first { it.rawCurrencyId == status.currency.id.rawCurrencyId } - val value = when (quote) { - is Quote.Empty -> CryptoCurrencyStatus.NoQuote( + val value = when (val value = quote.value) { + is QuoteStatus.Empty -> CryptoCurrencyStatus.NoQuote( amount = status.value.amount!!, pendingTransactions = emptySet(), hasCurrentNetworkTransactions = false, @@ -156,11 +157,11 @@ internal object MockTokensStates { yieldBalance = null, sources = CryptoCurrencyStatus.Sources(), ) - is Quote.Value -> CryptoCurrencyStatus.Loaded( + is QuoteStatus.Data -> CryptoCurrencyStatus.Loaded( amount = amount, - fiatAmount = amount * quote.fiatRate, - fiatRate = quote.fiatRate, - priceChange = quote.priceChange, + fiatAmount = amount * value.fiatRate, + fiatRate = value.fiatRate, + priceChange = value.priceChange, pendingTransactions = emptySet(), hasCurrentNetworkTransactions = false, networkAddress = requireNotNull(networkStatus.value as? NetworkStatus.Verified).address, @@ -187,4 +188,7 @@ internal object MockTokensStates { ), ) } + + private fun QuoteStatus.getPriceChange(): BigDecimal? = fold(onData = { priceChange }, onEmpty = { null }) + private fun QuoteStatus.getFiatRate(): BigDecimal? = fold(onData = { fiatRate }, onEmpty = { null }) } \ No newline at end of file diff --git a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/models/AssetRequirementsCondition.kt b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/models/AssetRequirementsCondition.kt index 2bb5c1d8fb..5826a800ab 100644 --- a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/models/AssetRequirementsCondition.kt +++ b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/models/AssetRequirementsCondition.kt @@ -9,6 +9,15 @@ sealed class AssetRequirementsCondition { */ data object PaidTransaction : AssetRequirementsCondition() + /** + * Trustlines are an explicit opt-in for an account to hold a particular asset. + */ + data class RequiredTrustline( + val requiredAmount: BigDecimal, + val currencySymbol: String, + val decimals: Int, + ) : AssetRequirementsCondition() + /** * The exact value of the fee for this type of condition is stored in `feeAmount`. */ 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 9825addc50..52a4d262b4 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 @@ -33,6 +33,7 @@ interface TransactionRepository { destination: String, userWalletId: UserWalletId, network: Network, + nonce: BigInteger? = null, ): TransactionData.Uncompiled @Suppress("LongParameterList") diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/OpenTrustlineError.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/OpenTrustlineError.kt new file mode 100644 index 0000000000..a71270bf29 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/OpenTrustlineError.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.transaction.error + +import java.math.BigDecimal + +sealed interface OpenTrustlineError { + val message: String? + + data class SomeError(override val message: String?) : OpenTrustlineError + data class NotEnoughCoin(val amount: BigDecimal, override val message: String?) : OpenTrustlineError +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt index 221f703b9d..291f343eeb 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt @@ -6,6 +6,7 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.models.network.Network import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.wallets.models.UserWalletId +import java.math.BigInteger /** * Use case to create and get transfer transaction @@ -28,11 +29,13 @@ class CreateTransferTransactionUseCase( destination: String, userWalletId: UserWalletId, network: Network, + nonce: BigInteger? = null, ) = Either.catch { transactionRepository.createTransferTransaction( amount = amount, fee = fee, memo = memo, + nonce = nonce, destination = destination, userWalletId = userWalletId, network = network, @@ -49,11 +52,13 @@ class CreateTransferTransactionUseCase( destination: String, userWalletId: UserWalletId, network: Network, + nonce: BigInteger? = null, ) = Either.catch { transactionRepository.createTransferTransaction( amount = amount, memo = memo, fee = null, + nonce = nonce, destination = destination, userWalletId = userWalletId, network = network, diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/OpenTrustlineUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/OpenTrustlineUseCase.kt new file mode 100644 index 0000000000..6f2fc407d4 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/OpenTrustlineUseCase.kt @@ -0,0 +1,46 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.blockchain.extensions.SimpleResult +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.transaction.error.OpenTrustlineError +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWalletId + +class OpenTrustlineUseCase( + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val walletManagersFacade: WalletManagersFacade, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + currency: CryptoCurrency, + ): Either { + return either { + val signer = cardSdkConfigRepository.getCommonSigner( + cardId = null, + twinKey = null, // use null here because no assets support for Twin cards + ) + + catch( + block = { + when (val result = walletManagersFacade.fulfillRequirements(userWalletId, currency, signer)) { + is SimpleResult.Failure -> when (val error = result.error) { + is BlockchainSdkError.Stellar.MinReserveRequired -> + raise(OpenTrustlineError.NotEnoughCoin(error.amount, result.error.customMessage)) + is BlockchainSdkError.Xrp.MinReserveRequired -> + raise(OpenTrustlineError.NotEnoughCoin(error.amount, result.error.customMessage)) + else -> raise(OpenTrustlineError.SomeError(result.error.customMessage)) + } + SimpleResult.Success -> Unit + } + }, + catch = { error -> raise(OpenTrustlineError.SomeError(error.message)) }, + ) + } + } +} \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/KycStartInfo.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/KycStartInfo.kt new file mode 100644 index 0000000000..e568e679f3 --- /dev/null +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/KycStartInfo.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.pay + +data class KycStartInfo( + val token: String, + val locale: String, +) \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaAuthChallenge.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaAuthChallenge.kt index 536b7207dc..eebd03482d 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaAuthChallenge.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaAuthChallenge.kt @@ -23,9 +23,10 @@ fun VisaAuthChallenge.Card.toSignedChallenge(signedChallenge: String, salt: Stri ) } -fun VisaAuthChallenge.Wallet.toSignedChallenge(signedChallenge: String): VisaAuthSignedChallenge { +fun VisaAuthChallenge.Wallet.toSignedChallenge(signedChallenge: String, salt: String): VisaAuthSignedChallenge { return VisaAuthSignedChallenge.ByWallet( challenge = this, signature = signedChallenge, + salt = salt, ) } \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaAuthSignedChallenge.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaAuthSignedChallenge.kt index a16916a887..607dabccbb 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaAuthSignedChallenge.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaAuthSignedChallenge.kt @@ -11,5 +11,6 @@ sealed class VisaAuthSignedChallenge { data class ByWallet( val challenge: VisaAuthChallenge.Wallet, val signature: String, + val salt: String, ) : VisaAuthSignedChallenge() } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt new file mode 100644 index 0000000000..58380aabb0 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.pay.repository + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.pay.KycStartInfo +import com.tangem.domain.wallets.models.UserWalletId + +interface KycRepository { + + suspend fun getKycStartInfo(): Either + + interface Factory { + fun create(userWalletId: UserWalletId): KycRepository + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/visa/repository/VisaAuthRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/visa/repository/VisaAuthRepository.kt index 256587dce1..f7f19ca0e9 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/visa/repository/VisaAuthRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/visa/repository/VisaAuthRepository.kt @@ -13,7 +13,10 @@ interface VisaAuthRepository { cardPublicKey: String, ): Either - suspend fun getCardWalletAuthChallenge(cardWalletAddress: String): Either + suspend fun getCardWalletAuthChallenge( + cardId: String, + cardWalletAddress: String, + ): Either suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): Either diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcApprovedAmount.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcApprovedAmount.kt new file mode 100644 index 0000000000..5b7dc55b23 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcApprovedAmount.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.walletconnect.model + +import com.tangem.domain.tokens.model.Amount + +data class WcApprovedAmount( + val amount: Amount?, + val chainId: Int?, + val logoUrl: String?, +) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthTransactionParams.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthTransactionParams.kt index cdde8975d6..af30e62722 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthTransactionParams.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthTransactionParams.kt @@ -12,7 +12,7 @@ data class WcEthTransactionParams( val to: String?, @Json(name = "data") - val data: String, + val data: String?, @Json(name = "gas") val gas: String?, diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt index 5a060eaaba..005daafedc 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt @@ -1,15 +1,19 @@ package com.tangem.domain.walletconnect.model -sealed class WcPairError(override val message: String) : Exception(message) { +sealed class WcPairError( + val code: String, + override val message: String = "", +) : Exception() { - data object UnsupportedDApp : WcPairError("UnsupportedDApp") - data class UnsupportedNetworks( - val chains: Set, - ) : WcPairError("ApprovalErrorMissingNetworks") - - data class ExternalApprovalError( - override val message: String, - ) : WcPairError("ExternalApprovalError") + data class UriAlreadyUsed(override val message: String) : WcPairError("107 001 001") + data class PairingFailed(override val message: String) : WcPairError("107 001 002") + data object InvalidDomainURL : WcPairError("107 001 003") + data object UnsupportedDomain : WcPairError("107 001 004") + data class UnsupportedBlockchains(val chains: Set) : WcPairError("107 001 005") + data object InvalidConnectionRequest : WcPairError("107 002 001") + data object ProposalExpired : WcPairError("107 002 002") + data class ApprovalFailed(override val message: String) : WcPairError("107 002 003") + data object RejectionFailed : WcPairError("107 002 004") data class Unknown(override val message: String) : WcPairError(message) } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcTransactionUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcTransactionUseCase.kt index 53de930b75..acc4686c4d 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcTransactionUseCase.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcTransactionUseCase.kt @@ -1,8 +1,8 @@ package com.tangem.domain.walletconnect.usecase.method -import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.walletconnect.model.WcApprovedAmount /** * Base UseCase for wc methods with single TransactionData @@ -34,16 +34,16 @@ interface WcListTransactionUseCase : * [updateFee] triggered a new [TransactionData] emit */ interface WcMutableFee { - suspend fun dAppFee(): Fee? + fun dAppFee(): Fee? fun updateFee(fee: Fee) } /** * Wc Approval Method - * If [Amount] is null it means unlimited + * If [WcApprovedAmount.amount] is null it means unlimited * [updateAmount] triggered a new [TransactionData] emit */ interface WcApproval { - suspend fun getAmount(): Amount? - fun updateAmount(amount: Amount?) + fun getAmount(): WcApprovedAmount? + fun updateAmount(amount: WcApprovedAmount?) } \ No newline at end of file diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 950482041e..0dd13a52a2 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :android do desc "Run detekt" lane :detekt do - FileUtils.cp("../ci_resources/tests_8gb_ram_ci_gradle.properties", "../gradle.properties") + FileUtils.cp("../tangem-android-tools/CI/gradle_properties/tests_ci_gradle.properties", "../gradle.properties") puts File.read("../gradle.properties") gradle(task: "detekt") end @@ -27,7 +27,7 @@ platform :android do desc "Run tests" lane :test do FileUtils.cp("../app/src/main/assets/tangem-app-config/android/google-services/dev/google-services.json", "../app") - FileUtils.cp("../ci_resources/tests_8gb_ram_ci_gradle.properties", "../gradle.properties") + FileUtils.cp("../tangem-android-tools/CI/gradle_properties/tests_ci_gradle.properties", "../gradle.properties") puts File.read("../gradle.properties") gradle(task: "testDebugUnitTest") end @@ -36,7 +36,7 @@ platform :android do desc "Build release AAB and APK" lane :buildRelease do |options| FileUtils.cp("../app/src/main/assets/tangem-app-config/android/google-services/release/google-services.json", "../app") - FileUtils.cp("../ci_resources/build_ci_gradle.properties", "../gradle.properties") + FileUtils.cp("../tangem-android-tools/CI/gradle_properties/build_ci_gradle.properties", "../gradle.properties") puts File.read("../gradle.properties") gradle( @@ -69,7 +69,7 @@ platform :android do desc "Build internal APK Firebase App Distribution" lane :buildInternal do |options| FileUtils.cp("../app/src/main/assets/tangem-app-config/android/google-services/dev/google-services.json", "../app") - FileUtils.cp("../ci_resources/build_ci_gradle.properties", "../gradle.properties") + FileUtils.cp("../tangem-android-tools/CI/gradle_properties/build_ci_gradle.properties", "../gradle.properties") puts File.read("../gradle.properties") gradle( diff --git a/features/create-wallet-selection/api/.gitignore b/features/create-wallet-selection/api/.gitignore new file mode 100644 index 0000000000..d3b3de5967 --- /dev/null +++ b/features/create-wallet-selection/api/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/features/create-wallet-selection/api/build.gradle.kts b/features/create-wallet-selection/api/build.gradle.kts new file mode 100644 index 0000000000..2ffb19aeee --- /dev/null +++ b/features/create-wallet-selection/api/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.createwalletselection.api" +} + +dependencies { + + /* Project - Domain */ + implementation(projects.domain.models) + + /* Project - Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /* Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/create-wallet-selection/api/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionComponent.kt b/features/create-wallet-selection/api/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionComponent.kt new file mode 100644 index 0000000000..cadc5950f1 --- /dev/null +++ b/features/create-wallet-selection/api/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionComponent.kt @@ -0,0 +1,8 @@ +package com.tangem.features.createwalletselection + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface CreateWalletSelectionComponent : ComposableContentComponent { + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/create-wallet-selection/impl/.gitignore b/features/create-wallet-selection/impl/.gitignore new file mode 100644 index 0000000000..d3b3de5967 --- /dev/null +++ b/features/create-wallet-selection/impl/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/features/create-wallet-selection/impl/build.gradle.kts b/features/create-wallet-selection/impl/build.gradle.kts new file mode 100644 index 0000000000..828a0ccfac --- /dev/null +++ b/features/create-wallet-selection/impl/build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.createwalletselection.impl" +} + +dependencies { + /** Api */ + implementation(projects.features.createWalletSelection.api) + + /** Hot Wallet Feature */ + implementation(projects.features.hotWallet.api) + + /** Core modules */ + implementation(projects.core.configToggles) + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + implementation(projects.core.utils) + implementation(projects.core.ui) + implementation(projects.core.res) + implementation(projects.core.decompose) + implementation(projects.core.navigation) + implementation(projects.core.datasource) + + /** Common */ + implementation(projects.common.ui) + implementation(projects.common.routing) + + /** Tangem libraries */ + implementation(projects.libs.tangemSdkApi) + implementation(tangemDeps.card.core) + implementation(tangemDeps.card.android) { + exclude(module = "joda-time") + } + + /** AndroidX libraries */ + implementation(deps.androidx.core.ktx) + implementation(deps.lifecycle.runtime.ktx) + + /** Compose libraries */ + implementation(deps.compose.material) + implementation(deps.compose.material3) + implementation(deps.compose.animation) + implementation(deps.compose.foundation) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.compose.coil) + implementation(deps.lottie.compose) + implementation(deps.decompose.ext.compose) + implementation(deps.androidx.activity.compose) + implementation(deps.androidx.datastore) + + /** Other libraries */ + implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) + implementation(deps.kotlin.serialization) + implementation(deps.timber) + implementation(deps.firebase.crashlytics) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionFeatureModule.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionFeatureModule.kt new file mode 100644 index 0000000000..a810f1ba9f --- /dev/null +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionFeatureModule.kt @@ -0,0 +1,30 @@ +package com.tangem.features.createwalletselection + +import com.tangem.core.decompose.model.Model +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object CreateWalletSelectionFeatureModule + +@Module +@InstallIn(SingletonComponent::class) +internal interface CreateWalletSelectionFeatureModuleBinds { + + @Binds + @Singleton + fun bindCreateWalletSelectionComponentFactory( + impl: DefaultCreateWalletSelectionComponent.Factory, + ): CreateWalletSelectionComponent.Factory + + @Binds + @IntoMap + @ClassKey(CreateWalletSelectionModel::class) + fun bindCreateWalletSelectionModel(model: CreateWalletSelectionModel): Model +} \ No newline at end of file diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt new file mode 100644 index 0000000000..0299618954 --- /dev/null +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt @@ -0,0 +1,37 @@ +package com.tangem.features.createwalletselection + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.features.createwalletselection.entity.CreateWalletSelectionUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ModelScoped +internal class CreateWalletSelectionModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + internal val uiState: StateFlow + field = MutableStateFlow( + CreateWalletSelectionUM( + onBackClick = { /* [REDACTED_TODO_COMMENT] */ }, + onMobileWalletClick = ::onMobileWalletClick, + onHardwareWalletClick = ::onHardwareWalletClick, + onScanClick = ::onScanClick, + ), + ) + + private fun onMobileWalletClick() { + // TODO navigate to mobile wallet creation + } + + private fun onHardwareWalletClick() { + // TODO open card order web page + } + + private fun onScanClick() { + // TODO open card scanning + } +} \ No newline at end of file diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/DefaultCreateWalletSelectionComponent.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/DefaultCreateWalletSelectionComponent.kt new file mode 100644 index 0000000000..a76cd26754 --- /dev/null +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/DefaultCreateWalletSelectionComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.createwalletselection + +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.createwalletselection.ui.CreateWalletSelectionContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultCreateWalletSelectionComponent @AssistedInject constructor( + @Assisted private val context: AppComponentContext, + @Assisted private val params: Unit, +) : CreateWalletSelectionComponent, AppComponentContext by context { + + private val model: CreateWalletSelectionModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + CreateWalletSelectionContent( + state = state, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : CreateWalletSelectionComponent.Factory { + override fun create(context: AppComponentContext, params: Unit): DefaultCreateWalletSelectionComponent + } +} \ No newline at end of file diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt new file mode 100644 index 0000000000..70c95a5b38 --- /dev/null +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.createwalletselection.entity + +internal data class CreateWalletSelectionUM( + val isScanInProgress: Boolean = false, + val onBackClick: () -> Unit, + val onMobileWalletClick: () -> Unit, + val onHardwareWalletClick: () -> Unit, + val onScanClick: () -> Unit, +) \ No newline at end of file diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt new file mode 100644 index 0000000000..4c66af5a94 --- /dev/null +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt @@ -0,0 +1,240 @@ +package com.tangem.features.createwalletselection.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.buttons.common.TangemButton +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.buttons.common.TangemButtonSize +import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.createwalletselection.entity.CreateWalletSelectionUM +import com.tangem.features.createwalletselection.impl.R + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifier: Modifier = Modifier) { + Column( + modifier = Modifier + .background(TangemTheme.colors.background.primary) + .fillMaxSize() + .systemBarsPadding(), + ) { + TopAppBar( + modifier = modifier + .statusBarsPadding(), + colors = TopAppBarDefaults.topAppBarColors( + containerColor = TangemTheme.colors.background.primary, + ), + navigationIcon = { + IconButton(onClick = state.onBackClick) { + Icon( + painter = painterResource(R.drawable.ic_back_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + } + }, + title = { }, + actions = { + Text( + modifier = Modifier + .padding(16.dp), + // TODO [REDACTED_TASK_KEY] update and extract this string + text = "What to choose?", + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + Column( + modifier = Modifier + .weight(1f) + .padding( + start = 16.dp, + top = 24.dp, + end = 16.dp, + ), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + // TODO [REDACTED_TASK_KEY] update and extract this string + text = "Choose how you want to create your wallet", + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + WalletBlock( + modifier = Modifier + .padding(top = 24.dp), + // TODO [REDACTED_TASK_KEY] update and extract this string + title = "Mobile Wallet", + // TODO [REDACTED_TASK_KEY] update and extract this string + description = "A secure wallet is created on your phone in seconds.", + badge = { + Box( + modifier = Modifier + .background( + color = TangemTheme.colors.field.focused, + shape = TangemTheme.shapes.roundedCorners8, + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + // TODO [REDACTED_TASK_KEY] update and extract this string + text = "Free", + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.secondary, + ) + } + }, + onClick = state.onMobileWalletClick, + ) + WalletBlock( + // TODO [REDACTED_TASK_KEY] update and extract this string + title = "Hardware Wallet", + // TODO [REDACTED_TASK_KEY] update and extract this string + description = "Buy Tangem Wallet — physical cards that securely store your crypto offline.", + badge = { + Box( + modifier = Modifier + .background( + color = TangemTheme.colors.text.accent.copy(alpha = 0.1f), + shape = TangemTheme.shapes.roundedCorners8, + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + // TODO [REDACTED_TASK_KEY] update and extract this string + text = "From $54.90", + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.accent, + ) + } + }, + onClick = state.onHardwareWalletClick, + ) + } + AlreadyHaveTangemWalletBlock( + onScanClick = state.onScanClick, + isScanInProgress = state.isScanInProgress, + ) + } +} + +@Composable +private fun WalletBlock( + modifier: Modifier = Modifier, + title: String, + description: String, + badge: @Composable () -> Unit, + onClick: () -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(top = 8.dp) + .background( + color = TangemTheme.colors.background.secondary, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .padding(16.dp) + .clickable(onClick = onClick), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + modifier = Modifier + .padding(end = 8.dp), + text = title, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + badge() + } + Text( + modifier = Modifier + .padding(top = 4.dp), + text = description, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +@Composable +private fun AlreadyHaveTangemWalletBlock( + onScanClick: () -> Unit, + isScanInProgress: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + .background( + color = TangemTheme.colors.field.primary, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .padding( + horizontal = 20.dp, + vertical = 16.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + // TODO [REDACTED_TASK_KEY] update and extract this string + text = "Do you already have Tangem Wallet?", + style = TangemTheme.typography.button, + color = TangemTheme.colors.text.primary1, + ) + TangemButton( + modifier = Modifier + .wrapContentWidth(), + // TODO [REDACTED_TASK_KEY] update and extract this string + text = "Scan Now", + onClick = onScanClick, + icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24), + size = TangemButtonSize.RoundedAction, + showProgress = isScanInProgress, + colors = TangemButtonsDefaults.secondaryButtonColors, + textStyle = TangemTheme.typography.subtitle1, + enabled = true, + animateContentChange = true, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewCreateWalletContent() { + TangemThemePreview { + CreateWalletSelectionContent( + state = CreateWalletSelectionUM( + onBackClick = {}, + onMobileWalletClick = {}, + onHardwareWalletClick = {}, + onScanClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/fee-selector/api/build.gradle.kts b/features/fee-selector/api/build.gradle.kts deleted file mode 100644 index f224bf5622..0000000000 --- a/features/fee-selector/api/build.gradle.kts +++ /dev/null @@ -1,27 +0,0 @@ -plugins { - alias(deps.plugins.android.library) - alias(deps.plugins.kotlin.android) - id("configuration") -} - -android { - namespace = "com.tangem.features.feeselector.api" -} - -dependencies { - /** Project - Core */ - implementation(projects.core.ui) - implementation(projects.core.decompose) - - /** Domain models */ - // api(projects.domain.models) - implementation(projects.domain.appCurrency.models) - // implementation(projects.domain.wallets.models) - - /** Compose */ - implementation(deps.compose.runtime) - implementation(deps.compose.foundation) - - /** Other */ - implementation(deps.kotlin.immutable.collections) -} \ No newline at end of file diff --git a/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/di/FeeSelectorFeatureModule.kt b/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/di/FeeSelectorFeatureModule.kt deleted file mode 100644 index d47efb12e3..0000000000 --- a/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/di/FeeSelectorFeatureModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.features.feeselector.impl.di - -import com.tangem.features.feeselector.api.component.FeeSelectorComponent -import com.tangem.features.feeselector.impl.component.DefaultFeeSelectorComponent -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 FeeSelectorFeatureModule { - - @Binds - @Singleton - fun bindComponentFactory(factory: DefaultFeeSelectorComponent.Factory): FeeSelectorComponent.Factory -} \ No newline at end of file diff --git a/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/entity/FeeSelectorUM.kt b/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/entity/FeeSelectorUM.kt deleted file mode 100644 index 9fba9272a5..0000000000 --- a/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/entity/FeeSelectorUM.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.tangem.features.feeselector.impl.entity - -import androidx.compose.runtime.Immutable -import com.tangem.blockchain.common.Amount -import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM -import kotlinx.collections.immutable.ImmutableList -import java.math.BigDecimal - -@Immutable -internal sealed class FeeSelectorUM { - - data object Loading : FeeSelectorUM() - - data class Content( - val isDoneEnabled: Boolean, - val feeItems: ImmutableList, - val selectedFeeItem: FeeItem, - val isFeeApproximate: Boolean, - val feeFiatRateDataHolder: FeeFiatRateDataHolder?, - ) : FeeSelectorUM() -} - -@Immutable -data class FeeFiatRateDataHolder( - val rate: BigDecimal, - val appCurrency: AppCurrency, -) - -@Immutable -internal sealed class FeeItem { - abstract val onSelect: () -> Unit - - data class Suggested(val title: TextReference, val amount: Amount, override val onSelect: () -> Unit) : FeeItem() - data class Slow(val amount: Amount, override val onSelect: () -> Unit) : FeeItem() - data class Market(val amount: Amount, override val onSelect: () -> Unit) : FeeItem() - data class Fast(val amount: Amount, override val onSelect: () -> Unit) : FeeItem() - data class Custom(val customValues: ImmutableList, override val onSelect: () -> Unit) : FeeItem() -} \ No newline at end of file diff --git a/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/model/FeeSelectorModel.kt b/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/model/FeeSelectorModel.kt deleted file mode 100644 index ea2ae3c857..0000000000 --- a/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/model/FeeSelectorModel.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.tangem.features.feeselector.impl.model - -import androidx.compose.runtime.Stable -import com.tangem.core.decompose.di.ModelScoped -import com.tangem.core.decompose.model.Model -import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.decompose.navigation.Router -import com.tangem.features.feeselector.api.component.FeeSelectorComponent -import com.tangem.features.feeselector.impl.entity.FeeSelectorUM -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import javax.inject.Inject - -@Stable -@ModelScoped -internal class FeeSelectorModel @Inject constructor( - paramsContainer: ParamsContainer, - private val router: Router, - override val dispatchers: CoroutineDispatcherProvider, -) : Model() { - - @Suppress("UnusedPrivateMember") - private val params = paramsContainer.require() - val uiState: StateFlow - field = MutableStateFlow(FeeSelectorUM.Loading) - - fun dismiss() { - router.pop() - } -} \ No newline at end of file diff --git a/features/hot-wallet/api/.gitignore b/features/hot-wallet/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/hot-wallet/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/hot-wallet/api/build.gradle.kts b/features/hot-wallet/api/build.gradle.kts new file mode 100644 index 0000000000..6260c97d71 --- /dev/null +++ b/features/hot-wallet/api/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.hotwallet.api" +} + +dependencies { + + /* Project - Domain */ + implementation(projects.domain.models) + + /* Project - Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /* Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/CreateMobileWalletComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/CreateMobileWalletComponent.kt new file mode 100644 index 0000000000..e9bdbf64e5 --- /dev/null +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/CreateMobileWalletComponent.kt @@ -0,0 +1,8 @@ +package com.tangem.features.hotwallet + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface CreateMobileWalletComponent : ComposableContentComponent { + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt new file mode 100644 index 0000000000..f8ecf58ffe --- /dev/null +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.features.hotwallet + +interface HotWalletFeatureToggles { + val isHotWalletEnabled: Boolean +} \ No newline at end of file diff --git a/features/hot-wallet/impl/.gitignore b/features/hot-wallet/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/hot-wallet/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/hot-wallet/impl/build.gradle.kts b/features/hot-wallet/impl/build.gradle.kts new file mode 100644 index 0000000000..7b63af71b6 --- /dev/null +++ b/features/hot-wallet/impl/build.gradle.kts @@ -0,0 +1,67 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.hotwallet.impl" +} + +dependencies { + /** Api */ + implementation(projects.features.hotWallet.api) + + /** Core modules */ + implementation(projects.core.configToggles) + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + implementation(projects.core.utils) + implementation(projects.core.ui) + implementation(projects.core.res) + implementation(projects.core.decompose) + implementation(projects.core.navigation) + implementation(projects.core.datasource) + + /** Common */ + implementation(projects.common.ui) + implementation(projects.common.routing) + + /** Tangem libraries */ + implementation(projects.libs.tangemSdkApi) + implementation(tangemDeps.card.core) + implementation(tangemDeps.card.android) { + exclude(module = "joda-time") + } + + /** AndroidX libraries */ + implementation(deps.androidx.core.ktx) + implementation(deps.lifecycle.runtime.ktx) + + /** Compose libraries */ + implementation(deps.compose.material) // to use buttons and text field in MultiWalletSeedPhraseImport.kt + implementation(deps.compose.material3) + implementation(deps.compose.animation) + implementation(deps.compose.foundation) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.compose.coil) + implementation(deps.lottie.compose) + implementation(deps.decompose.ext.compose) + implementation(deps.androidx.activity.compose) + implementation(deps.androidx.datastore) + + /** Other libraries */ + implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) + implementation(deps.kotlin.serialization) + implementation(deps.timber) + implementation(deps.firebase.crashlytics) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt new file mode 100644 index 0000000000..cadb09331f --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt @@ -0,0 +1,10 @@ +package com.tangem.features.hotwallet + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager + +internal class DefaultHotWalletFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : HotWalletFeatureToggles { + override val isHotWalletEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "HOT_WALLET_ENABLED") +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt new file mode 100644 index 0000000000..629e6c0a74 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt @@ -0,0 +1,27 @@ +package com.tangem.features.hotwallet.createmobilewallet + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.features.hotwallet.createmobilewallet.entity.CreateMobileWalletUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ModelScoped +internal class CreateMobileWalletModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + internal val uiState: StateFlow + field = MutableStateFlow( + CreateMobileWalletUM( + onBackClick = { /* [REDACTED_TODO_COMMENT] */ }, + onCreateClick = ::onCreateClick, + ), + ) + + private fun onCreateClick() { + // TODO create a wallet + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/DefaultCreateMobileWalletComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/DefaultCreateMobileWalletComponent.kt new file mode 100644 index 0000000000..7a8181fb40 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/DefaultCreateMobileWalletComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.hotwallet.createmobilewallet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.hotwallet.CreateMobileWalletComponent +import com.tangem.features.hotwallet.createmobilewallet.ui.CreateMobileWalletContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultCreateMobileWalletComponent @AssistedInject constructor( + @Assisted private val context: AppComponentContext, + @Assisted private val params: Unit, +) : CreateMobileWalletComponent, AppComponentContext by context { + private val model: CreateMobileWalletModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + CreateMobileWalletContent( + state = state, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : CreateMobileWalletComponent.Factory { + override fun create(context: AppComponentContext, params: Unit): DefaultCreateMobileWalletComponent + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/entity/CreateMobileWalletUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/entity/CreateMobileWalletUM.kt new file mode 100644 index 0000000000..918e811d85 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/entity/CreateMobileWalletUM.kt @@ -0,0 +1,6 @@ +package com.tangem.features.hotwallet.createmobilewallet.entity + +internal data class CreateMobileWalletUM( + val onBackClick: () -> Unit, + val onCreateClick: () -> Unit, +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/ui/CreateMobileWalletContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/ui/CreateMobileWalletContent.kt new file mode 100644 index 0000000000..6f3a77c019 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/ui/CreateMobileWalletContent.kt @@ -0,0 +1,144 @@ +package com.tangem.features.hotwallet.createmobilewallet.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.hotwallet.createmobilewallet.entity.CreateMobileWalletUM + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CreateMobileWalletContent(state: CreateMobileWalletUM, modifier: Modifier = Modifier) { + Column( + modifier = Modifier + .background(TangemTheme.colors.background.primary) + .fillMaxSize() + .systemBarsPadding(), + ) { + TangemTopAppBar( + modifier = modifier + .statusBarsPadding(), + startButton = TopAppBarButtonUM.Back(state.onBackClick), + title = TextReference.EMPTY, + ) + Column( + modifier = Modifier + .weight(1f) + .padding( + start = 16.dp, + top = 24.dp, + end = 16.dp, + ), + ) { + Icon( + modifier = Modifier + .fillMaxWidth(), + painter = painterResource(R.drawable.ic_create_mobile_wallet_56), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 20.dp, + end = 16.dp, + ), + // TODO [REDACTED_TASK_KEY] update and extract this string + text = "Create Mobile Wallet", + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 32.dp), + // TODO [REDACTED_TASK_KEY] update and extract this string + title = "Keys are stored in the app", + // TODO [REDACTED_TASK_KEY] update and extract this string + description = "Get notified of incoming transactions", + iconRes = R.drawable.ic_lock_24, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 24.dp), + // TODO [REDACTED_TASK_KEY] update and extract this string + title = "Seed phrase backup", + // TODO [REDACTED_TASK_KEY] update and extract this string + description = "Stay up to date with the latest features and news", + iconRes = R.drawable.ic_settings_24, + ) + } + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + text = "Create", + showProgress = false, + enabled = true, + onClick = state.onCreateClick, + ) + } +} + +@Composable +private fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + ) { + Icon( + modifier = Modifier + .padding(horizontal = 12.dp), + painter = painterResource(iconRes), + contentDescription = null, + tint = TangemTheme.colors.icon.primary1, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) { + Text( + text = title, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier + .padding(top = 4.dp), + text = description, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewCreateWalletContent() { + TangemThemePreview { + CreateMobileWalletContent( + state = CreateMobileWalletUM( + onBackClick = {}, + onCreateClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/di/HotWalletFeatureModule.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/di/HotWalletFeatureModule.kt new file mode 100644 index 0000000000..3b8b7412a8 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/di/HotWalletFeatureModule.kt @@ -0,0 +1,25 @@ +package com.tangem.features.hotwallet.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.hotwallet.DefaultHotWalletFeatureToggles +import com.tangem.features.hotwallet.HotWalletFeatureToggles +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 HotWalletFeatureModule { + + @Provides + @Singleton + fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): HotWalletFeatureToggles { + return DefaultHotWalletFeatureToggles(featureTogglesManager) + } +} + +@Module +@InstallIn(SingletonComponent::class) +internal interface HotWalletFeatureModuleBinds \ No newline at end of file diff --git a/features/kyc/api/.gitignore b/features/kyc/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/kyc/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/kyc/api/build.gradle.kts b/features/kyc/api/build.gradle.kts new file mode 100644 index 0000000000..3133f2c7de --- /dev/null +++ b/features/kyc/api/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.kyc.api" +} + +dependencies { + + /* Project - Domain */ + implementation(projects.domain.models) + + /* Project - Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /* Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/kyc/api/src/main/kotlin/com/tangem/features/kyc/KycComponent.kt b/features/kyc/api/src/main/kotlin/com/tangem/features/kyc/KycComponent.kt new file mode 100644 index 0000000000..6a18888000 --- /dev/null +++ b/features/kyc/api/src/main/kotlin/com/tangem/features/kyc/KycComponent.kt @@ -0,0 +1,12 @@ +package com.tangem.features.kyc + +import com.tangem.core.decompose.context.AppComponentContext + +interface KycComponent { + + fun launch() + + interface Factory { + fun create(appComponentContext: AppComponentContext): KycComponent + } +} \ No newline at end of file diff --git a/features/kyc/impl/.gitignore b/features/kyc/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/kyc/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/kyc/impl/build.gradle.kts b/features/kyc/impl/build.gradle.kts new file mode 100644 index 0000000000..1bf8fc66f2 --- /dev/null +++ b/features/kyc/impl/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.kyc.impl" +} + +dependencies { + /** Api */ + implementation(projects.features.kyc.api) + + /** Domain */ + implementation(projects.domain.visa) + implementation(projects.domain.wallets.models) + + /** Core modules */ + implementation(projects.core.configToggles) + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + implementation(projects.core.utils) + implementation(projects.core.ui) + implementation(projects.core.res) + implementation(projects.core.decompose) + implementation(projects.core.navigation) + implementation(projects.core.datasource) + implementation(projects.core.error) + + /** Common */ + implementation(projects.common.ui) + implementation(projects.common.routing) + + /** Compose libraries */ + implementation(deps.compose.material3) + implementation(deps.compose.animation) + implementation(deps.compose.foundation) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.compose.coil) + implementation(deps.lottie.compose) + implementation(deps.decompose.ext.compose) + implementation(deps.androidx.activity.compose) + + /** Other libraries */ + implementation(deps.kotlin.immutable.collections) + implementation(deps.kotlin.serialization) + implementation(deps.timber) + implementation(deps.firebase.crashlytics) + implementation(deps.sumsub.sdk) + implementation(deps.arrow.core) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt new file mode 100644 index 0000000000..5494ff060e --- /dev/null +++ b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt @@ -0,0 +1,71 @@ +package com.tangem.features.kyc + +import com.sumsub.sns.core.SNSMobileSDK +import com.sumsub.sns.core.data.listener.SNSCompleteHandler +import com.sumsub.sns.core.data.listener.TokenExpirationHandler +import com.sumsub.sns.core.data.model.SNSCompletionResult +import com.sumsub.sns.core.data.model.SNSSDKState +import com.sumsub.sns.core.theme.SNSTheme +import com.sumsub.sns.core.theme.SNSThemeMetric +import com.sumsub.sns.core.theme.colors +import com.sumsub.sns.core.theme.metrics +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.pay.repository.KycRepository +import com.tangem.domain.wallets.models.UserWalletId +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.Locale + +class DefaultKycComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + private val kycRepositoryFactory: KycRepository.Factory, +) : KycComponent, AppComponentContext by appComponentContext { + + private val kycRepository = kycRepositoryFactory.create(UserWalletId("0FFFFF")) + + override fun launch() { + componentScope.launch { + // val startInfo = kycRepository.getKycStartInfo().getOrNull() ?: return@launch + + val tokenExpirationHandler = object : TokenExpirationHandler { + override fun onTokenExpired(): String? { + val newToken = runBlocking { kycRepository.getKycStartInfo().getOrNull()?.token } + return newToken + } + } + + val snsSdk = SNSMobileSDK.Builder(activity) + .withAccessToken( + "TODO", + onTokenExpiration = tokenExpirationHandler, + ) + .withTheme( + SNSTheme { + colors { + } + metrics { + this.screenHeaderAlignment = SNSThemeMetric.TextAlignment.CENTER + } + }, + ) + .withLocale(Locale("en")) + .withCompleteHandler( + object : SNSCompleteHandler { + override fun onComplete(result: SNSCompletionResult, state: SNSSDKState) { + } + }, + ) + .build() + + snsSdk.launch() + } + } + + @AssistedFactory + interface Factory : KycComponent.Factory { + override fun create(appComponentContext: AppComponentContext): DefaultKycComponent + } +} \ No newline at end of file diff --git a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/di/FeatureModule.kt b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/di/FeatureModule.kt new file mode 100644 index 0000000000..c72f282775 --- /dev/null +++ b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/di/FeatureModule.kt @@ -0,0 +1,16 @@ +package com.tangem.features.kyc.di + +import com.tangem.features.kyc.DefaultKycComponent +import com.tangem.features.kyc.KycComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface FeatureModule { + + @Binds + fun bindComponentFactory(impl: DefaultKycComponent.Factory): KycComponent.Factory +} \ 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 87ecb9bb7b..84a0eb5e9a 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -14,6 +14,7 @@ android { dependencies { /* Project - API */ implementation(projects.features.manageTokens.api) + implementation(projects.features.swapV2.api) /* Project - Core */ implementation(projects.core.decompose) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/DefaultChooseManagedTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/DefaultChooseManagedTokensComponent.kt index c42147407a..ecd9dd8f6d 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/DefaultChooseManagedTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/DefaultChooseManagedTokensComponent.kt @@ -7,20 +7,27 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.features.managetokens.choosetoken.entity.ChooseManageTokensBottomSheetConfig import com.tangem.features.managetokens.choosetoken.model.ChooseManagedTokensModel import com.tangem.features.managetokens.choosetoken.ui.ChooseManagedTokenContent import com.tangem.features.managetokens.component.ChooseManagedTokensComponent +import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkComponent +import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkTrigger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.launch internal class DefaultChooseManagedTokensComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: ChooseManagedTokensComponent.Params, + private val swapChooseTokenNetworkFactory: SwapChooseTokenNetworkComponent.Factory, + private val swapChooseTokenNetworkTrigger: SwapChooseTokenNetworkTrigger, ) : ChooseManagedTokensComponent, AppComponentContext by context { private val model: ChooseManagedTokensModel = getOrCreateModel(params) @@ -48,18 +55,21 @@ internal class DefaultChooseManagedTokensComponent @AssistedInject constructor( config: ChooseManageTokensBottomSheetConfig, componentContext: ComponentContext, ): ComposableBottomSheetComponent = when (config) { - else -> getStubComponent() - } - - private fun getStubComponent() = StubComponent() - - class StubComponent : ComposableBottomSheetComponent { - override fun dismiss() {} - - @Composable - override fun BottomSheet() { - /* no-op */ - } + is ChooseManageTokensBottomSheetConfig.SwapTokensBottomSheetConfig -> swapChooseTokenNetworkFactory.create( + context = childByContext(componentContext), + params = SwapChooseTokenNetworkComponent.Params( + userWalletId = config.userWalletId, + initialCurrency = config.initialCurrency, + token = config.token, + onDismiss = model.bottomSheetNavigation::dismiss, + onResult = { + componentScope.launch { + swapChooseTokenNetworkTrigger.trigger(it) + } + router.pop() + }, + ), + ) } @AssistedFactory diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt index 65284e752d..ad0f6fe8f2 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt @@ -30,6 +30,7 @@ import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.PaginationStatus import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -142,7 +143,11 @@ internal class ChooseManagedTokensModel @Inject constructor( private fun updateItems(items: ImmutableList) { uiState.update { state -> state.copy( - readContent = state.readContent.copy(items = items), + readContent = state.readContent.copy( + items = items.filterNot { + it.id.value == params.initialCurrency.id.rawCurrencyId?.value + }.toPersistentList(), + ), ) } } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt index 1bdbfd739b..01b8c9cb51 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt @@ -3,7 +3,7 @@ package com.tangem.features.managetokens.utils import arrow.core.getOrElse import com.tangem.core.decompose.di.ModelScoped import com.tangem.domain.managetokens.CheckIsCurrencyNotAddedUseCase -import com.tangem.domain.managetokens.CreateCurrencyUseCase +import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase import com.tangem.domain.managetokens.FindTokenUseCase import com.tangem.domain.managetokens.ValidateTokenFormUseCase import com.tangem.domain.managetokens.model.AddCustomTokenForm @@ -23,7 +23,7 @@ import javax.inject.Inject @ModelScoped internal class CustomCurrencyValidator @Inject constructor( private val validateTokenFormUseCase: ValidateTokenFormUseCase, - private val createCustomCurrencyUseCase: CreateCurrencyUseCase, + private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase, private val findTokenUseCase: FindTokenUseCase, private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase, ) { @@ -164,7 +164,7 @@ internal class CustomCurrencyValidator @Inject constructor( derivationPath: Network.DerivationPath, validatedForm: AddCustomTokenForm.Validated.All?, ) { - val currency = createCustomCurrencyUseCase( + val currency = createCryptoCurrencyUseCase( userWalletId = userWalletId, networkId = networkId, derivationPath = derivationPath, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt index a6e5e646ce..4f55af238c 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt @@ -9,8 +9,9 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.quote.fold import com.tangem.domain.onramp.model.HotCryptoCurrency -import com.tangem.domain.tokens.model.Quote import com.tangem.utils.converter.Converter import java.math.BigDecimal @@ -30,7 +31,7 @@ internal class HotTokenItemStateConverter( id = value.cryptoCurrency.id.value, iconState = CryptoCurrencyToIconStateConverter().convert(value.cryptoCurrency), titleState = TokenItemState.TitleState.Content(text = stringReference(value.cryptoCurrency.name)), - subtitleState = value.quote.getCryptoPriceState(appCurrency), + subtitleState = value.quoteStatus.getCryptoPriceState(appCurrency), fiatAmountState = null, subtitle2State = null, onItemClick = onItemClick.let { onItemClick -> { onItemClick(it, value) } }, @@ -38,17 +39,17 @@ internal class HotTokenItemStateConverter( ) } - private fun Quote.getCryptoPriceState(appCurrency: AppCurrency): TokenItemState.SubtitleState { - return when (this) { - is Quote.Empty -> TokenItemState.SubtitleState.Unknown - is Quote.Value -> { + private fun QuoteStatus.getCryptoPriceState(appCurrency: AppCurrency): TokenItemState.SubtitleState { + return fold( + onData = { TokenItemState.SubtitleState.CryptoPriceContent( price = fiatRate.getFormattedCryptoPrice(appCurrency), priceChangePercent = priceChange.format { percent() }, type = priceChange.getPriceChangeType(), ) - } - } + }, + onEmpty = { TokenItemState.SubtitleState.Unknown }, + ) } private fun BigDecimal.getFormattedCryptoPrice(appCurrency: AppCurrency): String { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt index 608587c822..07d5b17e3c 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt @@ -51,6 +51,11 @@ internal fun OnrampAmountContent(state: OnrampAmountBlockUM, modifier: Modifier private fun OnrampAmountField(amountField: AmountFieldModel) { val decimalFormat = rememberDecimalFormat() val requester = remember { FocusRequester() } + val symbolColor = if (amountField.fiatValue.isBlank()) { + TangemTheme.colors.text.disabled + } else { + TangemTheme.colors.text.primary1 + } AmountTextField( value = amountField.fiatValue, decimals = amountField.fiatAmount.decimals, @@ -59,6 +64,7 @@ private fun OnrampAmountField(amountField: AmountFieldModel) { symbol = amountField.fiatAmount.currencySymbol, currencyCode = amountField.fiatAmount.currencySymbol, decimalFormat = decimalFormat, + symbolColor = symbolColor, ), onValueChange = amountField.onValueChange, keyboardOptions = amountField.keyboardOptions, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/paymentmethod/ui/SelectPaymentMethodBottomSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/paymentmethod/ui/SelectPaymentMethodBottomSheet.kt index 48ce4dfda8..fb80254c3c 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/paymentmethod/ui/SelectPaymentMethodBottomSheet.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/paymentmethod/ui/SelectPaymentMethodBottomSheet.kt @@ -14,11 +14,11 @@ import androidx.compose.ui.draw.clip import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.selectedBorder import com.tangem.core.ui.res.TangemTheme import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.paymentmethod.entity.PaymentMethodUM import com.tangem.features.onramp.paymentmethod.entity.PaymentMethodsBottomSheetConfig -import com.tangem.features.onramp.utils.selectedBorder import kotlinx.collections.immutable.ImmutableList @Composable @@ -59,27 +59,16 @@ private fun SelectPaymentMethodBottomSheetContent( val isSelected = remember(item, selectedMethodId) { item.id == selectedMethodId } - val itemModifier = if (isSelected) { - Modifier - .fillMaxWidth() - .selectedBorder() - .clickable(onClick = item.onSelect) - .padding( - start = TangemTheme.dimens.spacing12, - top = TangemTheme.dimens.spacing10, - bottom = TangemTheme.dimens.spacing10, - ) - } else { - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(TangemTheme.dimens.radius16)) - .clickable(onClick = item.onSelect) - .padding( - start = TangemTheme.dimens.spacing12, - top = TangemTheme.dimens.spacing10, - bottom = TangemTheme.dimens.spacing10, - ) - } + val itemModifier = Modifier + .fillMaxWidth() + .selectedBorder(isSelected = isSelected) + .clip(RoundedCornerShape(TangemTheme.dimens.radius16)) + .clickable(onClick = item.onSelect) + .padding( + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing10, + bottom = TangemTheme.dimens.spacing10, + ) PaymentMethodItem( modifier = itemModifier, paymentMethod = item, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/ui/SelectProviderBottomSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/ui/SelectProviderBottomSheet.kt index 6f74ef7c95..59cb8cb0b0 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/ui/SelectProviderBottomSheet.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/ui/SelectProviderBottomSheet.kt @@ -26,13 +26,10 @@ import androidx.compose.ui.util.fastForEach import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import com.tangem.core.ui.components.RectangleShimmer -import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.onramp.model.OnrampPaymentMethod @@ -41,7 +38,6 @@ import com.tangem.features.onramp.paymentmethod.ui.PaymentMethodIcon import com.tangem.features.onramp.providers.entity.ProviderListItemUM import com.tangem.features.onramp.providers.entity.SelectPaymentAndProviderUM import com.tangem.features.onramp.providers.model.previewData.SelectProviderPreviewData -import com.tangem.features.onramp.utils.selectedBorder @Composable internal fun SelectProviderBottomSheet(config: TangemBottomSheetConfig, content: @Composable () -> Unit) { @@ -151,13 +147,7 @@ private fun ProviderItem(state: ProviderListItemUM, modifier: Modifier = Modifie private fun AvailableProviderItem(state: ProviderListItemUM.Available.Content, modifier: Modifier = Modifier) { Row( modifier = modifier - .then( - if (state.isSelected) { - Modifier.selectedBorder() - } else { - Modifier.clip(RoundedCornerShape(16.dp)) - }, - ) + .selectedBorder(isSelected = state.isSelected) .clickable(onClick = state.onClick) .padding(all = TangemTheme.dimens.spacing12), verticalAlignment = Alignment.CenterVertically, @@ -227,13 +217,7 @@ private fun UnavailableProviderItem( ) { Row( modifier = modifier - .then( - if (isSelected) { - Modifier.selectedBorder() - } else { - Modifier.clip(RoundedCornerShape(16.dp)) - }, - ) + .selectedBorder(isSelected = isSelected) .padding(all = TangemTheme.dimens.spacing12), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/utils/ModifierExt.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/utils/ModifierExt.kt deleted file mode 100644 index 8e38b54575..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/utils/ModifierExt.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.features.onramp.utils - -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import com.tangem.core.ui.res.TangemTheme - -@Composable -internal fun Modifier.selectedBorder() = border( - width = 2.5.dp, - color = TangemTheme.colors.text.accent.copy(alpha = 0.1f), - shape = RoundedCornerShape(18.dp), -) - .padding(2.5.dp) - .border( - width = 1.dp, - color = TangemTheme.colors.text.accent, - shape = RoundedCornerShape(16.dp), - ) - .clip(RoundedCornerShape(16.dp)) \ No newline at end of file diff --git a/features/send-v2/api/build.gradle.kts b/features/send-v2/api/build.gradle.kts index 2147785720..43576f6348 100644 --- a/features/send-v2/api/build.gradle.kts +++ b/features/send-v2/api/build.gradle.kts @@ -15,10 +15,18 @@ dependencies { /** Domain models */ api(projects.domain.models) - implementation(projects.domain.wallets.models) - implementation(projects.domain.tokens.models) + implementation(projects.domain.appCurrency.models) implementation(projects.domain.nft.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.transaction.models) + implementation(projects.domain.wallets.models) - /* Compose */ + /** Compose */ implementation(deps.compose.runtime) + + /** Tangem */ + implementation(tangemDeps.blockchain) + + /** Other */ + implementation(deps.arrow.core) } \ No newline at end of file diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/FeeSelectorBlockComponent.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/FeeSelectorBlockComponent.kt new file mode 100644 index 0000000000..adbe917341 --- /dev/null +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/FeeSelectorBlockComponent.kt @@ -0,0 +1,10 @@ +package com.tangem.features.send.v2.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.send.v2.api.params.FeeSelectorParams + +interface FeeSelectorBlockComponent : ComposableContentComponent { + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/fee-selector/api/src/main/kotlin/com/tangem/features/feeselector/api/component/FeeSelectorComponent.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/FeeSelectorComponent.kt similarity index 89% rename from features/fee-selector/api/src/main/kotlin/com/tangem/features/feeselector/api/component/FeeSelectorComponent.kt rename to features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/FeeSelectorComponent.kt index 726aab533a..e3a8a20cc7 100644 --- a/features/fee-selector/api/src/main/kotlin/com/tangem/features/feeselector/api/component/FeeSelectorComponent.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/FeeSelectorComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feeselector.api.component +package com.tangem.features.send.v2.api import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt index 4dbf452662..0e08965245 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt @@ -1,3 +1,6 @@ package com.tangem.features.send.v2.api -interface SendFeatureToggles \ No newline at end of file +interface SendFeatureToggles { + + val isSendRedesignEnabled: Boolean +} \ No newline at end of file diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt new file mode 100644 index 0000000000..946936b160 --- /dev/null +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt @@ -0,0 +1,27 @@ +package com.tangem.features.send.v2.api.params + +import arrow.core.Either +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.network.Network +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError + +sealed class FeeSelectorParams { + abstract val onLoadFee: suspend () -> Either + abstract val network: Network + abstract val cryptoCurrencyStatus: CryptoCurrencyStatus + + data class FeeSelectorBlockParams( + override val onLoadFee: suspend () -> Either, + override val network: Network, + override val cryptoCurrencyStatus: CryptoCurrencyStatus, + val suggestedFeeState: SuggestedFeeState, + ) : FeeSelectorParams() + + sealed class SuggestedFeeState { + data object None : SuggestedFeeState() + data class Suggestion(val title: TextReference, val fee: Fee) : SuggestedFeeState() + } +} \ No newline at end of file diff --git a/features/send-v2/impl/build.gradle.kts b/features/send-v2/impl/build.gradle.kts index f3961499eb..dc97603af5 100644 --- a/features/send-v2/impl/build.gradle.kts +++ b/features/send-v2/impl/build.gradle.kts @@ -16,7 +16,6 @@ dependencies { implementation(projects.features.sendV2.api) implementation(projects.features.txhistory.api) implementation(projects.features.nft.api) - implementation(projects.features.feeSelector.api) /** Libs */ implementation(projects.libs.crypto) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt index 326f72db7e..cce9c99562 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt @@ -4,5 +4,8 @@ import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.send.v2.api.SendFeatureToggles internal class DefaultSendFeatureToggles( - @Suppress("UnusedPrivateMember") private val featureToggles: FeatureTogglesManager, -) : SendFeatureToggles \ No newline at end of file + private val featureToggles: FeatureTogglesManager, +) : SendFeatureToggles { + override val isSendRedesignEnabled: Boolean + get() = featureToggles.isFeatureEnabled("SEND_REDESIGN_ENABLED") +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/analytics/CommonSendAnalyticEvents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/analytics/CommonSendAnalyticEvents.kt index a392ca4da3..c40b00375d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/analytics/CommonSendAnalyticEvents.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/analytics/CommonSendAnalyticEvents.kt @@ -1,6 +1,7 @@ package com.tangem.features.send.v2.common.analytics import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM @@ -95,6 +96,20 @@ internal sealed class CommonSendAnalyticEvents( params = mapOf(SOURCE to source.name), ) + /** Fee screen is closed with non empty nonce */ + data class NonceInserted( + val categoryName: String, + val token: String, + val blockchain: String, + ) : CommonSendAnalyticEvents( + category = categoryName, + event = "Nonce Inserted", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), + ) + companion object { const val SEND_CATEGORY = "Token / Send" const val NFT_SEND_CATEGORY = "NFT" diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt new file mode 100644 index 0000000000..e619f49d9b --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt @@ -0,0 +1,185 @@ +package com.tangem.features.send.v2.feeselector + +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.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.amountScreen.utils.getFiatString +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.EMPTY_BALANCE_SIGN +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fee +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.send.v2.api.FeeSelectorBlockComponent +import com.tangem.features.send.v2.api.params.FeeSelectorParams +import com.tangem.features.send.v2.feeselector.entity.FeeFiatRateUM +import com.tangem.features.send.v2.feeselector.entity.FeeItem +import com.tangem.features.send.v2.feeselector.entity.FeeSelectorUM +import com.tangem.features.send.v2.feeselector.entity.PrimaryButtonConfig +import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel +import com.tangem.features.send.v2.impl.R +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: FeeSelectorParams.FeeSelectorBlockParams, +) : FeeSelectorBlockComponent, AppComponentContext by appComponentContext { + + private val model: FeeSelectorModel = getOrCreateModel(params = params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + FeeSelectorBlockContent(modifier = modifier, state = state) + } + + @AssistedFactory + interface Factory : FeeSelectorBlockComponent.Factory { + override fun create( + context: AppComponentContext, + params: FeeSelectorParams.FeeSelectorBlockParams, + ): DefaultFeeSelectorBlockComponent + } +} + +@Composable +private fun FeeSelectorBlockContent(state: FeeSelectorUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background(TangemTheme.colors.background.action) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_fee_new_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + Text( + modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + text = stringResourceSafe(R.string.common_network_fee_title), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + Icon( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing6) + .size(TangemTheme.dimens.size16), + painter = painterResource(id = R.drawable.ic_token_info_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + SpacerWMax() + when (state) { + is FeeSelectorUM.Content -> FeeContent(state) + is FeeSelectorUM.Loading -> FeeLoading() + is FeeSelectorUM.Error -> FeeError() + } + } +} + +@Composable +private fun RowScope.FeeError() { + Text( + text = EMPTY_BALANCE_SIGN, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.body2, + ) +} + +@Composable +private fun RowScope.FeeLoading() { + TextShimmer( + radius = TangemTheme.dimens.radius3, + style = TangemTheme.typography.body1, + modifier = Modifier.width(width = TangemTheme.dimens.size90), + ) +} + +@Composable +private fun RowScope.FeeContent(state: FeeSelectorUM.Content) { + EllipsisText( + text = if (state.feeFiatRateUM != null) { + getFiatString( + value = state.selectedFeeItem.fee.amount.value, + rate = state.feeFiatRateUM.rate, + appCurrency = state.feeFiatRateUM.appCurrency, + approximate = state.isFeeApproximate, + ) + } else { + state.selectedFeeItem.fee.amount.value.format { + crypto( + symbol = state.selectedFeeItem.fee.amount.currencySymbol, + decimals = state.selectedFeeItem.fee.amount.decimals, + ).fee(canBeLower = state.isFeeApproximate) + } + }, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.End, + modifier = Modifier + .weight(1f) + .padding(start = TangemTheme.dimens.spacing4), + ) + Icon( + modifier = Modifier.size(width = 18.dp, height = 24.dp), + painter = painterResource(id = R.drawable.ic_select_18_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) +} + +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun FeeSelectorBlockContent_Preview() { + TangemThemePreview { + val feeItem = FeeItem.Market( + Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum)), + ) + FeeSelectorBlockContent( + modifier = Modifier.fillMaxWidth(), + state = FeeSelectorUM.Content( + doneButtonConfig = PrimaryButtonConfig(enabled = true, onClick = {}), + feeItems = persistentListOf(feeItem), + onFeeSelected = {}, + selectedFeeItem = feeItem, + isFeeApproximate = false, + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal("2500"), + appCurrency = AppCurrency.Default, + ), + displayNonceInput = false, + nonce = null, + onNonceChange = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/component/DefaultFeeSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt similarity index 75% rename from features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/component/DefaultFeeSelectorComponent.kt rename to features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt index 0fda871e10..925d689d4f 100644 --- a/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/component/DefaultFeeSelectorComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt @@ -1,13 +1,15 @@ -package com.tangem.features.feeselector.impl.component +package com.tangem.features.send.v2.feeselector import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.feeselector.api.component.FeeSelectorComponent -import com.tangem.features.feeselector.impl.model.FeeSelectorModel -import com.tangem.features.feeselector.impl.ui.FeeSelectorModalBottomSheet +import com.tangem.features.send.v2.api.FeeSelectorComponent +import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel +import com.tangem.features.send.v2.feeselector.ui.FeeSelectorModalBottomSheet import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -31,8 +33,9 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor( // Temporary workaround, to use test this component @Composable override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() BackHandler(onBack = router::pop) - FeeSelectorModalBottomSheet(onDismiss = ::dismiss, state = TODO()) + FeeSelectorModalBottomSheet(onDismiss = ::dismiss, state = state) } @AssistedFactory diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorFeatureModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorFeatureModule.kt new file mode 100644 index 0000000000..1b6f3fe484 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorFeatureModule.kt @@ -0,0 +1,24 @@ +package com.tangem.features.send.v2.feeselector.di + +import com.tangem.features.send.v2.api.FeeSelectorBlockComponent +import com.tangem.features.send.v2.api.FeeSelectorComponent +import com.tangem.features.send.v2.feeselector.DefaultFeeSelectorBlockComponent +import com.tangem.features.send.v2.feeselector.DefaultFeeSelectorComponent +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 FeeSelectorFeatureModule { + + @Binds + @Singleton + fun bindComponentFactory(factory: DefaultFeeSelectorComponent.Factory): FeeSelectorComponent.Factory + + @Binds + @Singleton + fun bindBlockComponentFactory(factory: DefaultFeeSelectorBlockComponent.Factory): FeeSelectorBlockComponent.Factory +} \ No newline at end of file diff --git a/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/di/FeeSelectorModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt similarity index 78% rename from features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/di/FeeSelectorModelModule.kt rename to features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt index 2144133681..9afa349960 100644 --- a/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/di/FeeSelectorModelModule.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt @@ -1,8 +1,8 @@ -package com.tangem.features.feeselector.impl.di +package com.tangem.features.send.v2.feeselector.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model -import com.tangem.features.feeselector.impl.model.FeeSelectorModel +import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/entity/FeeSelectorUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/entity/FeeSelectorUM.kt new file mode 100644 index 0000000000..5d5272f9e0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/entity/FeeSelectorUM.kt @@ -0,0 +1,56 @@ +package com.tangem.features.send.v2.feeselector.entity + +import androidx.compose.runtime.Immutable +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM +import kotlinx.collections.immutable.ImmutableList +import java.math.BigDecimal +import java.math.BigInteger + +@Immutable +internal sealed class FeeSelectorUM { + + abstract val doneButtonConfig: PrimaryButtonConfig + + data class Loading(private val onDone: () -> Unit) : FeeSelectorUM() { + override val doneButtonConfig = PrimaryButtonConfig(enabled = false, onClick = onDone) + } + + data class Error(val error: GetFeeError, private val onDone: () -> Unit) : FeeSelectorUM() { + override val doneButtonConfig = PrimaryButtonConfig(enabled = false, onClick = onDone) + } + + data class Content( + override val doneButtonConfig: PrimaryButtonConfig, + val feeItems: ImmutableList, + val onFeeSelected: (FeeItem) -> Unit, + val selectedFeeItem: FeeItem, + val isFeeApproximate: Boolean, + val feeFiatRateUM: FeeFiatRateUM?, + val displayNonceInput: Boolean, + val nonce: BigInteger?, + val onNonceChange: (String) -> Unit, + ) : FeeSelectorUM() +} + +internal data class PrimaryButtonConfig(val enabled: Boolean, val onClick: () -> Unit) + +@Immutable +internal data class FeeFiatRateUM( + val rate: BigDecimal, + val appCurrency: AppCurrency, +) + +@Immutable +internal sealed class FeeItem { + abstract val fee: Fee + + data class Suggested(val title: TextReference, override val fee: Fee) : FeeItem() + data class Slow(override val fee: Fee) : FeeItem() + data class Market(override val fee: Fee) : FeeItem() + data class Fast(override val fee: Fee) : FeeItem() + data class Custom(override val fee: Fee, val customValues: ImmutableList) : FeeItem() +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt new file mode 100644 index 0000000000..7e2b1df414 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt @@ -0,0 +1,101 @@ +package com.tangem.features.send.v2.feeselector.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.blockchain.common.AmountType +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase +import com.tangem.features.send.v2.api.params.FeeSelectorParams +import com.tangem.features.send.v2.feeselector.entity.FeeItem +import com.tangem.features.send.v2.feeselector.entity.FeeSelectorUM +import com.tangem.features.send.v2.feeselector.model.transformers.FeeItemSelectedTransformer +import com.tangem.features.send.v2.feeselector.model.transformers.FeeSelectorErrorTransformer +import com.tangem.features.send.v2.feeselector.model.transformers.FeeSelectorLoadedTransformer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.transformer.update +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class FeeSelectorModel @Inject constructor( + paramsContainer: ParamsContainer, + private val isFeeApproximateUseCase: IsFeeApproximateUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val router: Router, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = paramsContainer.require() + private var appCurrency: AppCurrency = AppCurrency.Default + + val uiState: StateFlow + field = MutableStateFlow(FeeSelectorUM.Loading(onDone = ::onDone)) + + init { + initAppCurrency() + loadFee() + } + + fun dismiss() { + router.pop() + } + + private fun initAppCurrency() { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + } + } + + private fun loadFee() { + modelScope.launch { + params.onLoadFee() + .fold( + ifLeft = { uiState.update(FeeSelectorErrorTransformer(it)) }, + ifRight = { + uiState.update( + FeeSelectorLoadedTransformer( + cryptoCurrencyStatus = params.cryptoCurrencyStatus, + appCurrency = appCurrency, + fees = it, + suggestedFeeState = params.suggestedFeeState, + isFeeApproximate = isFeeApproximate(it.normal.amount.type), + onFeeSelected = ::onFeeItemSelected, + onCustomFeeValueChange = ::onCustomFeeValueChange, + onNextClick = ::onNextClick, + ), + ) + }, + ) + } + } + + private fun isFeeApproximate(amountType: AmountType): Boolean { + val networkId = params.network.id + return isFeeApproximateUseCase(networkId = networkId, amountType = amountType) + } + + private fun onFeeItemSelected(feeItem: FeeItem) { + uiState.update(FeeItemSelectedTransformer(feeItem)) + } + + @Suppress("UnusedPrivateMember") + private fun onCustomFeeValueChange(index: Int, value: String) { + // TODO: [REDACTED_JIRA] + } + + private fun onNextClick() { + // TODO: [REDACTED_JIRA] + } + + private fun onDone() { + // TODO: [REDACTED_JIRA] + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeItemConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeItemConverter.kt new file mode 100644 index 0000000000..6ec3832f8f --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeItemConverter.kt @@ -0,0 +1,64 @@ +package com.tangem.features.send.v2.feeselector.model.transformers + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.send.v2.api.params.FeeSelectorParams +import com.tangem.features.send.v2.feeselector.entity.FeeItem +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal class FeeItemConverter( + private val suggestedFeeState: FeeSelectorParams.SuggestedFeeState, + private val normalFee: Fee, + private val onCustomFeeValueChange: (Int, String) -> Unit, + private val onNextClick: () -> Unit, + private val appCurrency: AppCurrency, + cryptoCurrencyStatus: CryptoCurrencyStatus, +) : Converter> { + + private val customFeeFieldConverter = FeeSelectorCustomFieldConverter( + onCustomFeeValueChange = onCustomFeeValueChange, + onNextClick = onNextClick, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, + normalFee = normalFee, + ) + + override fun convert(value: TransactionFee): ImmutableList { + val fees = mutableListOf() + + when (suggestedFeeState) { + FeeSelectorParams.SuggestedFeeState.None -> Unit + is FeeSelectorParams.SuggestedFeeState.Suggestion -> fees.add( + FeeItem.Suggested( + title = suggestedFeeState.title, + fee = suggestedFeeState.fee, + ), + ) + } + when (value) { + is TransactionFee.Choosable -> { + fees.add(FeeItem.Slow(fee = value.minimum)) + fees.add(FeeItem.Market(fee = value.normal)) + fees.add(FeeItem.Fast(fee = value.priority)) + } + is TransactionFee.Single -> { + fees.add(FeeItem.Market(fee = value.normal)) + } + } + val customFeeFields = customFeeFieldConverter.convert(normalFee) + if (customFeeFields.isNotEmpty()) { + fees.add( + FeeItem.Custom( + fee = customFeeFieldConverter.convertBack(customFeeFields), + customValues = customFeeFields, + ), + ) + } + + return fees.toImmutableList() + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeItemSelectedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeItemSelectedTransformer.kt new file mode 100644 index 0000000000..f8b81f56e7 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeItemSelectedTransformer.kt @@ -0,0 +1,13 @@ +package com.tangem.features.send.v2.feeselector.model.transformers + +import com.tangem.features.send.v2.feeselector.entity.FeeItem +import com.tangem.features.send.v2.feeselector.entity.FeeSelectorUM +import com.tangem.utils.transformer.Transformer + +internal class FeeItemSelectedTransformer(private val selectedFeeItem: FeeItem) : Transformer { + override fun transform(prevState: FeeSelectorUM): FeeSelectorUM { + prevState as? FeeSelectorUM.Content ?: return prevState + + return prevState.copy(selectedFeeItem = selectedFeeItem) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorCustomFieldConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorCustomFieldConverter.kt new file mode 100644 index 0000000000..3357fdd5d9 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorCustomFieldConverter.kt @@ -0,0 +1,114 @@ +package com.tangem.features.send.v2.feeselector.model.transformers + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.bitcoin.BitcoinCustomFeeConverter +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.kaspa.KaspaCustomFeeConverter +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.utils.converter.TwoWayConverter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class FeeSelectorCustomFieldConverter( + private val onCustomFeeValueChange: (Int, String) -> Unit, + private val onNextClick: () -> Unit, + private val appCurrency: AppCurrency, + private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + private val normalFee: Fee, +) : TwoWayConverter> { + + private val ethereumCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) { + EthereumCustomFeeConverter( + onCustomFeeValueChange = onCustomFeeValueChange, + onNextClick = onNextClick, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + ) + } + + private val bitcoinCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) { + BitcoinCustomFeeConverter( + onCustomFeeValueChange = onCustomFeeValueChange, + onNextClick = onNextClick, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + ) + } + + private val kaspaCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) { + KaspaCustomFeeConverter( + onCustomFeeValueChange = onCustomFeeValueChange, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + ) + } + + override fun convert(value: Fee): ImmutableList { + return when (value) { + is Fee.Ethereum -> ethereumCustomFeeConverter.convert(value) + is Fee.Bitcoin -> bitcoinCustomFeeConverter.convert(value) + is Fee.Kaspa -> kaspaCustomFeeConverter.convert(value) + else -> persistentListOf() + } + } + + override fun convertBack(value: ImmutableList): Fee { + return if (value.isEmpty()) { + normalFee + } else { + when (normalFee) { + is Fee.Ethereum -> ethereumCustomFeeConverter.convertBack(normalFee = normalFee, value = value) + is Fee.Bitcoin -> bitcoinCustomFeeConverter.convertBack(normalFee = normalFee, value = value) + is Fee.Kaspa -> kaspaCustomFeeConverter.convertBack(normalFee = normalFee, value = value) + else -> { + val customFee = value.firstOrNull() + Fee.Common( + normalFee.amount.copy( + value = customFee?.value?.parseToBigDecimal(customFee.decimals), + ), + ) + } + } + } + } + + fun onValueChange(feeSelectorState: FeeSelectorUM.Content, index: Int, value: String) = + when (val fee = feeSelectorState.fees.normal) { + is Fee.Ethereum -> ethereumCustomFeeConverter.onValueChange( + feeValue = fee, + customValues = feeSelectorState.customValues, + index = index, + value = value, + ) + is Fee.Bitcoin -> bitcoinCustomFeeConverter.onValueChange( + customValues = feeSelectorState.customValues, + index = index, + value = value, + txSize = fee.txSize, + ) + is Fee.Kaspa -> kaspaCustomFeeConverter.onValueChange( + customValues = feeSelectorState.customValues, + index = index, + value = value, + ) + else -> feeSelectorState.customValues + } + + fun tryAutoFixValue(feeSelectorState: FeeSelectorUM.Content) = when (feeSelectorState.fees) { + is TransactionFee.Choosable -> feeSelectorState.fees.minimum + is TransactionFee.Single -> feeSelectorState.fees.normal + }.let { + when (it) { + is Fee.Kaspa -> kaspaCustomFeeConverter.tryAutoFixValue( + minimumFee = it, + customValues = feeSelectorState.customValues, + ) + else -> feeSelectorState.customValues + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorErrorTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorErrorTransformer.kt new file mode 100644 index 0000000000..d4340701a9 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorErrorTransformer.kt @@ -0,0 +1,12 @@ +package com.tangem.features.send.v2.feeselector.model.transformers + +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.features.send.v2.feeselector.entity.FeeSelectorUM +import com.tangem.utils.transformer.Transformer + +internal class FeeSelectorErrorTransformer(private val error: GetFeeError) : Transformer { + + override fun transform(prevState: FeeSelectorUM): FeeSelectorUM { + return FeeSelectorUM.Error(error = error, onDone = prevState.doneButtonConfig.onClick) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt new file mode 100644 index 0000000000..a33f5e831f --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt @@ -0,0 +1,54 @@ +package com.tangem.features.send.v2.feeselector.model.transformers + +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.send.v2.api.params.FeeSelectorParams +import com.tangem.features.send.v2.feeselector.entity.FeeFiatRateUM +import com.tangem.features.send.v2.feeselector.entity.FeeItem +import com.tangem.features.send.v2.feeselector.entity.FeeSelectorUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.ImmutableList + +@Suppress("LongParameterList") +internal class FeeSelectorLoadedTransformer( + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val appCurrency: AppCurrency, + private val fees: TransactionFee, + private val suggestedFeeState: FeeSelectorParams.SuggestedFeeState, + private val isFeeApproximate: Boolean, + private val onFeeSelected: (FeeItem) -> Unit, + private val onCustomFeeValueChange: (Int, String) -> Unit, + private val onNextClick: () -> Unit, +) : Transformer { + + private val feeItemsConverter = FeeItemConverter( + suggestedFeeState = suggestedFeeState, + normalFee = fees.normal, + onCustomFeeValueChange = onCustomFeeValueChange, + onNextClick = onNextClick, + appCurrency = appCurrency, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ) + + override fun transform(prevState: FeeSelectorUM): FeeSelectorUM { + val feeItems: ImmutableList = feeItemsConverter.convert(fees) + val selectedFee = feeItems.find { it is FeeItem.Suggested } ?: feeItems.first { it is FeeItem.Market } + return FeeSelectorUM.Content( + doneButtonConfig = prevState.doneButtonConfig.copy(enabled = true), + feeItems = feeItems, + selectedFeeItem = selectedFee, + isFeeApproximate = isFeeApproximate, + onFeeSelected = onFeeSelected, + feeFiatRateUM = cryptoCurrencyStatus.value.fiatRate?.let { rate -> + FeeFiatRateUM( + rate = rate, + appCurrency = appCurrency, + ) + }, + displayNonceInput = false, + nonce = null, + onNonceChange = {}, + ) + } +} \ No newline at end of file diff --git a/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/ui/FeeSelectorModalBottomSheet.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt similarity index 69% rename from features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/ui/FeeSelectorModalBottomSheet.kt rename to features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt index 4b6215a295..9acf05e797 100644 --- a/features/fee-selector/impl/src/main/kotlin/com/tangem/features/feeselector/impl/ui/FeeSelectorModalBottomSheet.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feeselector.impl.ui +package com.tangem.features.send.v2.feeselector.ui import android.content.res.Configuration import androidx.annotation.DrawableRes @@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview @@ -30,6 +31,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.ui.amountScreen.utils.getFiatReference import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.atoms.text.EllipsisText @@ -46,18 +48,22 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM -import com.tangem.features.feeselector.impl.R -import com.tangem.features.feeselector.impl.entity.FeeFiatRateDataHolder -import com.tangem.features.feeselector.impl.entity.FeeItem -import com.tangem.features.feeselector.impl.entity.FeeSelectorUM +import com.tangem.features.send.v2.feeselector.entity.FeeFiatRateUM +import com.tangem.features.send.v2.feeselector.entity.FeeItem +import com.tangem.features.send.v2.feeselector.entity.FeeSelectorUM +import com.tangem.features.send.v2.feeselector.entity.PrimaryButtonConfig +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal +import java.math.BigInteger @Composable -internal fun FeeSelectorModalBottomSheet(state: FeeSelectorUM.Content, onDismiss: () -> Unit) { +internal fun FeeSelectorModalBottomSheet(state: FeeSelectorUM, onDismiss: () -> Unit) { + if (state !is FeeSelectorUM.Content) return + TangemModalBottomSheetWithFooter( config = TangemBottomSheetConfig( isShown = true, @@ -73,7 +79,10 @@ internal fun FeeSelectorModalBottomSheet(state: FeeSelectorUM.Content, onDismiss ) }, content = { - FeeSelectorItems(state = state, modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp)) + FeeSelectorItems( + state = state, + modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp), + ) }, footer = { PrimaryButton( @@ -81,8 +90,8 @@ internal fun FeeSelectorModalBottomSheet(state: FeeSelectorUM.Content, onDismiss .fillMaxWidth() .padding(16.dp), text = stringResourceSafe(R.string.common_done), - onClick = onDismiss, - enabled = state.isDoneEnabled, + onClick = state.doneButtonConfig.onClick, + enabled = state.doneButtonConfig.enabled, ) }, ) @@ -107,7 +116,7 @@ private fun FeeSelectorItems(state: FeeSelectorUM.Content, modifier: Modifier = }, label = "Fee selector icon background change", ) - val onSelect by rememberUpdatedState(item.onSelect) + val onSelect by rememberUpdatedState(state.onFeeSelected) val itemModifier = Modifier .fillMaxWidth() .background(TangemTheme.colors.background.primary) @@ -126,7 +135,7 @@ private fun FeeSelectorItems(state: FeeSelectorUM.Content, modifier: Modifier = Modifier }, ) - .clickableSingle(onClick = onSelect) + .clickableSingle(onClick = { onSelect(item) }) when (item) { is FeeItem.Suggested -> RegularFeeItemContent( modifier = itemModifier, @@ -135,23 +144,23 @@ private fun FeeSelectorItems(state: FeeSelectorUM.Content, modifier: Modifier = iconBackgroundColor = iconBackgroundColor, iconTint = iconTint, preDot = stringReference( - item.amount.value.format { + item.fee.amount.value.format { crypto( - symbol = item.amount.currencySymbol, - decimals = item.amount.decimals, + symbol = item.fee.amount.currencySymbol, + decimals = item.fee.amount.decimals, ).fee(canBeLower = state.isFeeApproximate) }, ), - postDot = if (state.feeFiatRateDataHolder != null) { + postDot = if (state.feeFiatRateUM != null) { getFiatReference( - value = item.amount.value, - rate = state.feeFiatRateDataHolder.rate, - appCurrency = state.feeFiatRateDataHolder.appCurrency, + value = item.fee.amount.value, + rate = state.feeFiatRateUM.rate, + appCurrency = state.feeFiatRateUM.appCurrency, ) } else { null }, - ellipsizeOffset = item.amount.currencySymbol.length, + ellipsizeOffset = item.fee.amount.currencySymbol.length, showDivider = !isSelected && !lastItem, ) is FeeItem.Slow -> RegularFeeItemContent( @@ -161,23 +170,23 @@ private fun FeeSelectorItems(state: FeeSelectorUM.Content, modifier: Modifier = iconBackgroundColor = iconBackgroundColor, iconTint = iconTint, preDot = stringReference( - item.amount.value.format { + item.fee.amount.value.format { crypto( - symbol = item.amount.currencySymbol, - decimals = item.amount.decimals, + symbol = item.fee.amount.currencySymbol, + decimals = item.fee.amount.decimals, ).fee(canBeLower = state.isFeeApproximate) }, ), - postDot = if (state.feeFiatRateDataHolder != null) { + postDot = if (state.feeFiatRateUM != null) { getFiatReference( - value = item.amount.value, - rate = state.feeFiatRateDataHolder.rate, - appCurrency = state.feeFiatRateDataHolder.appCurrency, + value = item.fee.amount.value, + rate = state.feeFiatRateUM.rate, + appCurrency = state.feeFiatRateUM.appCurrency, ) } else { null }, - ellipsizeOffset = item.amount.currencySymbol.length, + ellipsizeOffset = item.fee.amount.currencySymbol.length, showDivider = !isSelected && !lastItem, ) is FeeItem.Market -> RegularFeeItemContent( @@ -187,23 +196,23 @@ private fun FeeSelectorItems(state: FeeSelectorUM.Content, modifier: Modifier = iconBackgroundColor = iconBackgroundColor, iconTint = iconTint, preDot = stringReference( - item.amount.value.format { + item.fee.amount.value.format { crypto( - symbol = item.amount.currencySymbol, - decimals = item.amount.decimals, + symbol = item.fee.amount.currencySymbol, + decimals = item.fee.amount.decimals, ).fee(canBeLower = state.isFeeApproximate) }, ), - postDot = if (state.feeFiatRateDataHolder != null) { + postDot = if (state.feeFiatRateUM != null) { getFiatReference( - value = item.amount.value, - rate = state.feeFiatRateDataHolder.rate, - appCurrency = state.feeFiatRateDataHolder.appCurrency, + value = item.fee.amount.value, + rate = state.feeFiatRateUM.rate, + appCurrency = state.feeFiatRateUM.appCurrency, ) } else { null }, - ellipsizeOffset = item.amount.currencySymbol.length, + ellipsizeOffset = item.fee.amount.currencySymbol.length, showDivider = !isSelected && !lastItem, ) is FeeItem.Fast -> RegularFeeItemContent( @@ -213,23 +222,23 @@ private fun FeeSelectorItems(state: FeeSelectorUM.Content, modifier: Modifier = iconBackgroundColor = iconBackgroundColor, iconTint = iconTint, preDot = stringReference( - item.amount.value.format { + item.fee.amount.value.format { crypto( - symbol = item.amount.currencySymbol, - decimals = item.amount.decimals, + symbol = item.fee.amount.currencySymbol, + decimals = item.fee.amount.decimals, ).fee(canBeLower = state.isFeeApproximate) }, ), - postDot = if (state.feeFiatRateDataHolder != null) { + postDot = if (state.feeFiatRateUM != null) { getFiatReference( - value = item.amount.value, - rate = state.feeFiatRateDataHolder.rate, - appCurrency = state.feeFiatRateDataHolder.appCurrency, + value = item.fee.amount.value, + rate = state.feeFiatRateUM.rate, + appCurrency = state.feeFiatRateUM.appCurrency, ) } else { null }, - ellipsizeOffset = item.amount.currencySymbol.length, + ellipsizeOffset = item.fee.amount.currencySymbol.length, showDivider = !isSelected && !lastItem, ) is FeeItem.Custom -> CustomFeeBlock( @@ -238,18 +247,25 @@ private fun FeeSelectorItems(state: FeeSelectorUM.Content, modifier: Modifier = isSelected = isSelected, iconBackgroundColor = iconBackgroundColor, iconTint = iconTint, + displayNonceInput = state.displayNonceInput, + nonce = state.nonce, + onNonceChange = state.onNonceChange, ) } } } } +@Suppress("LongParameterList") @Composable private fun CustomFeeBlock( customFee: FeeItem.Custom, isSelected: Boolean, iconBackgroundColor: Color, iconTint: Color, + displayNonceInput: Boolean, + nonce: BigInteger?, + onNonceChange: (String) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -279,7 +295,13 @@ private fun CustomFeeBlock( enter = expandVertically().plus(fadeIn()), exit = shrinkVertically().plus(fadeOut()), ) { - ExpandedCustomFeeItems(customFeeFields = customFee.customValues, onValueChange = { _, _ -> }) + ExpandedCustomFeeItems( + customFeeFields = customFee.customValues, + onValueChange = { _, _ -> }, + displayNonceInput = displayNonceInput, + nonce = nonce, + onNonceChange = onNonceChange, + ) } } } @@ -288,11 +310,14 @@ private fun CustomFeeBlock( private fun ExpandedCustomFeeItems( customFeeFields: ImmutableList, onValueChange: (Int, String) -> Unit, + displayNonceInput: Boolean, + nonce: BigInteger?, + onNonceChange: (String) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { customFeeFields.fastForEachIndexed { index, field -> - val showDivider = index != customFeeFields.size - 1 + val showDivider = index != customFeeFields.size - 1 || displayNonceInput if (field.label != null) { InputRowEnterInfoAmountV2( text = field.value, @@ -321,6 +346,21 @@ private fun ExpandedCustomFeeItems( ) } } + + if (displayNonceInput) { + // TODO implement v2 input without binding to amount + InputRowEnterInfoAmountV2( + text = nonce?.toString() ?: "", + decimals = 0, + title = resourceReference(R.string.send_nonce), + titleColor = TangemTheme.colors.text.tertiary, + symbol = null, + onValueChange = onNonceChange, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + keyboardActions = KeyboardActions(), + showDivider = false, + ) + } } } @@ -433,78 +473,72 @@ private fun FeeSelectorBS_Preview( private class FeeSelectorUMContentProvider : CollectionPreviewParameterProvider( collection = listOf( FeeSelectorUM.Content( - isDoneEnabled = false, + doneButtonConfig = PrimaryButtonConfig(enabled = true, onClick = {}), feeItems = persistentListOf( FeeItem.Suggested( title = stringReference("Suggested by Tangem"), - amount = Amount(value = BigDecimal("0.1"), blockchain = Blockchain.Ethereum), - onSelect = onSelectStub, + fee = Fee.Common(Amount(value = BigDecimal("0.1"), blockchain = Blockchain.Ethereum)), ), - FeeItem.Slow( - amount = Amount(value = BigDecimal("0.01"), blockchain = Blockchain.Ethereum), - onSelect = onSelectStub, - ), - FeeItem.Market( - amount = Amount(value = BigDecimal("0.02"), blockchain = Blockchain.Ethereum), - onSelect = onSelectStub, - ), - FeeItem.Fast( - amount = Amount(value = BigDecimal("0.3"), blockchain = Blockchain.Ethereum), - onSelect = onSelectStub, - ), - FeeItem.Custom(customValues = customFeeFields, onSelect = onSelectStub), + FeeItem.Slow(fee = Fee.Common(Amount(value = BigDecimal("0.01"), blockchain = Blockchain.Ethereum))), + FeeItem.Market(fee = Fee.Common(Amount(value = BigDecimal("0.02"), blockchain = Blockchain.Ethereum))), + FeeItem.Fast(fee = Fee.Common(Amount(value = BigDecimal("0.03"), blockchain = Blockchain.Ethereum))), + customFeeItem, ), + onFeeSelected = {}, // selectedFeeItem = FeeItem.Market( // amount = Amount(value = BigDecimal("0.02"), blockchain = Blockchain.Ethereum), - // onSelect = onSelectStub // ), - selectedFeeItem = FeeItem.Custom(customValues = customFeeFields, onSelect = onSelectStub), + selectedFeeItem = customFeeItem, isFeeApproximate = true, - feeFiatRateDataHolder = FeeFiatRateDataHolder( + feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal.TEN, appCurrency = AppCurrency.Default, ), + displayNonceInput = true, + onNonceChange = {}, + nonce = null, ), ), ) -private val onSelectStub: () -> Unit = {} - -private val customFeeFields = persistentListOf( - CustomFeeFieldUM( - value = "0.119806", - onValueChange = {}, - keyboardOptions = KeyboardOptions(), - keyboardActions = KeyboardActions(), - symbol = "ETH", - decimals = 8, - title = resourceReference(R.string.send_max_fee), - footer = resourceReference(R.string.send_custom_amount_fee_footer), - label = stringReference("~ 0,03 \$"), - isReadonly = false, - ), - CustomFeeFieldUM( - value = "40000", - onValueChange = {}, - keyboardOptions = KeyboardOptions(), - keyboardActions = KeyboardActions(), - symbol = "GWEI", - decimals = 8, - title = resourceReference(R.string.send_gas_price), - footer = resourceReference(R.string.send_gas_price_footer), - label = null, - isReadonly = false, - ), - CustomFeeFieldUM( - value = "31400", - onValueChange = {}, - keyboardOptions = KeyboardOptions(), - keyboardActions = KeyboardActions(), - symbol = null, - decimals = 8, - title = resourceReference(R.string.send_gas_limit), - footer = resourceReference(R.string.send_gas_limit_footer), - label = null, - isReadonly = false, +private val customFeeItem = FeeItem.Custom( + fee = Fee.Common(Amount(value = BigDecimal("0.05"), blockchain = Blockchain.Ethereum)), + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.119806", + onValueChange = {}, + keyboardOptions = KeyboardOptions(), + keyboardActions = KeyboardActions(), + symbol = "ETH", + decimals = 8, + title = resourceReference(R.string.send_max_fee), + footer = resourceReference(R.string.send_custom_amount_fee_footer), + label = stringReference("~ 0,03 \$"), + isReadonly = false, + ), + CustomFeeFieldUM( + value = "40000", + onValueChange = {}, + keyboardOptions = KeyboardOptions(), + keyboardActions = KeyboardActions(), + symbol = "GWEI", + decimals = 8, + title = resourceReference(R.string.send_gas_price), + footer = resourceReference(R.string.send_gas_price_footer), + label = null, + isReadonly = false, + ), + CustomFeeFieldUM( + value = "31400", + onValueChange = {}, + keyboardOptions = KeyboardOptions(), + keyboardActions = KeyboardActions(), + symbol = null, + decimals = 8, + title = resourceReference(R.string.send_gas_limit), + footer = resourceReference(R.string.send_gas_limit_footer), + label = null, + isReadonly = false, + ), ), ) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt index 36b57ddd7c..d38c5b42a8 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt @@ -206,6 +206,7 @@ internal class DefaultSendComponent @AssistedInject constructor( childStack = childStack, ) }, + isRedesignEnabled = model.uiState.value.isRedesignEnabled, ), ) } else { @@ -225,6 +226,7 @@ internal class DefaultSendComponent @AssistedInject constructor( return if (sendAmount != null && destinationAddress != null && feeCryptoCurrencyStatus != null && cryptoCurrencyStatus != null ) { + // TODO Apply new component [REDACTED_TASK_KEY] SendFeeComponent( appComponentContext = factoryContext, params = SendFeeComponentParams.FeeParams( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt index 90e33cdec8..30a9234904 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt @@ -2,8 +2,11 @@ package com.tangem.features.send.v2.send.analytics import com.tangem.core.analytics.models.AnalyticsEvent 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.NONCE import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM +import com.tangem.core.ui.extensions.capitalize import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents /** @@ -18,11 +21,15 @@ internal sealed class SendAnalyticEvents( data class TransactionScreenOpened( val token: String, val feeType: AnalyticsParam.FeeType, + val blockchain: String, + val nonceNotEmpty: Boolean, ) : SendAnalyticEvents( event = "Transaction Sent Screen Opened", params = mapOf( TOKEN_PARAM to token, FEE_TYPE to feeType.value, + BLOCKCHAIN to blockchain, + NONCE to nonceNotEmpty.toString().capitalize(), ), ) } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt index be2690fcc3..807a4f0542 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt @@ -26,6 +26,8 @@ internal class SendAnalyticHelper @Inject constructor( SendAnalyticEvents.TransactionScreenOpened( token = cryptoCurrency.symbol, feeType = feeType, + blockchain = cryptoCurrency.network.name, + nonceNotEmpty = feeSelectorUM.nonce != null, ), ) analyticsEventHandler.send( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt index a7145d5d6e..9a34d59c65 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt @@ -65,6 +65,7 @@ internal class SendConfirmComponent( appCurrency = params.appCurrency, blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), predefinedValues = params.predefinedValues, + isRedesignEnabled = model.uiState.value.isRedesignEnabled, ), onResult = model::onAmountResult, onClick = model::showEditAmount, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt index d5dcfadac4..29a3e1e93d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -320,6 +320,7 @@ internal class SendConfirmModel @Inject constructor( val memo = destinationUM?.memoTextField?.value val fee = feeSelectorUM?.selectedFee val feeValue = fee?.amount?.value ?: return + val nonce = feeSelectorUM?.nonce val receivingAmount = checkAndCalculateSubtractedAmount( isAmountSubtractAvailable = isAmountSubtractAvailable, @@ -334,6 +335,7 @@ internal class SendConfirmModel @Inject constructor( amount = receivingAmount.convertToSdkAmount(cryptoCurrency), fee = fee, memo = memo, + nonce = nonce, destination = destination, userWalletId = userWallet.walletId, network = cryptoCurrency.network, @@ -454,6 +456,7 @@ internal class SendConfirmModel @Inject constructor( } } + @Suppress("LongMethod") private fun configConfirmNavigation() { combine( flow = uiState, @@ -470,7 +473,11 @@ internal class SendConfirmModel @Inject constructor( id = R.string.send_summary_title, formatArgs = wrappedList(params.cryptoCurrencyStatus.currency.name), ), - subtitle = amountUM?.title, + subtitle = if (uiState.value.isRedesignEnabled) { + null + } else { + amountUM?.title + }, backIconRes = R.drawable.ic_close_24, backIconClick = { analyticsEventHandler.send( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt index 4f13d834a7..a6162f90f2 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt @@ -98,8 +98,13 @@ private fun LazyListScope.blocks( modifier = Modifier.padding(vertical = 12.dp), ) } - destinationBlockComponent.Content(modifier = Modifier) - amountBlockComponent.Content(modifier = Modifier) + if (uiState.isRedesignEnabled) { + amountBlockComponent.Content(modifier = Modifier) + destinationBlockComponent.Content(modifier = Modifier) + } else { + destinationBlockComponent.Content(modifier = Modifier) + amountBlockComponent.Content(modifier = Modifier) + } feeBlockComponent.Content(modifier = Modifier) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index 6c4430e910..a61ec8f1e6 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -37,6 +37,7 @@ import com.tangem.domain.wallets.models.isMultiCurrency import com.tangem.domain.wallets.models.requireColdWallet import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.v2.api.SendComponent +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.common.CommonSendRoute import com.tangem.features.send.v2.common.PredefinedValues import com.tangem.features.send.v2.common.SendConfirmAlertFactory @@ -87,6 +88,7 @@ internal class SendModel @Inject constructor( private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val getFeeUseCase: GetFeeUseCase, private val sendAmountUpdateQRTrigger: SendAmountUpdateQRTrigger, + private val sendFeatureToggles: SendFeatureToggles, ) : Model(), SendComponentCallback { private val params: SendComponent.Params = paramsContainer.require() @@ -361,9 +363,11 @@ internal class SendModel @Inject constructor( amountUM = AmountState.Empty(), destinationUM = SendDestinationInitialStateTransformer( cryptoCurrency = cryptoCurrency, + isRedesignEnabled = sendFeatureToggles.isSendRedesignEnabled, ).transform(DestinationUM.Empty()), feeUM = FeeUM.Empty(), confirmUM = ConfirmUM.Empty, navigationUM = NavigationUM.Empty, + isRedesignEnabled = sendFeatureToggles.isSendRedesignEnabled, ) } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt index 8359df5247..a84a77e878 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt @@ -12,4 +12,5 @@ internal data class SendUM( val feeUM: FeeUM, val confirmUM: ConfirmUM, val navigationUM: NavigationUM, + val isRedesignEnabled: Boolean, ) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt index b05e56c031..7bf133c215 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.ui.AmountBlock +import com.tangem.common.ui.amountScreen.ui.AmountBlockV2 import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent @@ -37,11 +38,24 @@ internal class SendAmountBlockComponent( val state by model.uiState.collectAsStateWithLifecycle() val isClickEnabled by params.blockClickEnableFlow.collectAsStateWithLifecycle() - AmountBlock( - amountState = state, - isClickDisabled = !isClickEnabled, - isEditingDisabled = params.predefinedValues is PredefinedValues.Content.Deeplink, - onClick = onClick, - ) + if (params.isRedesignEnabled) { + val amountState = state as? AmountState.Data ?: return + + AmountBlockV2( + amountState = state, + currencyIconState = amountState.tokenIconState, + isClickDisabled = !isClickEnabled, + isEditingDisabled = params.predefinedValues is PredefinedValues.Content.Deeplink, + onClick = onClick, + modifier = modifier, + ) + } else { + AmountBlock( + amountState = state, + isClickDisabled = !isClickEnabled, + isEditingDisabled = params.predefinedValues is PredefinedValues.Content.Deeplink, + onClick = onClick, + ) + } } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt index 7a59f9cfde..a13dcf3ef1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt @@ -18,6 +18,7 @@ internal sealed class SendAmountComponentParams { abstract val appCurrency: AppCurrency abstract val cryptoCurrencyStatus: CryptoCurrencyStatus abstract val predefinedValues: PredefinedValues + abstract val isRedesignEnabled: Boolean data class AmountParams( override val state: AmountState, @@ -26,6 +27,7 @@ internal sealed class SendAmountComponentParams { override val appCurrency: AppCurrency, override val cryptoCurrencyStatus: CryptoCurrencyStatus, override val predefinedValues: PredefinedValues, + override val isRedesignEnabled: Boolean, val callback: ModelCallback, val currentRoute: Flow, val isBalanceHidingFlow: StateFlow, @@ -40,6 +42,7 @@ internal sealed class SendAmountComponentParams { override val appCurrency: AppCurrency, override val cryptoCurrencyStatus: CryptoCurrencyStatus, override val predefinedValues: PredefinedValues, + override val isRedesignEnabled: Boolean, val blockClickEnableFlow: StateFlow, ) : SendAmountComponentParams() } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt index 691bd074ca..d60c1b6b81 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt @@ -17,6 +17,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.common.PredefinedValues import com.tangem.features.send.v2.common.ui.state.NavigationUM import com.tangem.features.send.v2.impl.R @@ -47,6 +48,7 @@ internal class SendAmountModel @Inject constructor( private val feeReloadTrigger: SendFeeReloadTrigger, private val sendAmountUpdateQRListener: SendAmountUpdateQRListener, private val analyticsEventHandler: AnalyticsEventHandler, + private val sendFeatureToggles: SendFeatureToggles, ) : Model(), AmountScreenClickIntents { private val params: SendAmountComponentParams = paramsContainer.require() @@ -95,6 +97,7 @@ internal class SendAmountModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, maxEnterAmount = maxAmountBoundary, iconStateConverter = CryptoCurrencyToIconStateConverter(), + isRedesignEnabled = sendFeatureToggles.isSendRedesignEnabled, ).convert( AmountParameters( title = stringReference(userWallet.name), diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt index 5f500027fa..f95975b12b 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt @@ -40,6 +40,7 @@ internal class SendDestinationBlockComponent( destinationUM = state, isClickDisabled = !isClickEnabled, isEditingDisabled = params.predefinedValues is PredefinedValues.Content.Deeplink, + isRedesignEnabled = (params.state as? DestinationUM.Content)?.isRedesignEnabled ?: false, onClick = onClick, ) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index 8ccf0bd907..6bd184f1fc 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -23,6 +23,7 @@ import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.isLocked import com.tangem.domain.wallets.models.isMultiCurrency import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.common.PredefinedValues import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents.SendScreenSource @@ -65,6 +66,7 @@ internal class SendDestinationModel @Inject constructor( private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val parseQrCodeUseCase: ParseQrCodeUseCase, private val analyticsEventHandler: AnalyticsEventHandler, + private val sendFeatureToggles: SendFeatureToggles, ) : Model(), SendDestinationClickIntents { private val params: SendDestinationComponentParams = paramsContainer.require() @@ -90,6 +92,7 @@ internal class SendDestinationModel @Inject constructor( _uiState.update( SendDestinationInitialStateTransformer( cryptoCurrency = cryptoCurrency, + isRedesignEnabled = sendFeatureToggles.isSendRedesignEnabled, isInitialized = true, ), ) @@ -297,6 +300,7 @@ internal class SendDestinationModel @Inject constructor( flow2 = params.currentRoute, transform = { state, route -> state to route }, ).onEach { (state, route) -> + val isRedesignEnabled = sendFeatureToggles.isSendRedesignEnabled params.callback.onNavigationResult( NavigationUM.Content( title = params.title, @@ -319,8 +323,16 @@ internal class SendDestinationModel @Inject constructor( } params.onBackClick() }, - additionalIconRes = R.drawable.ic_qrcode_scan_24, - additionalIconClick = ::onQrCodeScanClick, + additionalIconRes = if (isRedesignEnabled) { + null + } else { + R.drawable.ic_qrcode_scan_24 + }, + additionalIconClick = if (isRedesignEnabled) { + null + } else { + ::onQrCodeScanClick + }, primaryButton = ButtonsUM.PrimaryButtonUM( text = if (route.isEditMode) { resourceReference(R.string.common_continue) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt index 76de538ac5..8ad0687969 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt @@ -13,6 +13,7 @@ import com.tangem.utils.transformer.Transformer internal class SendDestinationInitialStateTransformer( val cryptoCurrency: CryptoCurrency, + val isRedesignEnabled: Boolean, val isInitialized: Boolean = false, ) : Transformer { override fun transform(prevState: DestinationUM): DestinationUM { @@ -54,6 +55,7 @@ internal class SendDestinationInitialStateTransformer( recent = loadingListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT), networkName = cryptoCurrency.network.name, isValidating = false, + isRedesignEnabled = isRedesignEnabled, ) } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt index 9fe2722f12..3debd43e2f 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt @@ -1,26 +1,38 @@ package com.tangem.features.send.v2.subcomponents.destination.ui +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.tangem.core.ui.components.icons.identicon.IdentIcon +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationRecipientListUM import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationTextFieldUM import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import kotlinx.collections.immutable.toImmutableList @Composable internal fun DestinationBlock( destinationUM: DestinationUM, isClickDisabled: Boolean, isEditingDisabled: Boolean, + isRedesignEnabled: Boolean, onClick: () -> Unit, ) { if (destinationUM !is DestinationUM.Content) return @@ -33,8 +45,15 @@ internal fun DestinationBlock( .clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick) .padding(TangemTheme.dimens.spacing12), ) { - AddressBlock(destinationUM.addressTextField) - MemoBlock(destinationUM.memoTextField) + if (isRedesignEnabled) { + AddressWithMemoBlock( + address = destinationUM.addressTextField, + memo = destinationUM.memoTextField, + ) + } else { + AddressBlock(destinationUM.addressTextField) + MemoBlock(destinationUM.memoTextField) + } } } @@ -84,4 +103,124 @@ private fun MemoBlock(memo: DestinationTextFieldUM.RecipientMemo?) { modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), ) } +} + +@Composable +private fun AddressWithMemoBlock( + address: DestinationTextFieldUM.RecipientAddress, + memo: DestinationTextFieldUM.RecipientMemo?, +) { + Text( + text = stringResourceSafe(R.string.send_to_address), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24), + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + ) { + Text( + modifier = Modifier.weight(1f), + text = address.value, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + IdentIcon( + address = address.value, + modifier = Modifier + .size(TangemTheme.dimens.size36) + .clip(RoundedCornerShape(TangemTheme.dimens.radius18)) + .background(TangemTheme.colors.background.tertiary), + ) + } + if (memo != null && memo.value.isNotBlank()) { + Text( + text = stringResourceSafe(R.string.send_memo, memo.value), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun DestinationBlockPreview( + @PreviewParameter(DestinationBlockPreviewProvider::class) state: DestinationUM.Content, +) { + TangemThemePreview { + DestinationBlock( + destinationUM = state, + isClickDisabled = false, + isEditingDisabled = false, + isRedesignEnabled = state.isRedesignEnabled, + onClick = {}, + ) + } +} + +private class DestinationBlockPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + DestinationUM.Content( + isPrimaryButtonEnabled = true, + addressTextField = DestinationTextFieldUM.RecipientAddress( + value = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE", + keyboardOptions = KeyboardOptions.Default, + placeholder = TextReference.Str("Enter address"), + label = TextReference.Str("Recipient Address"), + isError = false, + error = null, + isValuePasted = false, + ), + memoTextField = DestinationTextFieldUM.RecipientMemo( + value = "Test memo for transaction", + keyboardOptions = KeyboardOptions.Default, + placeholder = TextReference.Str("Enter memo (optional)"), + label = TextReference.Str("Memo"), + isError = false, + error = null, + disabledText = TextReference.Str("Memo disabled"), + isEnabled = true, + isValuePasted = false, + ), + recent = emptyList().toImmutableList(), + wallets = emptyList().toImmutableList(), + networkName = "Ethereum", + isValidating = false, + isInitialized = true, + isRedesignEnabled = false, + ), + DestinationUM.Content( + isPrimaryButtonEnabled = true, + addressTextField = DestinationTextFieldUM.RecipientAddress( + value = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE", + keyboardOptions = KeyboardOptions.Default, + placeholder = TextReference.Str("Enter address"), + label = TextReference.Str("Recipient Address"), + isError = false, + error = null, + isValuePasted = false, + ), + memoTextField = DestinationTextFieldUM.RecipientMemo( + value = "Test memo for transaction", + keyboardOptions = KeyboardOptions.Default, + placeholder = TextReference.Str("Enter memo (optional)"), + label = TextReference.Str("Memo"), + isError = false, + error = null, + disabledText = TextReference.Str("Memo disabled"), + isEnabled = true, + isValuePasted = false, + ), + recent = emptyList().toImmutableList(), + wallets = emptyList().toImmutableList(), + networkName = "Ethereum", + isValidating = false, + isInitialized = true, + isRedesignEnabled = true, + ), + ) } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt index 4caf6be2e2..1014976ae1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt @@ -16,6 +16,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.containers.FooterContainer import com.tangem.core.ui.components.inputrow.InputRowRecipient @@ -60,10 +64,13 @@ internal fun SendDestinationContent( isError = isError, isValidating = isValidating, onAddressChange = clickIntents::onRecipientAddressValueChange, + onQrCodeClick = clickIntents::onQrCodeScanClick, + isRedesignEnabled = state.isRedesignEnabled, ) memoField( memoField = memoField, onMemoChange = clickIntents::onRecipientMemoValueChange, + isRedesignEnabled = state.isRedesignEnabled, ) listHeaderItem( titleRes = R.string.send_recipient_wallets_title, @@ -100,16 +107,23 @@ internal fun SendDestinationContent( } } +@Suppress("LongParameterList") private fun LazyListScope.addressItem( address: DestinationTextFieldUM.RecipientAddress, networkName: String, isError: Boolean, isValidating: Boolean, onAddressChange: (String, EnterAddressSource) -> Unit, + onQrCodeClick: () -> Unit, + isRedesignEnabled: Boolean, ) { item(key = ADDRESS_FIELD_KEY) { FooterContainer( - footer = resourceReference(R.string.send_recipient_address_footer, wrappedList(networkName)), + footer = if (isRedesignEnabled) { + buildHighlightedFooterText(networkName) + } else { + resourceReference(R.string.send_recipient_address_footer, wrappedList(networkName)) + }, ) { InputRowRecipient( value = address.value, @@ -117,6 +131,8 @@ private fun LazyListScope.addressItem( placeholder = address.placeholder, onValueChange = { onAddressChange(it, EnterAddressSource.InputField) }, onPasteClick = { onAddressChange(it, EnterAddressSource.PasteButton) }, + onQrCodeClick = onQrCodeClick, + isRedesignEnabled = isRedesignEnabled, isError = isError, isLoading = isValidating, error = address.error, @@ -131,9 +147,37 @@ private fun LazyListScope.addressItem( } } +@Composable +private fun buildHighlightedFooterText(networkName: String): TextReference { + return annotatedReference( + buildAnnotatedString { + val styledText = stringResourceSafe( + R.string.send_recipient_address_footer_highlighted_part, + networkName, + ) + val styledColor = TangemTheme.colors.text.secondary + appendWithStyledPlaceholder( + template = stringResourceSafe( + R.string.send_recipient_address_footer_v2, + networkName, + ), + placeholder = networkName, + ) { + withStyle(SpanStyle(fontWeight = FontWeight.Medium)) { + appendColored( + text = styledText, + color = styledColor, + ) + } + } + }, + ) +} + private fun LazyListScope.memoField( memoField: DestinationTextFieldUM.RecipientMemo?, onMemoChange: (String, Boolean) -> Unit, + isRedesignEnabled: Boolean, ) { if (memoField != null) { item(key = MEMO_FIELD_KEY) { @@ -142,7 +186,24 @@ private fun LazyListScope.memoField( value = memoField.value, label = memoField.label, placeholder = placeholder, - footer = resourceReference(R.string.send_recipient_memo_footer), + footer = if (isRedesignEnabled) { + annotatedReference( + buildAnnotatedString { + append(stringResourceSafe(R.string.send_recipient_memo_footer_v2)) + append("\n") + withStyle(SpanStyle(fontWeight = FontWeight.Medium)) { + appendColored( + text = stringResourceSafe( + R.string.send_recipient_memo_footer_v2_highlighted, + ), + color = TangemTheme.colors.text.secondary, + ) + } + }, + ) + } else { + resourceReference(R.string.send_recipient_memo_footer) + }, onValueChange = { onMemoChange(it, false) }, onPasteClick = { onMemoChange(it, true) }, modifier = Modifier.padding(top = 20.dp), @@ -151,6 +212,7 @@ private fun LazyListScope.memoField( error = memoField.error, isReadOnly = !memoField.isEnabled, isValuePasted = memoField.isValuePasted, + isRedesignEnabled = isRedesignEnabled, ) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/TextFieldWithPaste.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/TextFieldWithPaste.kt index 162ab6bf7b..2c2fe6de6a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/TextFieldWithPaste.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/TextFieldWithPaste.kt @@ -20,6 +20,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.components.containers.FooterContainer +@Suppress("LongMethod", "LongParameterList") @Composable internal fun TextFieldWithPaste( value: String, @@ -27,6 +28,7 @@ internal fun TextFieldWithPaste( label: TextReference, onValueChange: (String) -> Unit, onPasteClick: (String) -> Unit, + isRedesignEnabled: Boolean, modifier: Modifier = Modifier, footer: TextReference? = null, labelStyle: TextStyle = TangemTheme.typography.body2, @@ -96,6 +98,18 @@ internal fun TextFieldWithPaste( PasteButton( isPasteButtonVisible = value.isBlank(), onClick = onPasteClick, + backgroundColorEnabled = if (isRedesignEnabled) { + TangemTheme.colors.button.secondary + } else { + TangemTheme.colors.button.primary + }, + textColor = if (isRedesignEnabled) { + TangemTheme.colors.text.primary1 + } else { + TangemTheme.colors.text.primary2 + }, + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing8), ) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationUM.kt index 06935a8368..8a73b412f0 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationUM.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationUM.kt @@ -17,6 +17,7 @@ internal sealed class DestinationUM { val networkName: String, val isValidating: Boolean = false, val isInitialized: Boolean = false, + val isRedesignEnabled: Boolean = false, ) : DestinationUM() data class Empty( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeClickIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeClickIntents.kt index 656699429a..bffe9c81a2 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeClickIntents.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeClickIntents.kt @@ -10,6 +10,8 @@ internal interface SendFeeClickIntents { fun onCustomFeeValueChange(index: Int, value: String) + fun onNonceChange(value: String) + fun onReadMoreClick() fun onNextClick() diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt index 570cb197f7..42b2a1da02 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt @@ -9,6 +9,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase +import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents.NonceInserted import com.tangem.features.send.v2.common.ui.state.NavigationUM import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.send.ui.state.ButtonsUM @@ -129,6 +130,12 @@ internal class SendFeeModel @Inject constructor( updateFeeNotifications() } + override fun onNonceChange(value: String) { + _uiState.update( + SendFeeNonceChangeTransformer(value = value), + ) + } + override fun onReadMoreClick() { val locale = if (Locale.getDefault().language == RU_LOCALE) RU_LOCALE else EN_LOCALE val url = buildString { @@ -157,6 +164,16 @@ internal class SendFeeModel @Inject constructor( ), ) + if (feeSelectorUM.nonce != null) { + analyticsEventHandler.send( + NonceInserted( + categoryName = analyticsCategoryName, + token = cryptoCurrencyStatus.currency.symbol, + blockchain = cryptoCurrencyStatus.currency.network.name, + ), + ) + } + saveResult() } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/FeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/FeeConverter.kt index b8c9fecfcf..2a5863ed1d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/FeeConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/FeeConverter.kt @@ -4,8 +4,8 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/SendFeeCustomFieldConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/SendFeeCustomFieldConverter.kt index 26603db4d2..1f6cfc86d7 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/SendFeeCustomFieldConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/SendFeeCustomFieldConverter.kt @@ -5,11 +5,11 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.bitcoin.BitcoinCustomFeeConverter import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.kaspa.KaspaCustomFeeConverter +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM import com.tangem.utils.converter.TwoWayConverter import kotlinx.collections.immutable.ImmutableList @@ -24,7 +24,8 @@ internal class SendFeeCustomFieldConverter( private val ethereumCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) { EthereumCustomFeeConverter( - clickIntents = clickIntents, + onCustomFeeValueChange = clickIntents::onCustomFeeValueChange, + onNextClick = clickIntents::onNextClick, appCurrency = appCurrency, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, ) @@ -32,7 +33,8 @@ internal class SendFeeCustomFieldConverter( private val bitcoinCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) { BitcoinCustomFeeConverter( - clickIntents = clickIntents, + onCustomFeeValueChange = clickIntents::onCustomFeeValueChange, + onNextClick = clickIntents::onNextClick, appCurrency = appCurrency, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, ) @@ -40,7 +42,7 @@ internal class SendFeeCustomFieldConverter( private val kaspaCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) { KaspaCustomFeeConverter( - clickIntents = clickIntents, + onCustomFeeValueChange = clickIntents::onCustomFeeValueChange, appCurrency = appCurrency, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt index 19645e440b..4250d48580 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt @@ -1,7 +1,7 @@ package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom import com.tangem.blockchain.common.transaction.Fee -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt index 548887f56d..feaed422ac 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt @@ -11,11 +11,10 @@ import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM import com.tangem.features.send.v2.impl.R -import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents import com.tangem.features.send.v2.subcomponents.fee.model.checkExceedBalance import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.CustomFeeConverter +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM import com.tangem.lib.crypto.BlockchainUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -24,7 +23,8 @@ import java.math.BigDecimal import java.math.RoundingMode internal class BitcoinCustomFeeConverter( - private val clickIntents: SendFeeClickIntents, + private val onCustomFeeValueChange: (Int, String) -> Unit, + private val onNextClick: () -> Unit, private val appCurrency: AppCurrency, private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, ) : CustomFeeConverter { @@ -40,7 +40,7 @@ internal class BitcoinCustomFeeConverter( value = feeValue?.parseBigDecimal(value.amount.decimals).orEmpty(), decimals = value.amount.decimals, symbol = value.amount.currencySymbol, - onValueChange = { clickIntents.onCustomFeeValueChange(FEE_AMOUNT_INDEX, it) }, + onValueChange = { onCustomFeeValueChange(FEE_AMOUNT_INDEX, it) }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Companion.Next, keyboardType = KeyboardType.Companion.Number, @@ -65,7 +65,7 @@ internal class BitcoinCustomFeeConverter( symbol = "", title = resourceReference(R.string.send_satoshi_per_byte_title), footer = resourceReference(R.string.send_satoshi_per_byte_text), - onValueChange = { clickIntents.onCustomFeeValueChange(FEE_SATOSHI_INDEX, it) }, + onValueChange = { onCustomFeeValueChange(FEE_SATOSHI_INDEX, it) }, keyboardOptions = KeyboardOptions( imeAction = if (checkExceedBalance( feeBalance = currencyStatus.amount, @@ -78,9 +78,7 @@ internal class BitcoinCustomFeeConverter( }, keyboardType = KeyboardType.Companion.Number, ), - keyboardActions = KeyboardActions( - onDone = { clickIntents.onNextClick() }, - ), + keyboardActions = KeyboardActions(onDone = { onNextClick() }), ), ) } else { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt index fbdd94c589..c0828d3d60 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt @@ -1,8 +1,8 @@ package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum import com.tangem.blockchain.common.transaction.Fee -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.CustomFeeConverter +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM import kotlinx.collections.immutable.ImmutableList /** diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt index f083aaa40c..674ef7d066 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt @@ -10,15 +10,15 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM import com.tangem.features.send.v2.impl.R -import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents import com.tangem.features.send.v2.subcomponents.fee.model.checkExceedBalance +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList internal class EthereumCustomFeeConverter( - private val clickIntents: SendFeeClickIntents, + private val onCustomFeeValueChange: (Int, String) -> Unit, + private val onNextClick: () -> Unit, private val appCurrency: AppCurrency, feeCryptoCurrencyStatus: CryptoCurrencyStatus, ) : BaseEthereumCustomFeeConverter { @@ -26,13 +26,13 @@ internal class EthereumCustomFeeConverter( private val currencyStatus = feeCryptoCurrencyStatus.value private val legacyFeeConverter = EthereumLegacyCustomFeeConverter( - clickIntents = clickIntents, + onCustomFeeValueChange = onCustomFeeValueChange, appCurrency = appCurrency, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, ) private val eipFeeConverter = EthereumEIPCustomFeeConverter( - clickIntents = clickIntents, + onCustomFeeValueChange = onCustomFeeValueChange, appCurrency = appCurrency, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, ) @@ -84,7 +84,7 @@ internal class EthereumCustomFeeConverter( value = feeValue?.parseBigDecimal(value.amount.decimals).orEmpty(), decimals = value.amount.decimals, symbol = value.amount.currencySymbol, - onValueChange = { clickIntents.onCustomFeeValueChange(FEE_AMOUNT, it) }, + onValueChange = { onCustomFeeValueChange(FEE_AMOUNT, it) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Number), title = resourceReference(R.string.send_max_fee), footer = resourceReference(R.string.send_custom_amount_fee_footer), @@ -106,13 +106,13 @@ internal class EthereumCustomFeeConverter( symbol = "", title = resourceReference(R.string.send_gas_limit), footer = resourceReference(R.string.send_gas_limit_footer), - onValueChange = { clickIntents.onCustomFeeValueChange(getGasLimitIndex(value), it) }, + onValueChange = { onCustomFeeValueChange(getGasLimitIndex(value), it) }, keyboardOptions = KeyboardOptions( imeAction = if (isExceedBalance) ImeAction.None else ImeAction.Done, keyboardType = KeyboardType.Number, ), keyboardActions = KeyboardActions( - onDone = { clickIntents.onNextClick() }, + onDone = { onNextClick() }, ), ) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt index 4a40819826..24d9dab7fc 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt @@ -11,22 +11,21 @@ import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM import com.tangem.features.send.v2.impl.R -import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents import com.tangem.features.send.v2.subcomponents.fee.model.checkExceedBalance import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.ETHEREUM_GAS_UNIT import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.FEE_AMOUNT import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.GAS_DECIMALS import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.GIGA_DECIMALS import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.setEmpty +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import java.math.RoundingMode internal class EthereumEIPCustomFeeConverter( - private val clickIntents: SendFeeClickIntents, + private val onCustomFeeValueChange: (Int, String) -> Unit, private val appCurrency: AppCurrency, private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, ) : BaseEthereumCustomFeeConverter { @@ -41,7 +40,7 @@ internal class EthereumEIPCustomFeeConverter( symbol = ETHEREUM_GAS_UNIT, title = resourceReference(R.string.send_custom_evm_max_fee), footer = resourceReference(R.string.send_custom_evm_max_fee_footer), - onValueChange = { clickIntents.onCustomFeeValueChange(MAX_FEE, it) }, + onValueChange = { onCustomFeeValueChange(MAX_FEE, it) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Number), keyboardActions = KeyboardActions(), ), @@ -51,7 +50,7 @@ internal class EthereumEIPCustomFeeConverter( symbol = ETHEREUM_GAS_UNIT, title = resourceReference(R.string.send_custom_evm_priority_fee), footer = resourceReference(R.string.send_custom_evm_priority_fee_footer), - onValueChange = { clickIntents.onCustomFeeValueChange(PRIORITY_FEE, it) }, + onValueChange = { onCustomFeeValueChange(PRIORITY_FEE, it) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Number), keyboardActions = KeyboardActions(), ), diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt index fe9af7c37b..ac680946cb 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt @@ -11,22 +11,21 @@ import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM import com.tangem.features.send.v2.impl.R -import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents import com.tangem.features.send.v2.subcomponents.fee.model.checkExceedBalance import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.ETHEREUM_GAS_UNIT import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.FEE_AMOUNT import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.GAS_DECIMALS import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.GIGA_DECIMALS import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.setEmpty +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import java.math.RoundingMode internal class EthereumLegacyCustomFeeConverter( - private val clickIntents: SendFeeClickIntents, + private val onCustomFeeValueChange: (Int, String) -> Unit, private val appCurrency: AppCurrency, feeCryptoCurrencyStatus: CryptoCurrencyStatus, ) : BaseEthereumCustomFeeConverter { @@ -41,7 +40,7 @@ internal class EthereumLegacyCustomFeeConverter( symbol = ETHEREUM_GAS_UNIT, title = resourceReference(R.string.send_gas_price), footer = resourceReference(R.string.send_gas_price_footer), - onValueChange = { clickIntents.onCustomFeeValueChange(GAS_PRICE, it) }, + onValueChange = { onCustomFeeValueChange(GAS_PRICE, it) }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Next, keyboardType = KeyboardType.Number, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt index af0c7a97b4..4dfd766663 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt @@ -11,17 +11,16 @@ import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM import com.tangem.features.send.v2.impl.R -import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.CustomFeeConverter +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import java.math.RoundingMode internal class KaspaCustomFeeConverter( - private val clickIntents: SendFeeClickIntents, + private val onCustomFeeValueChange: (Int, String) -> Unit, private val appCurrency: AppCurrency, feeCryptoCurrencyStatus: CryptoCurrencyStatus, ) : CustomFeeConverter { @@ -35,7 +34,7 @@ internal class KaspaCustomFeeConverter( value = feeValue?.parseBigDecimal(value.amount.decimals).orEmpty(), decimals = value.amount.decimals, symbol = value.amount.currencySymbol, - onValueChange = { clickIntents.onCustomFeeValueChange(FEE_AMOUNT_INDEX, it) }, + onValueChange = { onCustomFeeValueChange(FEE_AMOUNT_INDEX, it) }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Companion.Next, keyboardType = KeyboardType.Companion.Number, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadedTransformer.kt index b1a7fb7467..89a69d49f0 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadedTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadedTransformer.kt @@ -1,13 +1,14 @@ package com.tangem.features.send.v2.subcomponents.fee.model.transformers +import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents +import com.tangem.features.send.v2.subcomponents.fee.model.converters.FeeConverter +import com.tangem.features.send.v2.subcomponents.fee.model.converters.SendFeeCustomFieldConverter import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM -import com.tangem.features.send.v2.subcomponents.fee.model.converters.FeeConverter -import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents -import com.tangem.features.send.v2.subcomponents.fee.model.converters.SendFeeCustomFieldConverter import com.tangem.utils.transformer.Transformer internal class SendFeeLoadedTransformer( @@ -41,6 +42,7 @@ internal class SendFeeLoadedTransformer( fees = fees, customValues = customFeeFieldConverter.convert(fees.normal), selectedFee = fees.normal, + nonce = prevState.nonce, ) } else { FeeSelectorUM.Content( @@ -53,12 +55,15 @@ internal class SendFeeLoadedTransformer( customValues = feeSelectorUM.customValues, ), ), + nonce = prevState.nonce, ) } return state.copy( feeSelectorUM = updatedFeeSelector, isFeeApproximate = isFeeApproximate, + displayNonceInput = fees.normal is Fee.Ethereum, + nonce = prevState.nonce, ) } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeNonceChangeTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeNonceChangeTransformer.kt new file mode 100644 index 0000000000..8b453ed2d3 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeNonceChangeTransformer.kt @@ -0,0 +1,36 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.transformers + +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import com.tangem.utils.transformer.Transformer +import java.math.BigInteger + +internal class SendFeeNonceChangeTransformer( + private val value: String, +) : Transformer { + + override fun transform(prevState: FeeUM): FeeUM { + val state = prevState as? FeeUM.Content ?: return prevState + + if (value.isEmpty()) { + return state.copy(nonce = null) + } + + return try { + val nonce = BigInteger(value) + state.copy( + nonce = nonce, + feeSelectorUM = when (val feeSelectorUM = state.feeSelectorUM) { + is FeeSelectorUM.Loading, + is FeeSelectorUM.Error, + -> feeSelectorUM + is FeeSelectorUM.Content -> feeSelectorUM.copy( + nonce = nonce, + ) + }, + ) + } catch (e: NumberFormatException) { + state + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendCustomFee.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendCustomFee.kt index d597bdd4b3..95173f58c6 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendCustomFee.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendCustomFee.kt @@ -5,23 +5,31 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.containers.FooterContainer +import com.tangem.core.ui.components.inputrow.InputRowEnter import com.tangem.core.ui.components.inputrow.InputRowEnterAmount import com.tangem.core.ui.components.inputrow.InputRowEnterInfoAmount +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType import kotlinx.collections.immutable.ImmutableList +@Suppress("LongParameterList") @Composable internal fun SendCustomFee( customValues: ImmutableList, selectedFee: FeeType, hasNotifications: Boolean, onValueChange: (Int, String) -> Unit, + onNonceChange: (String) -> Unit, + nonce: String?, modifier: Modifier = Modifier, ) { AnimatedVisibility( @@ -77,6 +85,32 @@ internal fun SendCustomFee( } } } + Nonce( + onNonceChange = onNonceChange, + nonce = nonce, + ) } } +} + +@Composable +private fun Nonce(onNonceChange: (String) -> Unit, nonce: String?) { + FooterContainer( + footer = resourceReference(R.string.send_nonce_footer), + modifier = Modifier.padding(bottom = 12.dp), + ) { + InputRowEnter( + text = nonce.orEmpty(), + title = resourceReference(R.string.send_nonce), + onValueChange = onNonceChange, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + placeholder = resourceReference(R.string.send_nonce_hint), + showDivider = false, + modifier = Modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) + } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendFeeContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendFeeContent.kt index 728f537a37..c017356770 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendFeeContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendFeeContent.kt @@ -40,6 +40,8 @@ internal fun SendFeeContent(state: FeeUM, clickIntents: SendFeeClickIntents) { customFee( feeSelectorUM = feeSelectorUM, onValueChange = clickIntents::onCustomFeeValueChange, + onNonceChange = clickIntents::onNonceChange, + nonce = state.nonce?.toString().orEmpty(), hasNotifications = hasNotifications, ) } @@ -61,6 +63,8 @@ internal fun LazyListScope.customFee( feeSelectorUM: FeeSelectorUM.Content, hasNotifications: Boolean, onValueChange: (Int, String) -> Unit, + onNonceChange: (String) -> Unit, + nonce: String?, modifier: Modifier = Modifier, ) { item(key = FEE_CUSTOM_KEY) { @@ -69,6 +73,8 @@ internal fun LazyListScope.customFee( selectedFee = feeSelectorUM.selectedType, hasNotifications = hasNotifications, onValueChange = onValueChange, + onNonceChange = onNonceChange, + nonce = nonce, modifier = modifier .fillMaxWidth() .animateItem() diff --git a/features/fee-selector/api/src/main/kotlin/com/tangem/features/feeselector/api/entity/CustomFeeFieldUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/CustomFeeFieldUM.kt similarity index 90% rename from features/fee-selector/api/src/main/kotlin/com/tangem/features/feeselector/api/entity/CustomFeeFieldUM.kt rename to features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/CustomFeeFieldUM.kt index cc823ad6d6..cb3a73dae0 100644 --- a/features/fee-selector/api/src/main/kotlin/com/tangem/features/feeselector/api/entity/CustomFeeFieldUM.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/CustomFeeFieldUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feeselector.api.entity +package com.tangem.features.send.v2.subcomponents.fee.ui.state import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeSelectorUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeSelectorUM.kt index ee4677b452..46bbd7c4d9 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeSelectorUM.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeSelectorUM.kt @@ -5,9 +5,9 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.domain.transaction.error.GetFeeError -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import java.math.BigInteger @Stable internal sealed class FeeSelectorUM { @@ -17,6 +17,7 @@ internal sealed class FeeSelectorUM { val selectedType: FeeType = FeeType.Market, val selectedFee: Fee?, val customValues: ImmutableList = persistentListOf(), + val nonce: BigInteger? = null, ) : FeeSelectorUM() data object Loading : FeeSelectorUM() diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeUM.kt index e310e424af..592570e0b5 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeUM.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeUM.kt @@ -3,10 +3,10 @@ package com.tangem.features.send.v2.subcomponents.fee.ui.state import androidx.compose.runtime.Stable import com.tangem.common.ui.notifications.NotificationUM import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal +import java.math.BigInteger @Stable internal sealed class FeeUM { @@ -23,6 +23,8 @@ internal sealed class FeeUM { val isCustomSelected: Boolean, val isTronToken: Boolean, val customValues: ImmutableList = persistentListOf(), + val displayNonceInput: Boolean = false, + val nonce: BigInteger? = null, val notifications: ImmutableList, val isEditingDisabled: Boolean = false, ) : FeeUM() diff --git a/features/send/impl/build.gradle.kts b/features/send/impl/build.gradle.kts deleted file mode 100644 index f104eedf8b..0000000000 --- a/features/send/impl/build.gradle.kts +++ /dev/null @@ -1,95 +0,0 @@ -plugins { - alias(deps.plugins.android.library) - alias(deps.plugins.kotlin.android) - alias(deps.plugins.kotlin.kapt) - alias(deps.plugins.hilt.android) - id("configuration") -} - -android { - namespace = "com.tangem.features.send.impl" -} - -dependencies { - /** AndroidX */ - implementation(deps.androidx.fragment.ktx) - implementation(deps.androidx.appCompat) - implementation(deps.androidx.paging.runtime) - - /** Other dependencies */ - implementation(deps.kotlin.immutable.collections) - implementation(deps.material) - implementation(deps.arrow.core) - implementation(deps.lifecycle.compose) - implementation(deps.jodatime) - implementation(deps.timber) - implementation(deps.reKotlin) - implementation(deps.kotlin.serialization) - - /** Compose */ - implementation(deps.compose.accompanist.systemUiController) - implementation(deps.compose.material3) - implementation(deps.compose.material) - implementation(deps.compose.foundation) - implementation(deps.compose.ui) - implementation(deps.compose.ui.tooling) - implementation(deps.compose.navigation) - implementation(deps.compose.navigation.hilt) - implementation(deps.compose.paging) - implementation(deps.compose.constraintLayout) - - /** Tangem SDKs */ - implementation(tangemDeps.card.core) - implementation(tangemDeps.blockchain) - - /** Core modules */ - implementation(projects.core.configToggles) - implementation(projects.core.ui) - implementation(projects.core.utils) - implementation(projects.core.navigation) - implementation(projects.core.analytics) - implementation(projects.core.analytics.models) - implementation(projects.core.datasource) - implementation(projects.core.decompose) - - /** Common */ - implementation(projects.common.ui) - implementation(projects.common.routing) - - /** Libs */ - implementation(projects.libs.crypto) - - /** Domain modules */ - implementation(projects.domain.models) - implementation(projects.domain.legacy) - implementation(projects.libs.blockchainSdk) - implementation(projects.domain.tokens) - implementation(projects.domain.tokens.models) - implementation(projects.domain.wallets) - implementation(projects.domain.wallets.models) - implementation(projects.domain.appCurrency) - implementation(projects.domain.appCurrency.models) - 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) - implementation(projects.domain.feedback) - implementation(projects.domain.feedback.models) - implementation(projects.domain.qrScanning) - implementation(projects.domain.qrScanning.models) - implementation(projects.domain.settings) - implementation(projects.domain.notifications) - - /** Feature modules */ - implementation(projects.features.send.api) - implementation(projects.features.tokendetails.api) - implementation(projects.features.txhistory.api) - implementation(projects.features.qrScanning.api) - - /** DI */ - implementation(deps.hilt.android) - kapt(deps.hilt.kapt) -} \ No newline at end of file diff --git a/features/swap-v2/api/.gitignore b/features/swap-v2/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/swap-v2/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/swap-v2/api/build.gradle.kts b/features/swap-v2/api/build.gradle.kts new file mode 100644 index 0000000000..7530654088 --- /dev/null +++ b/features/swap-v2/api/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.swap.v2.api" +} + +dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Domain */ + implementation(projects.domain.wallets.models) + implementation(projects.domain.express.models) + implementation(projects.domain.swap.models) + implementation(projects.domain.manageTokens.models) + implementation(projects.domain.models) + + /** Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/SwapFeatureToggles.kt b/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/SwapFeatureToggles.kt new file mode 100644 index 0000000000..2bc15c06e7 --- /dev/null +++ b/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/SwapFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.features.swap.v2.api + +interface SwapFeatureToggles { + val isSwapRedesignEnabled: Boolean +} \ No newline at end of file diff --git a/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/choosetoken/SwapChooseTokenNetworkComponent.kt b/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/choosetoken/SwapChooseTokenNetworkComponent.kt new file mode 100644 index 0000000000..b283dd585c --- /dev/null +++ b/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/choosetoken/SwapChooseTokenNetworkComponent.kt @@ -0,0 +1,20 @@ +package com.tangem.features.swap.v2.api.choosetoken + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId + +interface SwapChooseTokenNetworkComponent : ComposableBottomSheetComponent { + + data class Params( + val userWalletId: UserWalletId, + val initialCurrency: CryptoCurrency, + val token: ManagedCryptoCurrency.Token, + val onDismiss: () -> Unit, + val onResult: (CryptoCurrency) -> Unit, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/choosetoken/SwapChooseTokenNetworkTrigger.kt b/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/choosetoken/SwapChooseTokenNetworkTrigger.kt new file mode 100644 index 0000000000..5940a10981 --- /dev/null +++ b/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/choosetoken/SwapChooseTokenNetworkTrigger.kt @@ -0,0 +1,11 @@ +package com.tangem.features.swap.v2.api.choosetoken + +import com.tangem.domain.models.currency.CryptoCurrency + +/** + * Triggers swap token selection + */ +interface SwapChooseTokenNetworkTrigger { + + suspend fun trigger(cryptoCurrency: CryptoCurrency) +} \ No newline at end of file diff --git a/features/swap-v2/impl/.gitignore b/features/swap-v2/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/swap-v2/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/swap-v2/impl/build.gradle.kts b/features/swap-v2/impl/build.gradle.kts new file mode 100644 index 0000000000..456c959e50 --- /dev/null +++ b/features/swap-v2/impl/build.gradle.kts @@ -0,0 +1,78 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.swap.v2.impl" + packaging { + resources { + // To build and run composable preview + merges += "paymentrequest.proto" + } + } +} + + +dependencies { + /** Feature */ + implementation(projects.features.swapV2.api) + implementation(projects.features.manageTokens.api) + implementation(projects.features.sendV2.api) + + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + implementation(projects.core.navigation) + implementation(projects.core.configToggles) + + /** Common */ + implementation(projects.common.ui) + implementation(projects.common.routing) + + implementation(tangemDeps.blockchain) { + exclude(module = "joda-time") + } + + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.wallets.models) + implementation(projects.domain.wallets) + implementation(projects.domain.tokens.models) + implementation(projects.domain.tokens) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.appCurrency) + implementation(projects.domain.express.models) + implementation(projects.domain.swap.models) + implementation(projects.domain.swap) + implementation(projects.domain.manageTokens.models) + implementation(projects.domain.manageTokens) + implementation(projects.domain.transaction.models) + implementation(projects.domain.transaction) + + /** Compose */ + implementation(deps.compose.foundation) + implementation(deps.compose.runtime) + implementation(deps.compose.material3) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.androidx.activity.compose) + implementation(deps.compose.constraintLayout) + implementation(deps.compose.coil) + + /** Other */ + implementation(deps.arrow.core) + implementation(deps.decompose) + implementation(deps.decompose.ext.compose) + implementation(deps.timber) + implementation(deps.kotlin.immutable.collections) + implementation(deps.coil) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/DefaultSwapFeatureToggles.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/DefaultSwapFeatureToggles.kt new file mode 100644 index 0000000000..61d092f28a --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/DefaultSwapFeatureToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.features.swap.v2.impl + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.swap.v2.api.SwapFeatureToggles + +internal class DefaultSwapFeatureToggles( + private val featureToggles: FeatureTogglesManager, +) : SwapFeatureToggles { + override val isSwapRedesignEnabled: Boolean + get() = featureToggles.isFeatureEnabled("SWAP_REDESIGN_ENABLED") +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/SwapChooseProviderComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/SwapChooseProviderComponent.kt new file mode 100644 index 0000000000..744a8a3c91 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/SwapChooseProviderComponent.kt @@ -0,0 +1,61 @@ +package com.tangem.features.swap.v2.impl.chooseprovider + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.swap.v2.impl.chooseprovider.model.SwapChooseProviderModel +import com.tangem.features.swap.v2.impl.chooseprovider.ui.SwapChooseProviderBottomSheet +import com.tangem.features.swap.v2.impl.chooseprovider.ui.SwapChooseProviderContent +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import kotlinx.collections.immutable.ImmutableList + +internal class SwapChooseProviderComponent( + context: AppComponentContext, + private val params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by context { + + private val model: SwapChooseProviderModel = getOrCreateModel(params = params) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state = model.uiState.collectAsStateWithLifecycle() + + val bottomSheetConfig = remember(key1 = this) { + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ) + } + + SwapChooseProviderBottomSheet(config = bottomSheetConfig) { + SwapChooseProviderContent( + providerList = state.value.providerList, + onProviderClick = model::onProviderClick, + ) + } + } + + data class Params( + val cryptoCurrency: CryptoCurrency, + val selectedProvider: ExpressProvider, + val providers: ImmutableList, + val callback: ModelCallback, + val onDismiss: () -> Unit, + ) + + interface ModelCallback { + fun onProviderResult(quoteUM: SwapQuoteUM) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/di/SwapChooseProviderModule.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/di/SwapChooseProviderModule.kt new file mode 100644 index 0000000000..db602af2e5 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/di/SwapChooseProviderModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.swap.v2.impl.chooseprovider.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.swap.v2.impl.chooseprovider.model.SwapChooseProviderModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@InstallIn(ModelComponent::class) +@Module +internal interface SwapChooseProviderModule { + + @Binds + @IntoMap + @ClassKey(SwapChooseProviderModel::class) + fun provideSwapChooseProviderModel(impl: SwapChooseProviderModel): Model +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/entity/SwapChooseProviderBottomSheetContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/entity/SwapChooseProviderBottomSheetContent.kt new file mode 100644 index 0000000000..ad5eb47e18 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/entity/SwapChooseProviderBottomSheetContent.kt @@ -0,0 +1,16 @@ +package com.tangem.features.swap.v2.impl.chooseprovider.entity + +import com.tangem.core.ui.components.provider.entity.ProviderChooseUM +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import kotlinx.collections.immutable.ImmutableList + +internal data class SwapChooseProviderBottomSheetContent( + val providerList: ImmutableList, + val selectedProvider: ExpressProvider, +) + +internal data class SwapProviderListItem( + val providerUM: ProviderChooseUM, + val quote: SwapQuoteUM, +) \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/SwapChooseProviderModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/SwapChooseProviderModel.kt new file mode 100644 index 0000000000..a4a4613f7f --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/SwapChooseProviderModel.kt @@ -0,0 +1,46 @@ +package com.tangem.features.swap.v2.impl.chooseprovider.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.swap.v2.impl.chooseprovider.SwapChooseProviderComponent +import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapChooseProviderBottomSheetContent +import com.tangem.features.swap.v2.impl.chooseprovider.model.converter.SwapProviderListItemConverter +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ModelScoped +internal class SwapChooseProviderModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params: SwapChooseProviderComponent.Params = paramsContainer.require() + + val uiState: StateFlow + field: MutableStateFlow = MutableStateFlow(getInitialState()) + + private val swapProviderListItemConverter by lazy(LazyThreadSafetyMode.NONE) { + SwapProviderListItemConverter( + cryptoCurrency = params.cryptoCurrency, + selectedProvider = params.selectedProvider, + ) + } + + fun onProviderClick(quoteUM: SwapQuoteUM) { + params.callback.onProviderResult(quoteUM) + } + + private fun getInitialState(): SwapChooseProviderBottomSheetContent { + return SwapChooseProviderBottomSheetContent( + providerList = swapProviderListItemConverter.convertList(params.providers) + .filterNotNull() + .toPersistentList(), + selectedProvider = params.selectedProvider, + ) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverter.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverter.kt new file mode 100644 index 0000000000..75234f816b --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverter.kt @@ -0,0 +1,85 @@ +package com.tangem.features.swap.v2.impl.chooseprovider.model.converter + +import com.tangem.core.ui.components.audits.AuditLabelUM +import com.tangem.core.ui.components.provider.entity.ProviderChooseUM +import com.tangem.core.ui.components.provider.entity.ProviderChooseUM.LabelUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.swap.v2.impl.R +import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapProviderListItem +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import com.tangem.utils.converter.Converter + +internal class SwapProviderListItemConverter( + private val cryptoCurrency: CryptoCurrency, + private val selectedProvider: ExpressProvider, +) : Converter { + override fun convert(value: SwapQuoteUM): SwapProviderListItem? { + val provider = value.provider ?: return null + + return SwapProviderListItem( + providerUM = ProviderChooseUM( + title = stringReference(provider.name), + subtitle = stringReference(provider.type.typeName), + infoText = when (value) { + is SwapQuoteUM.Content -> value.quoteAmountValue + else -> TextReference.EMPTY + }, + iconUrl = provider.imageLarge, + isSelected = provider.providerId == selectedProvider.providerId, + extraUM = getExtras(value = value), + labelUM = getLabel(value = value), + ), + quote = value, + ) + } + + private fun getExtras(value: SwapQuoteUM) = when (value) { + is SwapQuoteUM.Allowance -> ProviderChooseUM.ExtraUM.Action( + text = resourceReference(R.string.express_provider_permission_needed), + ) + is SwapQuoteUM.Error -> { + ProviderChooseUM.ExtraUM.Error( + when (val error = value.expressError) { + is ExpressError.AmountError.TooSmallError -> resourceReference( + id = R.string.express_provider_min_amount, + formatArgs = wrappedList( + error.amount.format { crypto(cryptoCurrency) }, + ), + ) + is ExpressError.AmountError.NotEnoughAllowanceError, + is ExpressError.AmountError.TooBigError, + -> resourceReference( + id = R.string.express_provider_max_amount, + formatArgs = wrappedList( + error.amount.format { crypto(cryptoCurrency) }, + ), + ) + else -> TextReference.EMPTY + }, + ) + } + SwapQuoteUM.Empty, + SwapQuoteUM.Loading, + is SwapQuoteUM.Content, + -> ProviderChooseUM.ExtraUM.Empty + } + + private fun getLabel(value: SwapQuoteUM) = when (val diff = (value as? SwapQuoteUM.Content)?.diffPercent) { + SwapQuoteUM.Content.DifferencePercent.Best -> LabelUM.Info( + AuditLabelUM( + text = resourceReference(R.string.express_provider_best_rate), + type = AuditLabelUM.Type.Info, + ), + ) + is SwapQuoteUM.Content.DifferencePercent.Diff -> LabelUM.Text(diff.percent) + else -> null + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/ui/SwapChooseProviderBottomSheet.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/ui/SwapChooseProviderBottomSheet.kt new file mode 100644 index 0000000000..1b2b8d2e04 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/ui/SwapChooseProviderBottomSheet.kt @@ -0,0 +1,124 @@ +package com.tangem.features.swap.v2.impl.chooseprovider.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.components.provider.ProviderChooseCrypto +import com.tangem.core.ui.extensions.conditional +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.swap.v2.impl.R +import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapChooseProviderBottomSheetContent +import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapProviderListItem +import com.tangem.features.swap.v2.impl.chooseprovider.ui.preview.SwapChooseProviderContentPreview +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun SwapChooseProviderBottomSheet(config: TangemBottomSheetConfig, content: @Composable () -> Unit) { + TangemModalBottomSheet( + config = config, + title = { + TangemModalBottomSheetTitle( + title = resourceReference(R.string.express_choose_provider), + endIconRes = R.drawable.ic_close_24, + onEndClick = config.onDismissRequest, + ) + }, + ) { + content() + } +} + +@Composable +internal fun SwapChooseProviderContent( + providerList: ImmutableList, + onProviderClick: (SwapQuoteUM) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 13.dp), + ) { + providerList.fastForEachIndexed { index, provider -> + ProviderChooseCrypto( + providerChooseUM = provider.providerUM, + onClick = { onProviderClick(provider.quote) }, + modifier = modifier + .conditional(index == 0) { padding(top = 6.dp) }, + ) + } + Icon( + painter = painterResource(id = R.drawable.ic_lightning_16), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier.padding( + top = 16.dp, + start = 3.dp, + end = 3.dp, + ), + ) + Text( + text = stringResourceSafe(R.string.express_more_providers_soon), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.icon.informative, + modifier = Modifier + .padding( + top = 4.dp, + bottom = 24.dp, + start = 3.dp, + end = 3.dp, + ), + textAlign = TextAlign.Center, + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun SwapChooseProviderContent_Preview( + @PreviewParameter(ProviderChooseCryptoPreviewProvider::class) params: SwapChooseProviderBottomSheetContent, +) { + TangemThemePreview { + SwapChooseProviderBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + ) { + SwapChooseProviderContent( + providerList = params.providerList, + onProviderClick = {}, + ) + } + } +} + +private class ProviderChooseCryptoPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SwapChooseProviderContentPreview.state, + ) +} +// endregion \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/ui/SwapChooseProviderContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/ui/SwapChooseProviderContent.kt new file mode 100644 index 0000000000..43c2d3c8f0 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/ui/SwapChooseProviderContent.kt @@ -0,0 +1,128 @@ +package com.tangem.features.swap.v2.impl.chooseprovider.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +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 +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.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.express.models.ExpressRateType +import com.tangem.features.swap.v2.impl.R + +@Composable +fun SwapChooseProviderContent(expressProvider: ExpressProvider?, onClick: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = onClick, + ), + ) { + HorizontalDivider( + thickness = 0.5.dp, + color = TangemTheme.colors.stroke.primary, + modifier = Modifier.padding(horizontal = 12.dp), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(12.dp), + ) { + Icon( + painter = rememberVectorPainter( + ImageVector.vectorResource(R.drawable.ic_exchange_horizontal_24), + ), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + Text( + text = stringResourceSafe(R.string.express_provider), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(start = 8.dp), + ) + SpacerWMax() + SubcomposeAsyncImage( + modifier = modifier + .size(20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(TangemColorPalette.Light1), + model = ImageRequest.Builder(context = LocalContext.current) + .data(expressProvider?.imageLarge) + .crossfade(enable = true) + .allowHardware(false) + .build(), + contentDescription = null, + ) + Text( + text = expressProvider?.name.orEmpty(), // todo provider error + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(start = 6.dp), + ) + Icon( + painter = rememberVectorPainter( + ImageVector.vectorResource(R.drawable.ic_chevron_24), + ), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + modifier = Modifier.padding(start = 4.dp), + ) + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun SwapChooseProviderContent_Preview() { + TangemThemePreview { + Box( + modifier = Modifier.background(TangemTheme.colors.background.tertiary), + ) { + SwapChooseProviderContent( + expressProvider = ExpressProvider( + providerId = "changelly", + rateTypes = listOf(ExpressRateType.Fixed), + name = "Changelly", + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = "", + privacyPolicy = "", + isRecommended = false, + slippage = null, + ), + onClick = {}, + ) + } + } +} +// endregion \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/ui/preview/SwapChooseProviderContentPreview.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/ui/preview/SwapChooseProviderContentPreview.kt new file mode 100644 index 0000000000..6558e68f96 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/ui/preview/SwapChooseProviderContentPreview.kt @@ -0,0 +1,90 @@ +package com.tangem.features.swap.v2.impl.chooseprovider.ui.preview + +import com.tangem.core.ui.components.audits.AuditLabelUM +import com.tangem.core.ui.components.provider.entity.ProviderChooseUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.express.models.ExpressRateType +import com.tangem.features.swap.v2.impl.R +import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapChooseProviderBottomSheetContent +import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapProviderListItem +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +internal object SwapChooseProviderContentPreview { + + private val provider1 = ExpressProvider( + providerId = "changenow", + rateTypes = listOf(ExpressRateType.Float), + name = "ChangeNow", + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = "", + privacyPolicy = "", + isRecommended = true, + slippage = BigDecimal.ZERO, + ) + private val provider2 = provider1.copy( + providerId = "changelly", + name = "Changelly", + ) + + private val quote1 = SwapQuoteUM.Content( + provider = provider1, + quoteAmount = "123".toBigDecimal(), + quoteAmountValue = stringReference("123"), + diffPercent = SwapQuoteUM.Content.DifferencePercent.Best, + ) + + private val quote2 = SwapQuoteUM.Content( + provider = provider2, + quoteAmount = "13.12".toBigDecimal(), + quoteAmountValue = stringReference("13.12"), + diffPercent = SwapQuoteUM.Content.DifferencePercent.Empty, + ) + + val state = SwapChooseProviderBottomSheetContent( + providerList = persistentListOf( + SwapProviderListItem( + providerUM = ProviderChooseUM( + title = stringReference(provider1.name), + subtitle = stringReference(provider1.type.typeName), + infoText = stringReference("1800 POL"), + iconUrl = "", + isSelected = true, + extraUM = ProviderChooseUM.ExtraUM.Empty, + labelUM = ProviderChooseUM.LabelUM.Info( + AuditLabelUM( + text = resourceReference(R.string.express_provider_best_rate), + type = AuditLabelUM.Type.Info, + ), + ), + ), + quote = quote1, + ), + SwapProviderListItem( + providerUM = ProviderChooseUM( + title = stringReference(provider2.name), + subtitle = stringReference(provider2.type.typeName), + infoText = stringReference("1800 POL"), + iconUrl = "", + isSelected = false, + extraUM = ProviderChooseUM.ExtraUM.Action( + text = resourceReference(R.string.express_provider_permission_needed), + ), + labelUM = ProviderChooseUM.LabelUM.Info( + AuditLabelUM( + text = resourceReference(R.string.express_provider_best_rate), + type = AuditLabelUM.Type.Info, + ), + ), + ), + quote = quote2, + ), + ), + selectedProvider = provider1, + ) +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/di/SwapChooseTokenModule.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/di/SwapChooseTokenModule.kt new file mode 100644 index 0000000000..187db962f7 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/di/SwapChooseTokenModule.kt @@ -0,0 +1,46 @@ +package com.tangem.features.swap.v2.impl.choosetoken.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkComponent +import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkTrigger +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.DefaultSwapChooseTokenNetworkComponent +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.DefaultSwapChooseTokenNetworkTrigger +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.SwapChooseTokenNetworkListener +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.SwapChooseTokenNetworkModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +@InstallIn(ModelComponent::class) +@Module +internal interface SwapChooseTokenModule { + + @Binds + @IntoMap + @ClassKey(SwapChooseTokenNetworkModel::class) + fun provideSwapChooseTokenNetworkModel(impl: SwapChooseTokenNetworkModel): Model +} + +@Module +@InstallIn(SingletonComponent::class) +internal interface SwapChooseTokenModuleBinds { + + @Binds + @Singleton + fun provideSwapChooseTokenNetworksBottomSheetComponent( + impl: DefaultSwapChooseTokenNetworkComponent.Factory, + ): SwapChooseTokenNetworkComponent.Factory + + @Binds + @Singleton + fun bindSwapChooseTokenNetworkTrigger(impl: DefaultSwapChooseTokenNetworkTrigger): SwapChooseTokenNetworkTrigger + + @Binds + @Singleton + fun bindSwapChooseTokenNetworkListener(impl: DefaultSwapChooseTokenNetworkTrigger): SwapChooseTokenNetworkListener +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/DefaultSwapChooseTokenNetworkComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/DefaultSwapChooseTokenNetworkComponent.kt new file mode 100644 index 0000000000..b073b75725 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/DefaultSwapChooseTokenNetworkComponent.kt @@ -0,0 +1,42 @@ +package com.tangem.features.swap.v2.impl.choosetoken.fromSupported + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkComponent +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.SwapChooseTokenNetworkModel +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.ui.SwapChooseTokenNetworkBottomSheet +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultSwapChooseTokenNetworkComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: SwapChooseTokenNetworkComponent.Params, +) : SwapChooseTokenNetworkComponent, AppComponentContext by context { + + private val model: SwapChooseTokenNetworkModel = getOrCreateModel(params) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.uiState.collectAsStateWithLifecycle() + + SwapChooseTokenNetworkBottomSheet( + config = state.bottomSheetConfig, + ) + } + + @AssistedFactory + interface Factory : SwapChooseTokenNetworkComponent.Factory { + override fun create( + context: AppComponentContext, + params: SwapChooseTokenNetworkComponent.Params, + ): DefaultSwapChooseTokenNetworkComponent + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/DefaultSwapChooseTokenNetworkTrigger.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/DefaultSwapChooseTokenNetworkTrigger.kt new file mode 100644 index 0000000000..887637781f --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/DefaultSwapChooseTokenNetworkTrigger.kt @@ -0,0 +1,28 @@ +package com.tangem.features.swap.v2.impl.choosetoken.fromSupported + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkTrigger +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Listens to swap token selection + */ +interface SwapChooseTokenNetworkListener { + val swapChooseTokenNetworkResultFlow: SharedFlow +} + +@Singleton +internal class DefaultSwapChooseTokenNetworkTrigger @Inject constructor() : + SwapChooseTokenNetworkTrigger, + SwapChooseTokenNetworkListener { + + override val swapChooseTokenNetworkResultFlow: SharedFlow + field = MutableSharedFlow() + + override suspend fun trigger(result: CryptoCurrency) { + swapChooseTokenNetworkResultFlow.emit(result) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/entity/SwapChooseTokenNetworkUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/entity/SwapChooseTokenNetworkUM.kt new file mode 100644 index 0000000000..202ba8410f --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/entity/SwapChooseTokenNetworkUM.kt @@ -0,0 +1,40 @@ +package com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +internal data class SwapChooseTokenNetworkUM( + val bottomSheetConfig: TangemBottomSheetConfig, +) + +@Immutable +internal sealed class SwapChooseTokenNetworkContentUM : TangemBottomSheetConfigContent { + + abstract val messageContent: MessageBottomSheetUMV2 + + data class Loading( + override val messageContent: MessageBottomSheetUMV2, + ) : SwapChooseTokenNetworkContentUM() + + data class Error( + override val messageContent: MessageBottomSheetUMV2, + ) : SwapChooseTokenNetworkContentUM() + + data class Content( + override val messageContent: MessageBottomSheetUMV2, + val swapNetworks: ImmutableList, + ) : SwapChooseTokenNetworkContentUM() +} + +internal data class SwapChooseNetworkUM( + val title: TextReference, + val subtitle: TextReference, + @DrawableRes val iconResId: Int, + val hasFixedRate: Boolean, + val onNetworkClick: () -> Unit, +) \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenFactory.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenFactory.kt new file mode 100644 index 0000000000..b7e4427eb3 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenFactory.kt @@ -0,0 +1,26 @@ +package com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model + +import com.tangem.core.ui.components.bottomsheets.message.* +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.features.swap.v2.impl.R + +internal object SwapChooseTokenFactory { + + fun getErrorMessage(tokenName: String, onDismiss: () -> Unit): MessageBottomSheetUMV2 { + return messageBottomSheetUM { + infoBlock { + icon(R.drawable.img_attention_20) { + type = MessageBottomSheetUMV2.Icon.Type.Attention + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + } + title = resourceReference(R.string.express_swap_not_supported_title, wrappedList(tokenName)) + body = resourceReference(R.string.express_swap_not_supported_text) + } + secondaryButton { + text = resourceReference(R.string.warning_button_ok) + onClick { onDismiss() } + } + } + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt new file mode 100644 index 0000000000..40bd268b58 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt @@ -0,0 +1,118 @@ +package com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model + +import arrow.core.getOrElse +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase +import com.tangem.domain.swap.usecase.GetSwapSupportedPairsUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkComponent +import com.tangem.features.swap.v2.impl.R +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkContentUM +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkUM +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.SwapChooseTokenFactory.getErrorMessage +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.transformers.SwapChooseContentStateTransformer +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.transformers.SwapChooseErrorStateTransformer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.transformer.update +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@ModelScoped +internal class SwapChooseTokenNetworkModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, + private val getSwapSupportedPairsUseCase: GetSwapSupportedPairsUseCase, + private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val uiMessageSender: UiMessageSender, +) : Model() { + + private val params: SwapChooseTokenNetworkComponent.Params = paramsContainer.require() + + val uiState: StateFlow + field: MutableStateFlow = MutableStateFlow( + SwapChooseTokenNetworkUM( + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = params.onDismiss, + content = SwapChooseTokenNetworkContentUM.Loading( + messageContent = getErrorMessage( + tokenName = params.token.name, + onDismiss = params.onDismiss, + ), + ), + ), + ), + ) + + init { + initContent() + } + + private fun initContent() { + val userWallet = getUserWalletUseCase(params.userWalletId).getOrElse { + Timber.e("Failed to get user wallet: $it") + getGenericErrorState() + return + } + + modelScope.launch { + val cryptoCurrencyList = createCryptoCurrencyUseCase( + token = params.token, + userWalletId = params.userWalletId, + ).getOrElse { + Timber.e("Failed to get crypto currency") + getGenericErrorState() + return@launch + } + val pairs = getSwapSupportedPairsUseCase.invoke( + userWallet = userWallet, + initialCurrency = params.initialCurrency, + cryptoCurrencyList = cryptoCurrencyList + params.initialCurrency, + ).getOrElse { + Timber.e(it.toString()) + uiState.update( + SwapChooseErrorStateTransformer( + tokenName = params.token.name, + onDismiss = params.onDismiss, + ), + ) + return@launch + } + delay(MINIMUM_LOADING_TIME) + uiState.update( + SwapChooseContentStateTransformer( + pairs = pairs, + onNetworkClick = params.onResult, + tokenName = params.token.name, + onDismiss = params.onDismiss, + ), + ) + } + } + + private fun getGenericErrorState() { + uiMessageSender.send( + DialogMessage( + title = resourceReference(id = R.string.common_error), + message = resourceReference(id = R.string.common_unknown_error), + onDismissRequest = params.onDismiss, + firstActionBuilder = { okAction() }, + ), + ) + } + + private companion object { + const val MINIMUM_LOADING_TIME = 100L + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt new file mode 100644 index 0000000000..f876199b86 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt @@ -0,0 +1,54 @@ +package com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.transformers + +import com.tangem.core.ui.extensions.iconResId +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapCurrencies +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseNetworkUM +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkContentUM +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkUM +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.SwapChooseTokenFactory.getErrorMessage +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toPersistentList + +internal class SwapChooseContentStateTransformer( + private val pairs: SwapCurrencies, + private val tokenName: String, + private val onNetworkClick: (CryptoCurrency) -> Unit, + private val onDismiss: () -> Unit, +) : Transformer { + override fun transform(prevState: SwapChooseTokenNetworkUM): SwapChooseTokenNetworkUM { + val swapNetworks = pairs.fromGroup.available.map { + val network = it.currencyStatus.currency.network + SwapChooseNetworkUM( + title = stringReference(network.name), + subtitle = stringReference(network.standardType.name), + iconResId = network.iconResId, + hasFixedRate = it.providers.any { it.rateTypes.any { it == ExpressRateType.Fixed } }, + onNetworkClick = { onNetworkClick(it.currencyStatus.currency) }, + ) + }.toPersistentList() + + return prevState.copy( + bottomSheetConfig = prevState.bottomSheetConfig.copy( + content = if (swapNetworks.isNotEmpty()) { + SwapChooseTokenNetworkContentUM.Content( + swapNetworks = swapNetworks, + messageContent = getErrorMessage( + tokenName = tokenName, + onDismiss = onDismiss, + ), + ) + } else { + SwapChooseTokenNetworkContentUM.Error( + messageContent = getErrorMessage( + tokenName = tokenName, + onDismiss = onDismiss, + ), + ) + }, + ), + ) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseErrorStateTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseErrorStateTransformer.kt new file mode 100644 index 0000000000..41ea5a2ea3 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseErrorStateTransformer.kt @@ -0,0 +1,24 @@ +package com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.transformers + +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkContentUM +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkUM +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.SwapChooseTokenFactory.getErrorMessage +import com.tangem.utils.transformer.Transformer + +internal class SwapChooseErrorStateTransformer( + private val tokenName: String, + private val onDismiss: () -> Unit, +) : Transformer { + override fun transform(prevState: SwapChooseTokenNetworkUM): SwapChooseTokenNetworkUM { + return prevState.copy( + bottomSheetConfig = prevState.bottomSheetConfig.copy( + content = SwapChooseTokenNetworkContentUM.Error( + messageContent = getErrorMessage( + tokenName = tokenName, + onDismiss = onDismiss, + ), + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/ui/SwapChooseTokenNetworkContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/ui/SwapChooseTokenNetworkContent.kt new file mode 100644 index 0000000000..aa9e2fc590 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/ui/SwapChooseTokenNetworkContent.kt @@ -0,0 +1,301 @@ +package com.tangem.features.swap.v2.impl.choosetoken.fromSupported.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +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.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +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.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.message.* +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.swap.v2.impl.R +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseNetworkUM +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.entity.SwapChooseTokenNetworkContentUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +@Composable +internal fun SwapChooseTokenNetworkBottomSheet(config: TangemBottomSheetConfig) { + TangemModalBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + title = { + AnimatedContent( + targetState = config.content is SwapChooseTokenNetworkContentUM.Content, + ) { isContent -> + TangemModalBottomSheetTitle( + title = resourceReference(R.string.common_choose_network).takeIf { isContent }, + endIconRes = R.drawable.ic_close_24, + onEndClick = config.onDismissRequest, + ) + } + }, + content = { contentConfig -> + SwapChooseTokenNetworkContent(contentConfig) + }, + ) +} + +@Composable +internal fun SwapChooseTokenNetworkContent(state: SwapChooseTokenNetworkContentUM) { + AnimatedContent( + targetState = state, + transitionSpec = { fadeIn().togetherWith(fadeOut()) }, + ) { targetState -> + when (targetState) { + is SwapChooseTokenNetworkContentUM.Content -> SwapChooseTokenNetworkContentList(targetState.swapNetworks) + else -> { + Box { + MessageBottomSheetV2Content( + state = targetState.messageContent, + modifier = Modifier + .conditional(targetState is SwapChooseTokenNetworkContentUM.Loading) { + alpha(0f) + }, + ) + if (targetState is SwapChooseTokenNetworkContentUM.Loading) { + CircularProgressIndicator( + color = TangemTheme.colors.icon.inactive, + modifier = Modifier + .align(Alignment.Center), + ) + } + } + } + } + } +} + +@Composable +private fun SwapChooseTokenNetworkContentList(swapNetworks: ImmutableList) { + Column { + swapNetworks.fastForEach { network -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = { network.onNetworkClick() }, + ) + .padding( + vertical = 12.dp, + horizontal = 14.dp, + ), + ) { + Image( + modifier = Modifier.size(36.dp), + painter = painterResource(id = network.iconResId), + contentDescription = null, + ) + Text( + text = network.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = network.subtitle.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + if (network.hasFixedRate) { + SpacerWMax() + Text( + text = stringResourceSafe(R.string.swap_fixed_rate), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .border( + width = 1.dp, + color = TangemTheme.colors.stroke.primary, + shape = RoundedCornerShape(6.dp), + ) + .padding( + vertical = 2.dp, + horizontal = 4.dp, + ), + ) + } + } + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun SwapChooseTokenNetworkContent_Preview( + @PreviewParameter(PreviewProvider::class) params: SwapChooseTokenNetworkContentUM, +) { + TangemThemePreview { + SwapChooseTokenNetworkContent(state = params) + } +} + +private class PreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SwapChooseTokenNetworkContentUM.Loading( + messageContent = messageBottomSheetUM { + infoBlock { + icon(R.drawable.ic_alert_triangle_20) { + type = MessageBottomSheetUMV2.Icon.Type.Attention + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Unspecified + } + title = stringReference("Shiba Inu is not suppoerted") + body = stringReference("This token is not supported. Please choose a different token to swap.") + } + secondaryButton { + text = resourceReference(R.string.warning_button_ok) + onClick { closeBs() } + } + }, + ), + SwapChooseTokenNetworkContentUM.Error( + messageContent = messageBottomSheetUM { + infoBlock { + icon(R.drawable.ic_alert_triangle_20) { + type = MessageBottomSheetUMV2.Icon.Type.Attention + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + } + title = stringReference("Shiba Inu is not supported") + body = stringReference("This token is not supported. Please choose a different token to swap.") + } + secondaryButton { + text = resourceReference(R.string.warning_button_ok) + onClick { closeBs() } + } + }, + ), + SwapChooseTokenNetworkContentUM.Content( + messageContent = messageBottomSheetUM { + infoBlock { + icon(R.drawable.img_attention_20) { + type = MessageBottomSheetUMV2.Icon.Type.Attention + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + } + title = stringReference("Shiba Inu is not suppoerted") + body = stringReference("This token is not supported. Please choose a different token to swap.") + } + secondaryButton { + text = resourceReference(R.string.warning_button_ok) + onClick { closeBs() } + } + }, + swapNetworks = persistentListOf( + SwapChooseNetworkUM( + title = stringReference("Cardano"), + subtitle = stringReference("MAIN"), + iconResId = R.drawable.img_cardano_22, + hasFixedRate = false, + onNetworkClick = {}, + ), + ), + ), + SwapChooseTokenNetworkContentUM.Content( + messageContent = messageBottomSheetUM { + infoBlock { + icon(R.drawable.img_attention_20) { + type = MessageBottomSheetUMV2.Icon.Type.Attention + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + } + title = stringReference("Shiba Inu is not suppoerted") + body = stringReference("This token is not supported. Please choose a different token to swap.") + } + secondaryButton { + text = resourceReference(R.string.warning_button_ok) + onClick { closeBs() } + } + }, + swapNetworks = persistentListOf( + SwapChooseNetworkUM( + title = stringReference("Cardano"), + subtitle = stringReference("MAIN"), + iconResId = R.drawable.img_cardano_22, + hasFixedRate = false, + onNetworkClick = {}, + ), + SwapChooseNetworkUM( + title = stringReference("BNB Smart Chain"), + subtitle = stringReference("BEP20"), + iconResId = R.drawable.img_bsc_22, + hasFixedRate = false, + onNetworkClick = {}, + ), + SwapChooseNetworkUM( + title = stringReference("BNB Smart Chain"), + subtitle = stringReference("BEP20"), + iconResId = R.drawable.img_bsc_22, + hasFixedRate = true, + onNetworkClick = {}, + ), + SwapChooseNetworkUM( + title = stringReference("BNB Smart Chain"), + subtitle = stringReference("BEP20"), + iconResId = R.drawable.img_bsc_22, + hasFixedRate = false, + onNetworkClick = {}, + ), + ), + ), + SwapChooseTokenNetworkContentUM.Content( + messageContent = messageBottomSheetUM { + infoBlock { + icon(R.drawable.img_attention_20) { + type = MessageBottomSheetUMV2.Icon.Type.Attention + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + } + title = stringReference("Shiba Inu is not suppoerted") + body = stringReference("This token is not supported. Please choose a different token to swap.") + } + secondaryButton { + text = resourceReference(R.string.warning_button_ok) + onClick { closeBs() } + } + }, + swapNetworks = buildList { + repeat(10) { + add( + SwapChooseNetworkUM( + title = stringReference("Cardano"), + subtitle = stringReference("MAIN"), + iconResId = R.drawable.img_cardano_22, + hasFixedRate = false, + onNetworkClick = {}, + ), + ) + } + }.toPersistentList(), + ), + ) +} +// endregion \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/entity/SwapQuoteUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/entity/SwapQuoteUM.kt new file mode 100644 index 0000000000..0b4abfd3c3 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/entity/SwapQuoteUM.kt @@ -0,0 +1,46 @@ +package com.tangem.features.swap.v2.impl.common.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ExpressProvider +import java.math.BigDecimal + +@Immutable +internal sealed class SwapQuoteUM { + + abstract val provider: ExpressProvider? + + data object Empty : SwapQuoteUM() { + override val provider = null + } + + data object Loading : SwapQuoteUM() { + override val provider = null + } + + data class Error( + override val provider: ExpressProvider, + val expressError: ExpressError, + ) : SwapQuoteUM() + + data class Allowance( + override val provider: ExpressProvider, + val allowanceContract: String, + ) : SwapQuoteUM() + + data class Content( + override val provider: ExpressProvider, + val quoteAmount: BigDecimal, + val quoteAmountValue: TextReference, + val diffPercent: DifferencePercent, + ) : SwapQuoteUM() { + sealed class DifferencePercent { + data object Empty : DifferencePercent() + data object Best : DifferencePercent() + data class Diff( + val percent: TextReference, + ) : DifferencePercent() + } + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/di/SwapFeatureModules.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/di/SwapFeatureModules.kt new file mode 100644 index 0000000000..3318c6d6c3 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/di/SwapFeatureModules.kt @@ -0,0 +1,21 @@ +package com.tangem.features.swap.v2.impl.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.swap.v2.api.SwapFeatureToggles +import com.tangem.features.swap.v2.impl.DefaultSwapFeatureToggles +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 SwapFeatureModules { + + @Provides + @Singleton + fun provideSwapFeatureToggles(featureTogglesManager: FeatureTogglesManager): SwapFeatureToggles { + return DefaultSwapFeatureToggles(featureTogglesManager) + } +} \ No newline at end of file diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 7d52289e23..256c69cbac 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } android { - namespace = "com.tangem.domain.swap" + namespace = "com.tangem.features.domain.swap" } dependencies { 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 410639462f..cc21bc6469 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 @@ -19,13 +19,13 @@ import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.domain.quotes.QuotesRepositoryV2 +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.FetchCurrencyStatusUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.FeePaidCurrency -import com.tangem.domain.tokens.model.Quote import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository @@ -65,7 +65,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase, private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, - private val quotesRepositoryV2: QuotesRepositoryV2, + private val quotesRepository: QuotesRepository, private val swapTransactionRepository: SwapTransactionRepository, private val currencyChecksRepository: CurrencyChecksRepository, private val appCurrencyRepository: AppCurrencyRepository, @@ -1034,7 +1034,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( val rates = getQuotes(fromToken.currency.id) val fromTokenSwapInfo = TokenSwapInfo( tokenAmount = amount, - amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value) + amountFiat = rates[fromToken.currency.id]?.multiply(amount.value) ?: BigDecimal.ZERO, cryptoCurrencyStatus = fromToken, ) @@ -1130,7 +1130,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( else -> getNativeToken(networkId = fromToken.network.backendId).id } val rates = getQuotes(feeCurrencyId) - return rates[feeCurrencyId]?.fiatRate?.let { rate -> + return rates[feeCurrencyId]?.let { rate -> fees.map { fee -> rate.multiply(fee).format { fiat( @@ -1233,7 +1233,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( val rates = getQuotes(fromToken.currency.id) val fromTokenSwapInfo = TokenSwapInfo( tokenAmount = amount, - amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value) + amountFiat = rates[fromToken.currency.id]?.multiply(amount.value) ?: BigDecimal.ZERO, cryptoCurrencyStatus = fromToken, ) @@ -1307,20 +1307,20 @@ internal class SwapInteractorImpl @AssistedInject constructor( fromTokenInfo = TokenSwapInfo( tokenAmount = fromTokenAmount, cryptoCurrencyStatus = fromTokenStatus, - amountFiat = rates[fromToken.id]?.fiatRate?.multiply(fromTokenAmount.value) + amountFiat = rates[fromToken.id]?.multiply(fromTokenAmount.value) ?: BigDecimal.ZERO, ), toTokenInfo = TokenSwapInfo( tokenAmount = toTokenAmount, cryptoCurrencyStatus = toTokenStatus, - amountFiat = rates[toToken.id]?.fiatRate?.multiply(toTokenAmount.value) + amountFiat = rates[toToken.id]?.multiply(toTokenAmount.value) ?: BigDecimal.ZERO, ), priceImpact = calculatePriceImpact( fromTokenAmount = fromTokenAmount.value, - fromRate = rates[fromToken.id]?.fiatRate?.toDouble() ?: 0.0, + fromRate = rates[fromToken.id]?.toDouble() ?: 0.0, toTokenAmount = toTokenAmount.value, - toRate = rates[toToken.id]?.fiatRate?.toDouble() ?: 0.0, + toRate = rates[toToken.id]?.toDouble() ?: 0.0, ), swapDataModel = swapData, txFee = txFeeState, @@ -1795,19 +1795,21 @@ internal class SwapInteractorImpl @AssistedInject constructor( return PriceImpact.Value(value) } - private suspend fun getQuotes(vararg ids: CryptoCurrency.ID): Map { + private suspend fun getQuotes(vararg ids: CryptoCurrency.ID): Map { val set = ids.mapNotNull { it.rawCurrencyId } .toSet() .getQuotesOrEmpty() - .filterIsInstance() return ids - .mapNotNull { id -> set.find { it.rawCurrencyId == id.rawCurrencyId }?.let { id to it } } + .mapNotNull { id -> + set.find { it.rawCurrencyId == id.rawCurrencyId && it.value is QuoteStatus.Data } + ?.let { id to (it.value as QuoteStatus.Data).fiatRate } + } .toMap() } - private suspend fun Set.getQuotesOrEmpty(): Set { - return runCatching { quotesRepositoryV2.getMultiQuoteSyncOrNull(currenciesIds = this) } + private suspend fun Set.getQuotesOrEmpty(): Set { + return runCatching { quotesRepository.getMultiQuoteSyncOrNull(currenciesIds = this) } .getOrNull() .orEmpty() } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index 6da65023c5..03ebfc66ca 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -60,7 +60,7 @@ fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) }, topBar = { ExpandableSearchView( - title = stringResourceSafe(R.string.swapping_token_list_title), + title = stringResourceSafe(R.string.common_choose_token), onBackClick = onBack, placeholderSearchText = stringResourceSafe(id = R.string.common_search_tokens), onSearchChange = state.onSearchEntered, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/testpush/ui/TestPushDeepLinkBottomSheet.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/testpush/ui/TestPushDeepLinkBottomSheet.kt index 4a99b46f96..7ca799438d 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/testpush/ui/TestPushDeepLinkBottomSheet.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/testpush/ui/TestPushDeepLinkBottomSheet.kt @@ -2,8 +2,7 @@ package com.tangem.feature.tester.presentation.testpush.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Text @@ -29,8 +28,12 @@ internal fun TestPushDeeplinkBottomSheet(config: TangemBottomSheetConfig?) { @Composable private fun TestPushMenuDeepLinkBottomSheetContent(config: TangemBottomSheetConfig) { - TangemBottomSheet(config) { content -> - LazyColumn { + TangemBottomSheet(config = config, addBottomInsets = false) { content -> + LazyColumn( + modifier = Modifier + .navigationBarsPadding() + .imePadding(), + ) { items( items = content.itemList.toList(), key = { it }, @@ -56,8 +59,13 @@ private fun TestPushMenuDeepLinkBottomSheetContent(config: TangemBottomSheetConf @Composable private fun TestPushMarketsTokenBottomSheetContent(config: TangemBottomSheetConfig) { - TangemBottomSheet(config) { content -> - LazyColumn { + TangemBottomSheet(config = config, addBottomInsets = false) { content -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .imePadding(), + ) { item(key = "Markets Search") { OutlineTextField( label = "Title", diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/testpush/viewmodel/transformers/markettokens/TestPushMarketTokenBottomSheetTransformer.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/testpush/viewmodel/transformers/markettokens/TestPushMarketTokenBottomSheetTransformer.kt index abd657eb9e..3c0c470fe5 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/testpush/viewmodel/transformers/markettokens/TestPushMarketTokenBottomSheetTransformer.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/testpush/viewmodel/transformers/markettokens/TestPushMarketTokenBottomSheetTransformer.kt @@ -15,16 +15,25 @@ internal class TestPushMarketTokenBottomSheetTransformer( private val onItemClick: (TokenMarket) -> Unit, ) : Transformer { override fun transform(prevState: TestPushUM): TestPushUM { + val content = (prevState.bottomSheetConfig?.content as? TestPushMarketsTokenConfigUM)?.copy( + itemList = item, + onSearchValueEdit = onSearchEdit, + onItemClick = onItemClick, + ) ?: TestPushMarketsTokenConfigUM( + itemList = item, + searchValue = TextFieldValue(""), + onSearchValueEdit = onSearchEdit, + onItemClick = onItemClick, + ) return prevState.copy( - bottomSheetConfig = TangemBottomSheetConfig( + bottomSheetConfig = prevState.bottomSheetConfig?.let { + prevState.bottomSheetConfig.copy( + content = content, + ) + } ?: TangemBottomSheetConfig( isShown = true, onDismissRequest = onDismissBottomSheet, - content = TestPushMarketsTokenConfigUM( - itemList = item, - searchValue = TextFieldValue(""), - onSearchValueEdit = onSearchEdit, - onItemClick = onItemClick, - ), + content = content, ), ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt index b28e14d171..dbe31e24b9 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt @@ -51,6 +51,7 @@ internal class TokenDetailsNotificationsAnalyticsSender( is TokenDetailsNotification.RentInfo, is TokenDetailsNotification.NetworkShutdown, is TokenDetailsNotification.HederaAssociateWarning, + is TokenDetailsNotification.RequiredTrustlineWarning, is TokenDetailsNotification.KoinosMana, is TokenDetailsNotification.MigrationMaticToPol, is TokenDetailsNotification.UsedOutdatedData, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index 6aab0064c7..e0e6cd1566 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -62,6 +62,8 @@ interface TokenDetailsClickIntents { fun onRetryIncompleteTransactionClick() + fun onOpenTrustlineClick() + fun onDismissIncompleteTransactionClick() fun onConfirmDismissIncompleteTransactionClick() diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 976ace101c..d2d966d302 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -56,9 +56,11 @@ import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent.Compan import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent import com.tangem.domain.transaction.error.AssociateAssetError import com.tangem.domain.transaction.error.IncompleteTransactionError +import com.tangem.domain.transaction.error.OpenTrustlineError import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.usecase.AssociateAssetUseCase import com.tangem.domain.transaction.usecase.DismissIncompleteTransactionUseCase +import com.tangem.domain.transaction.usecase.OpenTrustlineUseCase import com.tangem.domain.transaction.usecase.RetryIncompleteTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase @@ -116,6 +118,7 @@ internal class TokenDetailsModel @Inject constructor( private val isDemoCardUseCase: IsDemoCardUseCase, private val associateAssetUseCase: AssociateAssetUseCase, private val retryIncompleteTransactionUseCase: RetryIncompleteTransactionUseCase, + private val openTrustlineUseCase: OpenTrustlineUseCase, private val dismissIncompleteTransactionUseCase: DismissIncompleteTransactionUseCase, private val reduxStateHolder: ReduxStateHolder, private val analyticsEventsHandler: AnalyticsEventHandler, @@ -893,6 +896,37 @@ internal class TokenDetailsModel @Inject constructor( } } + override fun onOpenTrustlineClick() { + analyticsEventsHandler.send( + TokenScreenAnalyticsEvent.Associate( + tokenSymbol = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + ), + ) + modelScope.launch(dispatchers.mainImmediate) { + openTrustlineUseCase( + userWalletId = userWalletId, + currency = cryptoCurrency, + ).fold( + ifLeft = { e -> + Timber.e(e.message) + internalUiState.value = when (e) { + is OpenTrustlineError.SomeError -> + stateFactory.getStateWithErrorDialog(stringReference(e.message.orEmpty())) + is OpenTrustlineError.NotEnoughCoin -> { + val text = resourceReference( + id = R.string.warning_token_required_min_coin_reserve, + formatArgs = wrappedList(e.amount), + ) + stateFactory.getStateWithErrorDialog(text) + } + } + }, + ifRight = { internalUiState.value = stateFactory.getStateWithRemovedRequiredTrustlineNotification() }, + ) + } + } + override fun onDismissIncompleteTransactionClick() { analyticsEventsHandler.send( TokenScreenAnalyticsEvent.RevealCancel( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt index 74eaebd01d..297bc67dfc 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt @@ -193,6 +193,24 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) { ), ) + data class RequiredTrustlineWarning( + private val currency: CryptoCurrency, + private val amount: String, + private val currencySymbol: String, + private val onOpenClick: () -> Unit, + ) : Warning( + title = resourceReference(id = R.string.warning_token_trustline_title), + subtitle = resourceReference( + id = R.string.warning_token_trustline_subtitle, + formatArgs = wrappedList(amount, currencySymbol), + ), + iconResId = currency.networkIconResId, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.warning_token_trustline_button_title), + onClick = onOpenClick, + ), + ) + data class KaspaIncompleteTransactionWarning( private val currency: CryptoCurrency, private val amount: String, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt index c6faec47ad..67215ebe35 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt @@ -42,6 +42,12 @@ internal class TokenDetailsNotificationConverter( return newNotifications.toImmutableList() } + fun removeRequiredTrustlineWarning(currentState: TokenDetailsState): ImmutableList { + val newNotifications = currentState.notifications.toMutableList() + newNotifications.removeBy { it is RequiredTrustlineWarning } + return newNotifications.toImmutableList() + } + fun removeKaspaIncompleteTransactionWarning( currentState: TokenDetailsState, ): ImmutableList { @@ -118,6 +124,12 @@ internal class TokenDetailsNotificationConverter( feeCurrencySymbol = warning.feeCurrencySymbol, onAssociateClick = clickIntents::onAssociateClick, ) + is CryptoCurrencyWarning.RequiredTrustline -> RequiredTrustlineWarning( + currency = warning.currency, + amount = warning.requiredAmount.format { crypto(symbol = "", warning.currencyDecimals) }, + currencySymbol = warning.currencySymbol, + onOpenClick = clickIntents::onOpenTrustlineClick, + ) is KaspaWarnings.IncompleteTransaction -> KaspaIncompleteTransactionWarning( currency = warning.currency, amount = warning.amount.format { crypto(symbol = "", decimals = warning.currencyDecimals) }, 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 222804e41b..4819797954 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 @@ -334,6 +334,11 @@ internal class TokenDetailsStateFactory( return state.copy(notifications = notificationConverter.removeHederaAssociateWarning(state)) } + fun getStateWithRemovedRequiredTrustlineNotification(): TokenDetailsState { + val state = currentStateProvider() + return state.copy(notifications = notificationConverter.removeRequiredTrustlineWarning(state)) + } + fun getStateWithRemovedKaspaIncompleteTransactionNotification(): TokenDetailsState { val state = currentStateProvider() return state.copy( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt index f02dc11cf4..01835b8f16 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -17,7 +17,8 @@ import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday import com.tangem.core.ui.utils.toTimeFormat import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.quote.mapData import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed @@ -57,7 +58,7 @@ internal class TokenDetailsSwapTransactionsStateConverter( fun convert( savedTransactions: List, - quotes: Set, + quoteStatuses: Set, ): PersistentList { val result = mutableListOf() @@ -73,12 +74,17 @@ internal class TokenDetailsSwapTransactionsStateConverter( val fromAmount = transaction.fromCryptoAmount var toFiatAmount: BigDecimal? = null var fromFiatAmount: BigDecimal? = null - quotes.forEach { quote -> - if (quote is Quote.Value && quote.rawCurrencyId == toCryptoCurrencyRawId) { - toFiatAmount = quote.fiatRate.multiply(toAmount) + quoteStatuses.forEach { quote -> + quote.mapData { + if (quote.rawCurrencyId == toCryptoCurrencyRawId) { + toFiatAmount = fiatRate.multiply(toAmount) + } } - if (quote is Quote.Value && quote.rawCurrencyId == fromCryptoCurrencyRawId) { - fromFiatAmount = quote.fiatRate.multiply(fromAmount) + + quote.mapData { + if (quote.rawCurrencyId == fromCryptoCurrencyRawId) { + fromFiatAmount = fiatRate.multiply(fromAmount) + } } } val statusModel = transaction.status diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt index 0a75b5e7d7..c12a0e8048 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt @@ -6,9 +6,9 @@ import com.tangem.datasource.local.swaptx.ExpressAnalyticsStatus import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.quotes.QuotesRepositoryV2 +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase -import com.tangem.domain.tokens.model.Quote import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.swap.domain.SwapTransactionRepository @@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.map internal class ExchangeStatusFactory @AssistedInject constructor( private val swapTransactionRepository: SwapTransactionRepository, private val swapRepository: SwapRepository, - private val quotesRepositoryV2: QuotesRepositoryV2, + private val quotesRepository: QuotesRepository, private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, private val swapTransactionStatusStore: SwapTransactionStatusStore, private val analyticsEventsHandler: AnalyticsEventHandler, @@ -66,7 +66,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( getExchangeStatusState( savedTransactions = savedTransactions, - quotes = quotes, + quoteStatuses = quotes, ) } } @@ -155,7 +155,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( private fun getExchangeStatusState( savedTransactions: List?, - quotes: Set, + quoteStatuses: Set, ): PersistentList { if (savedTransactions == null) { return persistentListOf() @@ -163,7 +163,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( return swapTransactionsStateConverter.convert( savedTransactions = savedTransactions, - quotes = quotes, + quoteStatuses = quoteStatuses, ) } @@ -187,10 +187,10 @@ internal class ExchangeStatusFactory @AssistedInject constructor( } } - private suspend fun Set.getQuotesOrEmpty(): Set { + private suspend fun Set.getQuotesOrEmpty(): Set { val rawIds = mapNotNull { it.rawCurrencyId }.toSet() - return runCatching { quotesRepositoryV2.getMultiQuoteSyncOrNull(currenciesIds = rawIds) } + return runCatching { quotesRepository.getMultiQuoteSyncOrNull(currenciesIds = rawIds) } .getOrNull() .orEmpty() } diff --git a/features/walletconnect/impl/build.gradle.kts b/features/walletconnect/impl/build.gradle.kts index 53a4e3c17c..f0cd4ec491 100644 --- a/features/walletconnect/impl/build.gradle.kts +++ b/features/walletconnect/impl/build.gradle.kts @@ -14,6 +14,7 @@ android { dependencies { implementation(projects.features.wallet.api) implementation(projects.features.walletconnect.api) + implementation(projects.features.sendV2.api) /** Core */ implementation(projects.core.configToggles) @@ -29,6 +30,7 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.qrScanning.models) implementation(projects.domain.tokens.models) + implementation(projects.domain.transaction.models) implementation(projects.domain.wallets.models) implementation(projects.domain.walletConnect) implementation(projects.domain.walletConnect.models) @@ -36,8 +38,10 @@ dependencies { /** Domain */ implementation(projects.domain.appCurrency) implementation(projects.domain.balanceHiding) + implementation(projects.domain.legacy) implementation(projects.domain.qrScanning) implementation(projects.domain.tokens) + implementation(projects.domain.transaction) implementation(projects.domain.wallets) /** DI */ diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcSelectNetworksComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcSelectNetworksComponent.kt index 86acac33bc..73d622b6ce 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcSelectNetworksComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcSelectNetworksComponent.kt @@ -66,7 +66,7 @@ internal class WcSelectNetworksComponent( } interface ModelCallback { - fun onNetworksSelected(selectedNetworks: Set) + fun onNetworksSelected(selectedNetworks: Set) } data class WcSelectNetworksParams( @@ -74,7 +74,7 @@ internal class WcSelectNetworksComponent( val requiredNetworks: Set, val availableNetworks: Set, val notAddedNetworks: Set, - val enabledAvailableNetworks: Set, + val enabledAvailableNetworks: Set, val onDismiss: () -> Unit, val callback: ModelCallback, ) @@ -87,8 +87,6 @@ private fun WcSelectNetworksBS( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - if (state.required.isEmpty()) return // required can/must not be empty - TangemModalBottomSheetWithFooter( config = TangemBottomSheetConfig( isShown = true, @@ -117,7 +115,6 @@ private fun WcSelectNetworksBS( .fillMaxWidth(), text = "Done", onClick = state.onDone, - enabled = state.missing.isEmpty(), ) }, ) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectedAppInfoModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectedAppInfoModel.kt index f415b0b68e..622f3c98f0 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectedAppInfoModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectedAppInfoModel.kt @@ -58,7 +58,7 @@ internal class WcConnectedAppInfoModel @Inject constructor( appIcon = session.sdkModel.appMetaData.icons.firstOrNull().orEmpty(), isVerified = session.securityStatus == CheckDAppResult.SAFE, verifiedDAppState = extractVerifiedState(session), - appSubtitle = session.sdkModel.appMetaData.description, + appSubtitle = session.sdkModel.appMetaData.url, walletName = session.wallet.name, networks = session.networks .map { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt index 0167da0484..bf9adcb783 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt @@ -13,6 +13,7 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.models.network.Network +import com.tangem.domain.walletconnect.model.WcPairError import com.tangem.domain.walletconnect.model.WcPairRequest import com.tangem.domain.walletconnect.model.WcSessionApprove import com.tangem.domain.walletconnect.model.WcSessionProposal @@ -25,14 +26,10 @@ import com.tangem.features.walletconnect.connections.components.WcSelectNetworks import com.tangem.features.walletconnect.connections.components.WcSelectWalletComponent import com.tangem.features.walletconnect.connections.entity.WcAppInfoUM import com.tangem.features.walletconnect.connections.entity.WcPrimaryButtonConfig -import com.tangem.features.walletconnect.connections.model.transformers.WcAppInfoTransformer -import com.tangem.features.walletconnect.connections.model.transformers.WcAppInfoWalletChangedTransformer -import com.tangem.features.walletconnect.connections.model.transformers.WcConnectButtonProgressTransformer -import com.tangem.features.walletconnect.connections.model.transformers.WcDAppVerifiedStateConverter +import com.tangem.features.walletconnect.connections.model.transformers.* import com.tangem.features.walletconnect.connections.routes.WcAppInfoRoutes import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* -import timber.log.Timber import javax.inject.Inject import kotlin.properties.Delegates import com.tangem.utils.transformer.update as transformerUpdate @@ -67,7 +64,7 @@ internal class WcPairModel @Inject constructor( MutableStateFlow(getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }) private var proposalNetwork by Delegates.notNull() private var sessionProposal by Delegates.notNull() - private val additionallyEnabledNetworks = mutableSetOf() + private var additionallyEnabledNetworks = setOf() private val dAppVerifiedStateConverter = WcDAppVerifiedStateConverter(onVerifiedClick = ::showVerifiedAlert) val appInfoUiState: StateFlow @@ -78,6 +75,7 @@ internal class WcPairModel @Inject constructor( } private fun loadDAppInfo() { + fun errorToast(error: WcPairError) = messageSender.send(ToastMessage(message = stringReference(error.message))) wcPairUseCase() .onEach { pairState -> when (pairState) { @@ -88,20 +86,20 @@ internal class WcPairModel @Inject constructor( appInfoUiState.transformerUpdate( WcConnectButtonProgressTransformer(showProgress = false), ) + pairState.result.onLeft(::errorToast) router.pop() } is WcPairState.Error -> { appInfoUiState.transformerUpdate( WcConnectButtonProgressTransformer(showProgress = false), ) - messageSender.send(ToastMessage(message = stringReference(pairState.error.message))) - Timber.e(pairState.error) + errorToast(pairState.error) } is WcPairState.Loading -> appInfoUiState.update { createLoadingState() } is WcPairState.Proposal -> { sessionProposal = pairState.dAppSession proposalNetwork = sessionProposal.proposalNetwork.getValue(selectedUserWalletFlow.value) - proposalNetwork.available.mapTo(additionallyEnabledNetworks) { it.id.rawId } + additionallyEnabledNetworks = proposalNetwork.available appInfoUiState.transformerUpdate( WcAppInfoTransformer( dAppSession = sessionProposal, @@ -126,6 +124,7 @@ internal class WcPairModel @Inject constructor( }, userWallet = selectedUserWalletFlow.value, proposalNetwork = proposalNetwork, + additionallyEnabledNetworks = additionallyEnabledNetworks, ), ) } @@ -153,7 +152,7 @@ internal class WcPairModel @Inject constructor( private fun connect() { val enabledAvailableNetworks = - proposalNetwork.available.filter { network -> network.id.rawId in additionallyEnabledNetworks } + proposalNetwork.available.filter { network -> network in additionallyEnabledNetworks } wcPairUseCase.approve( WcSessionApprove( wallet = selectedUserWalletFlow.value, @@ -178,14 +177,25 @@ internal class WcPairModel @Inject constructor( val selectedUserWallet = sessionProposal.proposalNetwork.keys.first { it.walletId == userWalletId } proposalNetwork = sessionProposal.proposalNetwork.getValue(selectedUserWallet) selectedUserWalletFlow.update { selectedUserWallet } - appInfoUiState.transformerUpdate(WcAppInfoWalletChangedTransformer(selectedUserWallet, proposalNetwork)) - additionallyEnabledNetworks.clear() - proposalNetwork.available.mapTo(additionallyEnabledNetworks) { it.id.rawId } + additionallyEnabledNetworks = proposalNetwork.available + appInfoUiState.transformerUpdate( + WcAppInfoWalletChangedTransformer( + selectedUserWallet = selectedUserWallet, + proposalNetwork = proposalNetwork, + additionallyEnabledNetworks = additionallyEnabledNetworks, + ), + ) } - override fun onNetworksSelected(selectedNetworks: Set) { - additionallyEnabledNetworks.clear() - additionallyEnabledNetworks.addAll(selectedNetworks) + override fun onNetworksSelected(selectedNetworks: Set) { + additionallyEnabledNetworks = selectedNetworks + appInfoUiState.transformerUpdate( + WcNetworksSelectedTransformer( + missingNetworks = proposalNetwork.missingRequired, + requiredNetworks = proposalNetwork.required, + additionallyEnabledNetworks = additionallyEnabledNetworks, + ), + ) } private fun createLoadingState(): WcAppInfoUM.Loading { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcSelectNetworksModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcSelectNetworksModel.kt index ba3466ee86..c7e1f38b60 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcSelectNetworksModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcSelectNetworksModel.kt @@ -29,7 +29,7 @@ internal class WcSelectNetworksModel @Inject constructor( ) : Model() { private val params = paramsContainer.require() - private val additionallyEnabledNetworks = MutableStateFlow>( + private val additionallyEnabledNetworks = MutableStateFlow>( filterAdditionallyEnabledNetworks( availableNetworks = params.availableNetworks, additionallyEnabledNetworks = params.enabledAvailableNetworks, @@ -39,11 +39,11 @@ internal class WcSelectNetworksModel @Inject constructor( val state: StateFlow field = MutableStateFlow(getInitialState()) - private fun onCheckedChange(isChecked: Boolean, networkId: String) { + private fun onCheckedChange(isChecked: Boolean, network: Network) { additionallyEnabledNetworks.update { - if (isChecked) it.plus(Network.RawID(networkId)) else it.minus(Network.RawID(networkId)) + if (isChecked) it.plus(network) else it.minus(network) } - state.update(WcSelectNetworksCheckedTransformer(networkId, isChecked)) + state.update(WcSelectNetworksCheckedTransformer(network = network, isChecked = isChecked)) } private fun onDone() { @@ -53,12 +53,9 @@ internal class WcSelectNetworksModel @Inject constructor( private fun filterAdditionallyEnabledNetworks( availableNetworks: Set, - additionallyEnabledNetworks: Set, - ): Set { - return availableNetworks - .asSequence() - .filter { it.id.rawId in additionallyEnabledNetworks } - .mapTo(hashSetOf()) { it.id.rawId } + additionallyEnabledNetworks: Set, + ): Set { + return availableNetworks.filterTo(hashSetOf()) { it in additionallyEnabledNetworks } } private fun getInitialState(): WcSelectNetworksUM { @@ -85,8 +82,8 @@ internal class WcSelectNetworksModel @Inject constructor( icon = network.iconResId, name = network.name, symbol = network.currencySymbol, - checked = network.id.rawId in additionallyEnabledNetworks.value, - onCheckedChange = { onCheckedChange(it, network.rawId) }, + checked = network in additionallyEnabledNetworks.value, + onCheckedChange = { onCheckedChange(it, network) }, ) }.toImmutableList(), notAdded = params.notAddedNetworks.map { network -> diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcAppInfoTransformer.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcAppInfoTransformer.kt index 2989e66730..61d9f198d0 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcAppInfoTransformer.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcAppInfoTransformer.kt @@ -1,6 +1,7 @@ package com.tangem.features.walletconnect.connections.model.transformers import com.domain.blockaid.models.dapp.CheckDAppResult +import com.tangem.domain.models.network.Network import com.tangem.domain.walletconnect.model.WcSessionProposal import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.walletconnect.connections.entity.WcAppInfoSecurityNotification @@ -18,6 +19,7 @@ internal class WcAppInfoTransformer( private val onNetworksClick: () -> Unit, private val userWallet: UserWallet, private val proposalNetwork: WcSessionProposal.ProposalNetwork, + private val additionallyEnabledNetworks: Set, ) : Transformer { override fun transform(prevState: WcAppInfoUM): WcAppInfoUM { @@ -27,11 +29,17 @@ internal class WcAppInfoTransformer( verifiedDAppState = dAppVerifiedStateConverter.convert( dAppSession.securityStatus to dAppSession.dAppMetaData.name, ), - appSubtitle = dAppSession.dAppMetaData.description, + appSubtitle = dAppSession.dAppMetaData.url, notification = createNotification(dAppSession.securityStatus), walletName = userWallet.name, onWalletClick = onWalletClick, - networksInfo = WcNetworksInfoConverter.convert(proposalNetwork), + networksInfo = WcNetworksInfoConverter.convert( + value = WcNetworksInfoConverter.Input( + missingNetworks = proposalNetwork.missingRequired, + requiredNetworks = proposalNetwork.required, + additionallyEnabledNetworks = additionallyEnabledNetworks, + ), + ), onNetworksClick = onNetworksClick, connectButtonConfig = WcPrimaryButtonConfig( showProgress = false, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcAppInfoWalletChangedTransformer.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcAppInfoWalletChangedTransformer.kt index 453ab9b608..21be3da5a5 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcAppInfoWalletChangedTransformer.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcAppInfoWalletChangedTransformer.kt @@ -1,5 +1,6 @@ package com.tangem.features.walletconnect.connections.model.transformers +import com.tangem.domain.models.network.Network import com.tangem.domain.walletconnect.model.WcSessionProposal import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.walletconnect.connections.entity.WcAppInfoUM @@ -8,12 +9,19 @@ import com.tangem.utils.transformer.Transformer internal class WcAppInfoWalletChangedTransformer( private val selectedUserWallet: UserWallet, private val proposalNetwork: WcSessionProposal.ProposalNetwork, + private val additionallyEnabledNetworks: Set, ) : Transformer { override fun transform(prevState: WcAppInfoUM): WcAppInfoUM { val contentState = prevState as? WcAppInfoUM.Content ?: return prevState return contentState.copy( walletName = selectedUserWallet.name, - networksInfo = WcNetworksInfoConverter.convert(proposalNetwork), + networksInfo = WcNetworksInfoConverter.convert( + WcNetworksInfoConverter.Input( + missingNetworks = proposalNetwork.missingRequired, + requiredNetworks = proposalNetwork.required, + additionallyEnabledNetworks = additionallyEnabledNetworks, + ), + ), connectButtonConfig = prevState.connectButtonConfig.copy( enabled = proposalNetwork.missingRequired.isEmpty(), ), diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksInfoConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksInfoConverter.kt index 85af70aefa..26934b08b7 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksInfoConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksInfoConverter.kt @@ -1,22 +1,21 @@ package com.tangem.features.walletconnect.connections.model.transformers import com.tangem.core.ui.extensions.iconResId -import com.tangem.domain.walletconnect.model.WcSessionProposal +import com.tangem.domain.models.network.Network import com.tangem.features.walletconnect.connections.entity.WcNetworkInfoItem import com.tangem.features.walletconnect.connections.entity.WcNetworksInfo import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList -internal object WcNetworksInfoConverter : Converter { - override fun convert(value: WcSessionProposal.ProposalNetwork): WcNetworksInfo { - return if (value.missingRequired.isNotEmpty()) { +internal object WcNetworksInfoConverter : Converter { + override fun convert(value: Input): WcNetworksInfo { + return if (value.missingNetworks.isNotEmpty()) { WcNetworksInfo.MissingRequiredNetworkInfo( - networks = value.missingRequired - .joinToString { it.name }, + networks = value.missingNetworks.joinToString { it.name }, ) } else { WcNetworksInfo.ContainsAllRequiredNetworks( - items = (value.required + value.available) + items = (value.requiredNetworks + value.additionallyEnabledNetworks) .map { WcNetworkInfoItem.Required( id = it.rawId, @@ -29,4 +28,10 @@ internal object WcNetworksInfoConverter : Converter, + val requiredNetworks: Set, + val additionallyEnabledNetworks: Set, + ) } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksSelectedTransformer.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksSelectedTransformer.kt new file mode 100644 index 0000000000..ee953b6130 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksSelectedTransformer.kt @@ -0,0 +1,28 @@ +package com.tangem.features.walletconnect.connections.model.transformers + +import com.tangem.domain.models.network.Network +import com.tangem.features.walletconnect.connections.entity.WcAppInfoUM +import com.tangem.utils.transformer.Transformer + +internal class WcNetworksSelectedTransformer( + private val missingNetworks: Set, + private val requiredNetworks: Set, + private val additionallyEnabledNetworks: Set, +) : Transformer { + override fun transform(prevState: WcAppInfoUM): WcAppInfoUM { + val contentState = prevState as? WcAppInfoUM.Content ?: return prevState + return contentState.copy( + connectButtonConfig = contentState.connectButtonConfig.copy( + enabled = missingNetworks.isEmpty() && + (requiredNetworks.isNotEmpty() || additionallyEnabledNetworks.isNotEmpty()), + ), + networksInfo = WcNetworksInfoConverter.convert( + value = WcNetworksInfoConverter.Input( + missingNetworks = missingNetworks, + requiredNetworks = requiredNetworks, + additionallyEnabledNetworks = additionallyEnabledNetworks, + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSelectNetworksCheckedTransformer.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSelectNetworksCheckedTransformer.kt index 582c0465dd..c8901e01b8 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSelectNetworksCheckedTransformer.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSelectNetworksCheckedTransformer.kt @@ -1,17 +1,18 @@ package com.tangem.features.walletconnect.connections.model.transformers +import com.tangem.domain.models.network.Network import com.tangem.features.walletconnect.connections.entity.WcSelectNetworksUM import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.toImmutableList internal class WcSelectNetworksCheckedTransformer( - private val networkId: String, + private val network: Network, private val isChecked: Boolean, ) : Transformer { override fun transform(prevState: WcSelectNetworksUM): WcSelectNetworksUM { return prevState.copy( available = prevState.available - .map { item -> if (item.id == networkId) item.copy(checked = isChecked) else item } + .map { item -> if (item.id == network.rawId) item.copy(checked = isChecked) else item } .toImmutableList(), ) } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsTransformer.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsTransformer.kt index b71e892c4e..b3c3ad5b81 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsTransformer.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsTransformer.kt @@ -25,7 +25,7 @@ internal class WcSessionsTransformer( WcConnectedAppInfo( name = appMetaData.name, iconUrl = appMetaData.icons.firstOrNull().orEmpty(), - subtitle = stringReference(appMetaData.description), + subtitle = stringReference(appMetaData.url), verifiedState = WcDAppVerifiedStateConverter {} .convert(session.securityStatus to appMetaData.name), onClick = { openAppInfoModal(session) }, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/WcAppInfoRoutes.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/WcAppInfoRoutes.kt index 9732c1e0aa..6c83d5ac07 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/WcAppInfoRoutes.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/WcAppInfoRoutes.kt @@ -21,7 +21,7 @@ internal sealed class WcAppInfoRoutes : TangemBottomSheetConfigContent, Route { val missingRequiredNetworks: Set, val requiredNetworks: Set, val availableNetworks: Set, - val enabledAvailableNetworks: Set, + val enabledAvailableNetworks: Set, val notAddedNetworks: Set, ) : WcAppInfoRoutes() diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt index 4e0a019cb7..84a1d7c20d 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt @@ -14,6 +14,7 @@ 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.ComposableContentComponent +import com.tangem.features.send.v2.api.FeeSelectorBlockComponent import com.tangem.features.walletconnect.components.WcRoutingComponent import com.tangem.features.walletconnect.connections.components.AlertsComponent import com.tangem.features.walletconnect.connections.components.WcPairComponent @@ -29,6 +30,7 @@ import dagger.assisted.AssistedInject internal class DefaultWcRoutingComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @Assisted params: Unit, + private val feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory, ) : AppComponentContext by appComponentContext, WcRoutingComponent { private val model: WcRoutingModel = getOrCreateModel() @@ -70,6 +72,7 @@ internal class DefaultWcRoutingComponent @AssistedInject constructor( is WcInnerRoute.Send -> WcSendTransactionContainerComponent( appComponentContext = childContext, params = WcTransactionModelParams(config.rawRequest), + feeSelectorBlockComponentFactory = feeSelectorBlockComponentFactory, ) is WcInnerRoute.Pair -> WcPairComponent( appComponentContext = childContext, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/InternalComponents.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/InternalComponents.kt index 1b8b649805..3150cbc5f6 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/InternalComponents.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/InternalComponents.kt @@ -37,6 +37,7 @@ internal fun WcAppInfoItem( }, ) .padding(TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), ) { AsyncImage( diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoBS.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoBS.kt index 29f564d601..9e2490c0f9 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoBS.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoBS.kt @@ -349,12 +349,7 @@ private fun SelectNetworksBlock(networksInfo: WcNetworksInfo, modifier: Modifier } Icon( modifier = Modifier.size(width = 18.dp, height = 24.dp), - painter = painterResource( - id = when (networksInfo) { - is WcNetworksInfo.ContainsAllRequiredNetworks -> R.drawable.ic_select_18_24 - is WcNetworksInfo.MissingRequiredNetworkInfo -> R.drawable.ic_chevron_right_24 - }, - ), + painter = painterResource(id = R.drawable.ic_select_18_24), contentDescription = null, tint = TangemTheme.colors.icon.informative, ) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcAlertsFactory.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcAlertsFactory.kt index e9ab26ccf8..75e7d6143e 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcAlertsFactory.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcAlertsFactory.kt @@ -2,16 +2,24 @@ package com.tangem.features.walletconnect.connections.utils import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.message.* +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.features.walletconnect.transaction.routes.WcTransactionRoutes internal object WcAlertsFactory { - internal fun createCommonTransactionAppInfoAlertUM(alertType: WcTransactionRoutes.Alert.Type) = when (alertType) { - is WcTransactionRoutes.Alert.Type.Verified -> createVerifiedDomainAlert(alertType.appName) - is WcTransactionRoutes.Alert.Type.UnknownDomain -> createUnknownDomainAlert() - is WcTransactionRoutes.Alert.Type.UnsafeDomain -> createUnsafeDomainAlert() + fun createCommonTransactionAppInfoAlertUM(alertType: WcTransactionRoutes.Alert.Type) = when (alertType) { + is WcTransactionRoutes.Alert.Type.Verified -> + createVerifiedDomainAlert(alertType.appName) + is WcTransactionRoutes.Alert.Type.UnknownDomain -> + createUnknownDomainAlert() + is WcTransactionRoutes.Alert.Type.UnsafeDomain -> + createUnsafeDomainAlert() + is WcTransactionRoutes.Alert.Type.MaliciousInfo -> + createMaliciousDAppAlert(alertType.description, alertType.onClick) + is WcTransactionRoutes.Alert.Type.UnknownError -> + createUnknownErrorAlert(alertType.errorMessage, alertType.onDismiss) } fun createUnknownDomainAlert(activeButtonOnClick: (() -> Unit)? = null): MessageBottomSheetUMV2 { @@ -66,6 +74,56 @@ internal object WcAlertsFactory { } } + private fun createUnknownErrorAlert(errorMessage: String?, onDismiss: () -> Unit): MessageBottomSheetUMV2 { + return messageBottomSheetUM { + infoBlock { + icon(R.drawable.img_attention_20) { + type = MessageBottomSheetUMV2.Icon.Type.Warning + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + } + title = resourceReference(R.string.wc_alert_unknown_error_title) + body = if (errorMessage.isNullOrEmpty()) { + resourceReference(R.string.wc_alert_unknown_error_description_no_error_code) + } else { + resourceReference( + R.string.wc_alert_unknown_error_description, + wrappedList(errorMessage), + ) + } + } + secondaryButton { + text = resourceReference(R.string.balance_hidden_got_it_button) + onClick { onDismiss() } + } + } + } + + private fun createMaliciousDAppAlert( + description: String?, + activeButtonOnClick: (() -> Unit), + ): MessageBottomSheetUMV2 { + return messageBottomSheetUM { + infoBlock { + icon(R.drawable.img_knight_shield_32) { + type = MessageBottomSheetUMV2.Icon.Type.Warning + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + } + title = resourceReference(R.string.security_alert_title) + if (!description.isNullOrEmpty()) { + body = TextReference.Str(description) + } + } + primaryButton { + text = resourceReference(R.string.common_cancel) + onClick { closeBs() } + } + secondaryButton { + text = resourceReference(R.string.wc_alert_sign_anyway) + onClick { activeButtonOnClick() } + } + } + } + fun createVerifiedDomainAlert(appName: String): MessageBottomSheetUMV2 { return messageBottomSheetUM { infoBlock { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt new file mode 100644 index 0000000000..00c218f576 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt @@ -0,0 +1,63 @@ +package com.tangem.features.walletconnect.transaction.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.send.v2.api.FeeSelectorBlockComponent +import com.tangem.features.walletconnect.impl.R + +internal class PreviewFeeSelectorBlockComponent : FeeSelectorBlockComponent { + @Composable + override fun Content(modifier: Modifier) { + Row( + modifier = modifier + .background(TangemTheme.colors.background.action) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_fee_new_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + Text( + modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + text = stringResourceSafe(R.string.common_network_fee_title), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + Icon( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing6) + .size(TangemTheme.dimens.size16), + painter = painterResource(id = R.drawable.ic_token_info_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "~ 0.25 \$", + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.tertiary, + ) + Icon( + modifier = Modifier.size(width = 18.dp, height = 24.dp), + painter = painterResource(id = R.drawable.ic_select_18_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + } + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcCommonTransactionComponentDelegate.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcCommonTransactionComponentDelegate.kt index 0dd02cb797..1be9296325 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcCommonTransactionComponentDelegate.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcCommonTransactionComponentDelegate.kt @@ -60,6 +60,7 @@ internal abstract class WcCommonTransactionComponentDelegate( is WcTransactionRoutes.Transaction, is WcTransactionRoutes.TransactionRequestInfo, is WcTransactionRoutes.Alert, + is WcTransactionRoutes.CustomAllowance, -> stackNavigation?.pop() else -> Unit } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt index d06bc28646..c38c4ce6bd 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt @@ -4,7 +4,9 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.features.walletconnect.connections.components.AlertsComponentV2 import com.tangem.features.walletconnect.connections.utils.WcAlertsFactory.createCommonTransactionAppInfoAlertUM +import com.tangem.features.walletconnect.transaction.components.send.WcCustomAllowanceComponent import com.tangem.features.walletconnect.transaction.entity.common.WcCommonTransactionModel +import com.tangem.features.walletconnect.transaction.model.WcSendTransactionModel import com.tangem.features.walletconnect.transaction.routes.WcTransactionRoutes internal fun getWcCommonScreen( @@ -23,5 +25,12 @@ internal fun getWcCommonScreen( appComponentContext = appComponentContext, model = model, ) + is WcTransactionRoutes.CustomAllowance -> WcCustomAllowanceComponent( + appComponentContext = appComponentContext, + model = model, + onClickDoneCustomAllowance = { value, isUnlimited -> + (model as? WcSendTransactionModel)?.onClickDoneCustomAllowance(value, isUnlimited) + }, + ) } } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcCustomAllowanceComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcCustomAllowanceComponent.kt new file mode 100644 index 0000000000..afe90c5207 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcCustomAllowanceComponent.kt @@ -0,0 +1,39 @@ +package com.tangem.features.walletconnect.transaction.components.send + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.walletconnect.transaction.entity.common.WcCommonTransactionModel +import com.tangem.features.walletconnect.transaction.entity.send.WcSendTransactionUM +import com.tangem.features.walletconnect.transaction.ui.approve.WcCustomAllowanceContent +import java.math.BigDecimal + +internal class WcCustomAllowanceComponent( + private val appComponentContext: AppComponentContext, + private val model: WcCommonTransactionModel, + private val onClickDoneCustomAllowance: (BigDecimal, Boolean) -> Unit, +) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { + + override fun dismiss() { + model.dismiss() + } + + @Composable + override fun BottomSheet() { + val content by model.uiState.collectAsStateWithLifecycle() + val spendAllowance = (content as? WcSendTransactionUM)?.spendAllowance + if (spendAllowance != null) { + WcCustomAllowanceContent( + state = spendAllowance, + onClickDone = { value, isUnlimited -> + router.pop() + onClickDoneCustomAllowance(value, isUnlimited) + }, + onBack = router::pop, + onDismiss = ::dismiss, + ) + } + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt index 648dc13467..4a9271ab98 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt @@ -5,14 +5,29 @@ import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.send.v2.api.FeeSelectorBlockComponent +import com.tangem.features.send.v2.api.params.FeeSelectorParams import com.tangem.features.walletconnect.transaction.model.WcSendTransactionModel import com.tangem.features.walletconnect.transaction.ui.send.WcSendTransactionModalBottomSheet internal class WcSendTransactionComponent( private val appComponentContext: AppComponentContext, private val model: WcSendTransactionModel, + private val feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory, ) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { + private val feeSelectorBlockComponent by lazy { + feeSelectorBlockComponentFactory.create( + context = appComponentContext, + params = FeeSelectorParams.FeeSelectorBlockParams( + onLoadFee = model::loadFee, + network = model.useCase.network, + cryptoCurrencyStatus = model.cryptoCurrencyStatus, + suggestedFeeState = model.suggestedFeeState, + ), + ) + } + override fun dismiss() { model.dismiss() } @@ -24,9 +39,11 @@ internal class WcSendTransactionComponent( if (content != null) { WcSendTransactionModalBottomSheet( state = content!!.transaction, + feeSelectorBlockComponent = feeSelectorBlockComponent, onClickTransactionRequest = model::showTransactionRequest, onBack = router::pop, onDismiss = ::dismiss, + onClickAllowToSpend = model::onClickAllowToSpend, ) } } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionContainerComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionContainerComponent.kt index 420e419ce1..5de06f65d4 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionContainerComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionContainerComponent.kt @@ -5,6 +5,7 @@ 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.send.v2.api.FeeSelectorBlockComponent import com.tangem.features.walletconnect.transaction.components.common.WcCommonTransactionComponentDelegate import com.tangem.features.walletconnect.transaction.components.common.WcTransactionModelParams import com.tangem.features.walletconnect.transaction.components.common.getWcCommonScreen @@ -14,6 +15,7 @@ import com.tangem.features.walletconnect.transaction.routes.WcTransactionRoutes internal class WcSendTransactionContainerComponent( appComponentContext: AppComponentContext, params: WcTransactionModelParams, + private val feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory, ) : WcCommonTransactionComponentDelegate(appComponentContext) { private val model: WcSendTransactionModel = getOrCreateModel(params = params) @@ -40,6 +42,10 @@ internal class WcSendTransactionContainerComponent( } private fun createTransactionComponent(appComponentContext: AppComponentContext): WcSendTransactionComponent { - return WcSendTransactionComponent(appComponentContext = appComponentContext, model = model) + return WcSendTransactionComponent( + appComponentContext = appComponentContext, + model = model, + feeSelectorBlockComponentFactory = feeSelectorBlockComponentFactory, + ) } } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/sign/WcSignTransactionContainerComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/sign/WcSignTransactionContainerComponent.kt index cdfd63915b..4f137d1841 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/sign/WcSignTransactionContainerComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/sign/WcSignTransactionContainerComponent.kt @@ -40,6 +40,9 @@ internal class WcSignTransactionContainerComponent( } private fun createTransactionComponent(appComponentContext: AppComponentContext): WcSignTransactionComponent { - return WcSignTransactionComponent(appComponentContext = appComponentContext, model = model) + return WcSignTransactionComponent( + appComponentContext = appComponentContext, + model = model, + ) } } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt index d91c5cfc46..ca1ba812c4 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt @@ -3,15 +3,13 @@ package com.tangem.features.walletconnect.transaction.converter import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.walletconnect.model.WcEthMethod import com.tangem.domain.walletconnect.model.WcSolanaMethod +import com.tangem.domain.walletconnect.usecase.method.WcMutableFee import com.tangem.domain.walletconnect.usecase.method.WcSignState import com.tangem.domain.walletconnect.usecase.method.WcSignStep import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase import com.tangem.features.walletconnect.impl.R import com.tangem.features.walletconnect.transaction.entity.blockaid.WcSendReceiveTransactionCheckResultsUM -import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionActionsUM -import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestBlockUM -import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestInfoItemUM -import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestInfoUM +import com.tangem.features.walletconnect.transaction.entity.common.* import com.tangem.features.walletconnect.transaction.entity.send.WcSendTransactionItemUM import com.tangem.features.walletconnect.transaction.entity.send.WcSendTransactionUM import com.tangem.utils.converter.Converter @@ -39,6 +37,7 @@ internal class WcSendTransactionUMConverter @Inject constructor( onShowVerifiedAlert = value.actions.onShowVerifiedAlert, ), ), + feeState = constructFeeState(useCase = value.useCase), walletName = value.useCase.session.wallet.name, networkInfo = networkInfoUMConverter.convert(value.useCase.network), address = value.useCase.walletAddress.toShortAddressText(), @@ -81,6 +80,12 @@ internal class WcSendTransactionUMConverter @Inject constructor( else -> null } + private fun constructFeeState(useCase: WcTransactionUseCase): WcTransactionFeeState { + val mutableFee = useCase as? WcMutableFee ?: return WcTransactionFeeState.None + val dAppFee = mutableFee.dAppFee() + return if (dAppFee != null) WcTransactionFeeState.Success(dAppFee) else WcTransactionFeeState.Loading + } + data class Input( val useCase: WcTransactionUseCase, val signState: WcSignState<*>, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcTransactionAppInfoContentUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcTransactionAppInfoContentUMConverter.kt index f7b97c0d9d..05a70969d3 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcTransactionAppInfoContentUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcTransactionAppInfoContentUMConverter.kt @@ -15,7 +15,7 @@ internal class WcTransactionAppInfoContentUMConverter @Inject constructor() : verifiedState = WcDAppVerifiedStateConverter(onVerifiedClick = value.onShowVerifiedAlert).convert( value.session.securityStatus to value.session.sdkModel.appMetaData.name, ), - appSubtitle = value.session.sdkModel.appMetaData.description, + appSubtitle = value.session.sdkModel.appMetaData.url, ) data class Input( diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcTransactionRequestBlockUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcTransactionRequestBlockUMConverter.kt index 188c4bc5d2..241a8ba2bf 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcTransactionRequestBlockUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcTransactionRequestBlockUMConverter.kt @@ -8,23 +8,40 @@ import com.tangem.features.walletconnect.transaction.entity.common.WcTransaction import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestInfoItemUM import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.persistentListOf +import org.json.JSONArray import javax.inject.Inject +private const val DATA_FIELD = "data" + internal class WcTransactionRequestBlockUMConverter @Inject constructor() : Converter { - override fun convert(value: Input) = WcTransactionRequestBlockUM( - persistentListOf( - WcTransactionRequestInfoItemUM( - title = resourceReference(R.string.wc_signature_type), - description = value.rawSdkRequest.request.method, + override fun convert(value: Input): WcTransactionRequestBlockUM { + val params = value.rawSdkRequest.request.params + return WcTransactionRequestBlockUM( + persistentListOf( + WcTransactionRequestInfoItemUM( + title = resourceReference(R.string.wc_signature_type), + description = value.rawSdkRequest.request.method, + ), + WcTransactionRequestInfoItemUM( + title = resourceReference(R.string.wc_contents), + description = value.signModel?.humanMsg ?: params.tryExtractData() ?: params, + ), ), - WcTransactionRequestInfoItemUM( - title = resourceReference(R.string.wc_contents), - description = value.signModel?.humanMsg ?: value.rawSdkRequest.request.params, - ), - ), - ) + ) + } + + private fun String.tryExtractData() = try { + val array = JSONArray(this) + if (array.length() > 0 && array.getJSONObject(0).has(DATA_FIELD)) { + array.getJSONObject(0).getString(DATA_FIELD) + } else { + null + } + } catch (ignore: Exception) { + null + } data class Input( val rawSdkRequest: WcSdkSessionRequest, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcApproveTransactionUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcApproveTransactionUM.kt deleted file mode 100644 index b8107b23c1..0000000000 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcApproveTransactionUM.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.tangem.features.walletconnect.transaction.entity.approve - -import androidx.annotation.DrawableRes -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.features.walletconnect.transaction.entity.common.WcNetworkInfoUM -import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionAppInfoContentUM -import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestInfoUM - -internal data class WcApproveTransactionUM( - val transaction: WcApproveTransactionItemUM, - val transactionRequestInfo: WcTransactionRequestInfoUM, - val customAllowance: WcCustomAllowanceUM, -) - -internal data class WcApproveTransactionItemUM( - val onDismiss: () -> Unit, - val onSend: () -> Unit, - val appInfo: WcTransactionAppInfoContentUM, - val spendAllowance: WcSpendAllowanceUM? = null, - val walletName: String, - val networkInfo: WcNetworkInfoUM, - val networkFee: String? = null, - val isLoading: Boolean = false, -) : TangemBottomSheetConfigContent - -internal data class WcCustomAllowanceUM( - @DrawableRes val networkIconRes: Int, - val tokenIconUrl: String, - val amountText: String, - val isUnlimited: Boolean, -) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcCustomAllowanceUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcCustomAllowanceUM.kt new file mode 100644 index 0000000000..28f8405259 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcCustomAllowanceUM.kt @@ -0,0 +1,11 @@ +package com.tangem.features.walletconnect.transaction.entity.approve + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +internal data class WcCustomAllowanceUM( + @DrawableRes val networkIconRes: Int, + val tokenIconUrl: String, + val amountText: String, + val isUnlimited: Boolean, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcSpendAllowanceUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcSpendAllowanceUM.kt index d3609afa63..0bdad0e725 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcSpendAllowanceUM.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcSpendAllowanceUM.kt @@ -1,6 +1,14 @@ package com.tangem.features.walletconnect.transaction.entity.approve +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference +import java.math.BigDecimal + internal data class WcSpendAllowanceUM( - val amountText: String, - val tokenImageUrl: String, -) \ No newline at end of file + val amountValue: BigDecimal, + val isUnlimited: Boolean, + val amountText: TextReference, + val tokenSymbol: String, + val tokenImageUrl: String?, + val networkIconRes: Int?, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/blockaid/WcSendReceiveTransactionCheckResultsUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/blockaid/WcSendReceiveTransactionCheckResultsUM.kt index 418d483031..096af30724 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/blockaid/WcSendReceiveTransactionCheckResultsUM.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/blockaid/WcSendReceiveTransactionCheckResultsUM.kt @@ -1,9 +1,11 @@ package com.tangem.features.walletconnect.transaction.entity.blockaid import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM internal data class WcSendReceiveTransactionCheckResultsUM( val estimatedWalletChanges: WcEstimatedWalletChangesUM? = null, + val spendAllowance: WcSpendAllowanceUM? = null, val notificationText: TextReference? = null, val isLoading: Boolean = true, ) \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/common/WcTransactionFeeState.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/common/WcTransactionFeeState.kt new file mode 100644 index 0000000000..7c38bf7f48 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/common/WcTransactionFeeState.kt @@ -0,0 +1,9 @@ +package com.tangem.features.walletconnect.transaction.entity.common + +import com.tangem.blockchain.common.transaction.Fee + +internal sealed class WcTransactionFeeState { + object Loading : WcTransactionFeeState() + data class Success(val fee: Fee) : WcTransactionFeeState() + data object None : WcTransactionFeeState() +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/send/WcSendTransactionUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/send/WcSendTransactionUM.kt index e7bfe5f339..64c1ba82d3 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/send/WcSendTransactionUM.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/send/WcSendTransactionUM.kt @@ -1,25 +1,28 @@ package com.tangem.features.walletconnect.transaction.entity.send import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcSendReceiveTransactionCheckResultsUM import com.tangem.features.walletconnect.transaction.entity.common.WcCommonTransactionUM import com.tangem.features.walletconnect.transaction.entity.common.WcNetworkInfoUM import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionAppInfoContentUM +import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionFeeState import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestInfoUM internal data class WcSendTransactionUM( val transaction: WcSendTransactionItemUM, override val transactionRequestInfo: WcTransactionRequestInfoUM, + val spendAllowance: WcSpendAllowanceUM? = null, ) : WcCommonTransactionUM internal data class WcSendTransactionItemUM( + val feeState: WcTransactionFeeState, val onDismiss: () -> Unit, val onSend: () -> Unit, val appInfo: WcTransactionAppInfoContentUM, val estimatedWalletChanges: WcSendReceiveTransactionCheckResultsUM?, val walletName: String, val networkInfo: WcNetworkInfoUM, - val networkFee: String? = null, val address: String? = null, val isLoading: Boolean = false, ) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt index 84765ddb0b..4cde43d227 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt @@ -1,17 +1,31 @@ package com.tangem.features.walletconnect.transaction.model import androidx.compose.runtime.Stable +import arrow.core.Either import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.pushNew +import com.domain.blockaid.models.transaction.ValidationResult import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.core.lce.Lce +import com.tangem.domain.models.network.Network +import com.tangem.domain.tokens.GetNetworkCoinStatusUseCase +import com.tangem.domain.tokens.error.CurrencyStatusError +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.walletconnect.WcRequestUseCaseFactory import com.tangem.domain.walletconnect.usecase.method.* +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.send.v2.api.params.FeeSelectorParams import com.tangem.features.walletconnect.connections.routing.WcInnerRoute import com.tangem.features.walletconnect.transaction.components.common.WcTransactionModelParams import com.tangem.features.walletconnect.transaction.converter.WcCommonTransactionUMConverter @@ -24,7 +38,9 @@ import com.tangem.features.walletconnect.transaction.ui.blockaid.WcSendAndReceiv import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import java.math.BigDecimal import javax.inject.Inject +import kotlin.properties.Delegates @Suppress("LongParameterList") @Stable @@ -37,6 +53,8 @@ internal class WcSendTransactionModel @Inject constructor( private val useCaseFactory: WcRequestUseCaseFactory, private val converter: WcCommonTransactionUMConverter, private val blockAidUiConverter: WcSendAndReceiveBlockAidUiConverter, + private val getFeeUseCase: GetFeeUseCase, + private val getNetworkCoinUseCase: GetNetworkCoinStatusUseCase, ) : Model(), WcCommonTransactionModel { private val params = paramsContainer.require() @@ -46,6 +64,13 @@ internal class WcSendTransactionModel @Inject constructor( val stackNavigation = StackNavigation() + internal var useCase: WcSignUseCase<*> by Delegates.notNull() + internal var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() + internal var suggestedFeeState: FeeSelectorParams.SuggestedFeeState = FeeSelectorParams.SuggestedFeeState.None + private var signState: WcSignState<*> by Delegates.notNull() + private var wcApproval: WcApproval? = null + private var sign: () -> Unit = {} + init { @Suppress("UnusedPrivateMember") modelScope.launch { @@ -56,41 +81,78 @@ internal class WcSendTransactionModel @Inject constructor( when (useCase) { is WcListTransactionUseCase, is WcTransactionUseCase, - -> combine( - useCase.invoke(), - useCase.securityStatus, - ) { signState, securityCheck -> signState to securityCheck } - .distinctUntilChanged() - .collectLatest { - val (signState, securityCheck) = it - if (signingIsDone(signState)) return@collectLatest - val signModel: Any = signState.signModel - val isMutableFee = useCase is WcMutableFee - val dAppFee = if (isMutableFee) useCase.dAppFee() else null - val selectedFee = when (signModel) { - is TransactionData.Compiled -> signModel.fee - is TransactionData.Uncompiled -> signModel.fee - else -> null + -> { + this@WcSendTransactionModel.cryptoCurrencyStatus = + getCryptoCurrencyStatus(userWallet = useCase.wallet, network = useCase.network) + .onLeft { unknownMethodRunnable() } + .getOrNull() ?: return@launch + this@WcSendTransactionModel.useCase = useCase + (useCase as? WcMutableFee) + ?.dAppFee() + ?.let { dAppFee -> + suggestedFeeState = FeeSelectorParams.SuggestedFeeState.Suggestion( + title = stringReference(useCase.session.sdkModel.appMetaData.name), + fee = dAppFee, + ) } - val isDAppFeeSelected = dAppFee != null && dAppFee == selectedFee + combine( + useCase.invoke(), + useCase.securityStatus, + ) { signState, securityCheck -> signState to securityCheck } + .distinctUntilChanged() + .collectLatest { (signState, securityCheck) -> + if (signingIsDone(signState, useCase)) return@collectLatest - val isSecurityCheckContent = securityCheck is Lce.Content - var isApprovalMethod = isSecurityCheckContent && - securityCheck.content is BlockAidTransactionCheck.Result.Approval - buildUiState(securityCheck, useCase, signState) - } + this@WcSendTransactionModel.signState = signState + val isSecurityCheckContent = securityCheck is Lce.Content + val isApprovalMethod = isSecurityCheckContent && + securityCheck.content is BlockAidTransactionCheck.Result.Approval + wcApproval = useCase as? WcApproval + sign = { useCase.sign() } + buildUiState(securityCheck, useCase, signState, isApprovalMethod) + } + } else -> unknownMethodRunnable() } } } + suspend fun loadFee(): Either { + val signModel = signState.signModel + val transactionData = signModel as? TransactionData.Uncompiled ?: error("TransactionData must be Uncompiled") + return getFeeUseCase.invoke( + userWallet = useCase.wallet, + network = useCase.network, + transactionData = transactionData, + ) + } + + private suspend fun getCryptoCurrencyStatus( + userWallet: UserWallet, + network: Network, + ): Either { + return getNetworkCoinUseCase.invokeSync( + userWalletId = userWallet.walletId, + networkId = network.id, + derivationPath = network.derivationPath, + isSingleWalletWithTokens = userWallet is UserWallet.Cold && + userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), + ) + } + private suspend fun buildUiState( securityCheck: Lce, useCase: WcSignUseCase<*>, signState: WcSignState<*>, + isApproval: Boolean, ) { val blockAidState = when (securityCheck) { - is Lce.Content -> blockAidUiConverter.convert(securityCheck.content.result) + is Lce.Content -> blockAidUiConverter.convert( + WcSendAndReceiveBlockAidUiConverter.Input( + securityCheck.content.result, + if (isApproval) wcApproval?.getAmount() else null, + ), + ) is Lce.Error -> WcSendReceiveTransactionCheckResultsUM(isLoading = false) is Lce.Loading -> WcSendReceiveTransactionCheckResultsUM(isLoading = true) } @@ -101,13 +163,14 @@ internal class WcSendTransactionModel @Inject constructor( actions = WcTransactionActionsUM( onShowVerifiedAlert = ::showVerifiedAlert, onDismiss = { cancel(useCase) }, - onSign = useCase::sign, + onSign = { onSign(securityCheck.getOrNull()) }, onCopy = { copyData(useCase.rawSdkRequest.request.params) }, ), ), ) as? WcSendTransactionUM transactionUM = transactionUM?.copy( transaction = transactionUM.transaction.copy(estimatedWalletChanges = blockAidState), + spendAllowance = blockAidState.spendAllowance, ) _uiState.emit(transactionUM) } @@ -120,18 +183,59 @@ internal class WcSendTransactionModel @Inject constructor( stackNavigation.pushNew(WcTransactionRoutes.TransactionRequestInfo) } + private fun onSign(securityCheck: BlockAidTransactionCheck.Result?) { + if (securityCheck?.result?.validation == ValidationResult.UNSAFE) { + showMaliciousAlert(securityCheck.result.description) + } else { + sign() + } + } + + fun onClickDoneCustomAllowance(value: BigDecimal, isUnlimited: Boolean) { + val newValue = if (isUnlimited) null else value + wcApproval?.getAmount()?.let { currentAmount -> + wcApproval?.updateAmount(currentAmount.copy(amount = currentAmount.amount?.copy(value = newValue))) + } + } + + fun onClickAllowToSpend() { + wcApproval?.let { stackNavigation.pushNew(WcTransactionRoutes.CustomAllowance) } + } + private fun showVerifiedAlert(appName: String) { stackNavigation.pushNew(WcTransactionRoutes.Alert(WcTransactionRoutes.Alert.Type.Verified(appName))) } - private fun signingIsDone(signState: WcSignState<*>): Boolean { + private fun showMaliciousAlert(description: String?) { + val type = WcTransactionRoutes.Alert.Type.MaliciousInfo(description = description, onClick = ::signFromAlert) + stackNavigation.pushNew(WcTransactionRoutes.Alert(type)) + } + + private fun signFromAlert() { + stackNavigation.pop() + sign() + } + + private fun signingIsDone(signState: WcSignState<*>, useCase: WcSignUseCase<*>): Boolean { (signState.domainStep as? WcSignStep.Result)?.result?.let { - router.pop() + handleSigningError(it, useCase) return true } return false } + private fun handleSigningError(result: Either, useCase: WcSignUseCase<*>) { + if (result.isLeft()) { + val error = WcTransactionRoutes.Alert.Type.UnknownError( + errorMessage = result.leftOrNull()?.message, + onDismiss = { cancel(useCase) }, + ) + stackNavigation.pushNew(WcTransactionRoutes.Alert(error)) + } else { + router.pop() + } + } + private fun cancel(useCase: WcSignUseCase<*>) { useCase.cancel() router.pop() diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/routes/WcTransactionRoutes.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/routes/WcTransactionRoutes.kt index b762172be6..066a19eebc 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/routes/WcTransactionRoutes.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/routes/WcTransactionRoutes.kt @@ -14,6 +14,9 @@ internal sealed class WcTransactionRoutes : TangemBottomSheetConfigContent, Rout @Serializable data object TransactionRequestInfo : WcTransactionRoutes() + @Serializable + data object CustomAllowance : WcTransactionRoutes() + @Serializable data class Alert(val type: Type) : WcTransactionRoutes() { @Serializable @@ -21,6 +24,8 @@ internal sealed class WcTransactionRoutes : TangemBottomSheetConfigContent, Rout data class Verified(val appName: String) : Type() data object UnknownDomain : Type() data object UnsafeDomain : Type() + data class MaliciousInfo(val description: String?, val onClick: () -> Unit) : Type() + data class UnknownError(val errorMessage: String?, val onDismiss: () -> Unit) : Type() } } } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcApproveTransactionModalBottomSheet.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcApproveTransactionModalBottomSheet.kt deleted file mode 100644 index 6e1ffd76e9..0000000000 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcApproveTransactionModalBottomSheet.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.tangem.features.walletconnect.transaction.ui.approve - -import android.content.res.Configuration -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet -import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle -import com.tangem.core.ui.components.divider.DividerWithPadding -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.walletconnect.connections.entity.VerifiedDAppState -import com.tangem.features.walletconnect.connections.ui.WcAppInfoItem -import com.tangem.features.walletconnect.impl.R -import com.tangem.features.walletconnect.transaction.entity.approve.WcApproveTransactionItemUM -import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM -import com.tangem.features.walletconnect.transaction.entity.common.WcNetworkInfoUM -import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionAppInfoContentUM -import com.tangem.features.walletconnect.transaction.ui.common.* - -@Composable -internal fun WcApproveTransactionModalBottomSheetContent( - state: WcApproveTransactionItemUM, - onClickTransactionRequest: () -> Unit, -) { - Column( - modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16), - ) { - Column( - modifier = Modifier - .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) - .background(color = TangemTheme.colors.background.action) - .fillMaxWidth() - .animateContentSize(), - ) { - WcSmallTitleItem(R.string.wc_request_from) - WcAppInfoItem( - iconUrl = state.appInfo.appIcon, - title = state.appInfo.appName, - subtitle = state.appInfo.appSubtitle, - verifiedDAppState = state.appInfo.verifiedState, - ) - DividerWithPadding(start = 0.dp, end = 0.dp) - WcTransactionRequestItem( - iconRes = R.drawable.ic_doc_new_24, - modifier = Modifier - .fillMaxWidth() - .clickable { onClickTransactionRequest() } - .padding(TangemTheme.dimens.spacing12), - ) - } - Column(modifier = Modifier.padding(top = TangemTheme.dimens.spacing16)) { - if (state.spendAllowance != null) { - WcSpendAllowanceItem(state.spendAllowance) - Spacer(Modifier.height(TangemTheme.dimens.spacing16)) - } - WcSendTransactionItems( - walletName = state.walletName, - networkInfo = state.networkInfo, - networkFee = state.networkFee, - ) - WcTransactionRequestButtons( - modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing16), - onDismiss = state.onDismiss, - onClickActiveButton = state.onSend, - activeButtonText = resourceReference(R.string.common_send), - isLoading = state.isLoading, - ) - } - } -} - -@Composable -@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) -@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun WcApproveTransactionBottomSheetPreview( - @PreviewParameter(WcApproveTransactionStateProvider::class) state: WcApproveTransactionItemUM, -) { - TangemThemePreview { - TangemModalBottomSheet( - config = TangemBottomSheetConfig( - isShown = true, - onDismissRequest = {}, - content = state, - ), - containerColor = TangemTheme.colors.background.tertiary, - title = { - TangemModalBottomSheetTitle( - title = resourceReference(R.string.wallet_connect_title), - endIconRes = R.drawable.ic_close_24, - onEndClick = {}, - startIconRes = null, - onStartClick = {}, - ) - }, - content = { - WcApproveTransactionModalBottomSheetContent(state = state, onClickTransactionRequest = {}) - }, - ) - } -} - -private class WcApproveTransactionStateProvider : CollectionPreviewParameterProvider( - listOf( - WcApproveTransactionItemUM( - onDismiss = {}, - onSend = {}, - appInfo = WcTransactionAppInfoContentUM( - appName = "React App", - appIcon = "", - verifiedState = VerifiedDAppState.Verified {}, - appSubtitle = "react-app.walletconnect.com", - ), - spendAllowance = WcSpendAllowanceUM(amountText = "Unlimited USDT", tokenImageUrl = ""), - walletName = "Tangem 2.0", - networkInfo = WcNetworkInfoUM(name = "Ethereum", iconRes = R.drawable.img_eth_22), - networkFee = "~ 0.22 $", - ), - ), -) \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcCustomAllowanceContent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcCustomAllowanceContent.kt index 08f144454d..ffb2eea5a6 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcCustomAllowanceContent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcCustomAllowanceContent.kt @@ -12,7 +12,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -23,91 +23,126 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.TangemSwitch import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.walletconnect.impl.R -import com.tangem.features.walletconnect.transaction.entity.approve.WcCustomAllowanceUM +import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM +import java.math.BigDecimal @Composable internal fun WcCustomAllowanceContent( - allowance: WcCustomAllowanceUM, - onAmountChange: (String) -> Unit, - onToggleChange: (Boolean) -> Unit, - onClickDone: () -> Unit, - modifier: Modifier = Modifier, + state: WcSpendAllowanceUM, + onClickDone: (BigDecimal, Boolean) -> Unit, + onBack: () -> Unit, + onDismiss: () -> Unit, ) { - Column( - modifier = modifier - .background(TangemTheme.colors.background.tertiary) - .fillMaxWidth() - .padding(TangemTheme.dimens.size16), - ) { - AmountTextField( - allowance = allowance, - onAmountChange = onAmountChange, - ) + var amountText by remember { mutableStateOf(state.amountValue.toPlainString()) } + var isUnlimited by remember { mutableStateOf(state.isUnlimited) } - Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) - .background(color = TangemTheme.colors.background.action) - .padding(TangemTheme.dimens.spacing16) - .fillMaxWidth(), - ) { - Text( - text = stringResourceSafe(R.string.wc_unlimited_amount), - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.primary1, - modifier = Modifier.weight(1f), - ) - TangemSwitch( - checked = allowance.isUnlimited, - onCheckedChange = onToggleChange, - ) - } - - Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing60)) - - PrimaryButton( - modifier = Modifier.fillMaxWidth(), - text = stringResourceSafe(R.string.common_done), - onClick = onClickDone, - ) + val amount: BigDecimal by remember(amountText) { + mutableStateOf(amountText.toBigDecimalOrNull() ?: state.amountValue) } + + TangemModalBottomSheetWithFooter( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors.background.tertiary, + onBack = onBack, + title = { + TangemModalBottomSheetTitle( + title = resourceReference(R.string.wc_custom_allowance_title), + startIconRes = R.drawable.ic_back_24, + onStartClick = onBack, + ) + }, + content = { + Column( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .fillMaxWidth() + .padding(16.dp), + ) { + AmountTextField( + amountText = amountText, + onAmountChange = { amountText = it }, + isEnabled = !isUnlimited, + tokenImageUrl = state.tokenImageUrl, + networkIconRes = state.networkIconRes, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(14.dp)) + .background(TangemTheme.colors.background.action) + .padding(16.dp) + .fillMaxWidth(), + ) { + Text( + text = stringResourceSafe(R.string.wc_unlimited_amount), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.weight(1f), + ) + TangemSwitch( + checked = isUnlimited, + onCheckedChange = { isUnlimited = it }, + ) + } + + Spacer(modifier = Modifier.height(60.dp)) + } + }, + footer = { + PrimaryButton( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + text = stringResourceSafe(R.string.common_done), + onClick = { onClickDone(amount, isUnlimited) }, + ) + }, + ) } @Composable private fun AmountTextField( - allowance: WcCustomAllowanceUM, + amountText: String, onAmountChange: (String) -> Unit, + isEnabled: Boolean, + tokenImageUrl: String?, + @DrawableRes networkIconRes: Int?, modifier: Modifier = Modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier - .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) - .background(color = TangemTheme.colors.background.action) - .padding( - end = TangemTheme.dimens.size8, - top = TangemTheme.dimens.size4, - bottom = TangemTheme.dimens.size4, - ) + .clip(RoundedCornerShape(14.dp)) + .background(TangemTheme.colors.background.action) + .padding(end = 8.dp, top = 4.dp, bottom = 4.dp) .fillMaxWidth(), ) { TextField( - value = allowance.amountText, + value = amountText, onValueChange = onAmountChange, + enabled = isEnabled, label = { Text( text = stringResourceSafe(R.string.send_amount_label), @@ -127,6 +162,9 @@ private fun AmountTextField( unfocusedTextColor = TangemTheme.colors.text.primary1, focusedPlaceholderColor = TangemTheme.colors.text.disabled, unfocusedPlaceholderColor = TangemTheme.colors.text.disabled, + disabledContainerColor = Color.Transparent, + disabledTextColor = TangemTheme.colors.text.disabled, + disabledIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, cursorColor = TangemTheme.colors.icon.primary1, @@ -134,40 +172,44 @@ private fun AmountTextField( ) TokenWithNetworkIcon( - tokenIconUrl = allowance.tokenIconUrl, - networkIconRes = allowance.networkIconRes, + tokenIconUrl = tokenImageUrl, + networkIconRes = networkIconRes, ) } } @Composable private fun TokenWithNetworkIcon( - tokenIconUrl: String, - @DrawableRes networkIconRes: Int, + tokenIconUrl: String?, + @DrawableRes networkIconRes: Int?, modifier: Modifier = Modifier, ) { Box( - modifier = modifier - .size(TangemTheme.dimens.size48), + modifier = modifier.size(48.dp), ) { AsyncImage( + model = tokenIconUrl, + contentDescription = null, modifier = Modifier .aspectRatio(1f) .clip(CircleShape) .align(Alignment.Center) - .padding(TangemTheme.dimens.spacing8), - model = tokenIconUrl, - contentDescription = null, - ) - Image( - modifier = Modifier - .align(Alignment.TopEnd) - .size(TangemTheme.dimens.size18) - .border(TangemTheme.dimens.size2, TangemTheme.colors.background.action, CircleShape) - .padding(TangemTheme.dimens.spacing2), - painter = painterResource(id = networkIconRes), - contentDescription = null, + .padding(8.dp), + placeholder = painterResource(R.drawable.ic_nft_placeholder_20), + error = painterResource(R.drawable.ic_nft_placeholder_20), ) + + if (networkIconRes != null) { + Image( + modifier = Modifier + .align(Alignment.TopEnd) + .size(18.dp) + .border(2.dp, TangemTheme.colors.background.action, CircleShape) + .padding(2.dp), + painter = painterResource(networkIconRes), + contentDescription = null, + ) + } } } @@ -175,10 +217,10 @@ private fun TokenWithNetworkIcon( @Preview(showBackground = true, device = Devices.PIXEL_7_PRO) @Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES) internal fun PreviewWcCustomAllowanceContent( - @PreviewParameter(WcCustomAllowanceStateProvider::class) state: WcCustomAllowanceUM, + @PreviewParameter(WcCustomAllowanceStateProvider::class) state: WcSpendAllowanceUM, ) { TangemThemePreview { - TangemModalBottomSheet( + TangemModalBottomSheet( config = TangemBottomSheetConfig( isShown = true, onDismissRequest = {}, @@ -196,22 +238,24 @@ internal fun PreviewWcCustomAllowanceContent( }, content = { WcCustomAllowanceContent( - allowance = state, - onAmountChange = {}, - onToggleChange = {}, - onClickDone = {}, + state = state, + onClickDone = { _, _ -> }, + onBack = {}, + onDismiss = {}, ) }, ) } } -private class WcCustomAllowanceStateProvider : CollectionPreviewParameterProvider( +private class WcCustomAllowanceStateProvider : CollectionPreviewParameterProvider( listOf( - WcCustomAllowanceUM( + WcSpendAllowanceUM( networkIconRes = R.drawable.img_eth_22, - tokenIconUrl = "https://tangem.com", - amountText = "100", + tokenImageUrl = "https://tangem.com", + amountText = TextReference.Str("100"), + amountValue = BigDecimal("100"), + tokenSymbol = "ETH", isUnlimited = false, ), ), diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcSpendAllowanceItem.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcSpendAllowanceItem.kt index 2c1322217f..92ec4f6e17 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcSpendAllowanceItem.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcSpendAllowanceItem.kt @@ -2,6 +2,7 @@ package com.tangem.features.walletconnect.transaction.ui.approve import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -13,27 +14,31 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import com.tangem.features.walletconnect.impl.R import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM import com.tangem.features.walletconnect.transaction.ui.common.WcSmallTitleItem @Composable -internal fun WcSpendAllowanceItem(spendAllowance: WcSpendAllowanceUM, modifier: Modifier = Modifier) { +internal fun WcSpendAllowanceItem( + spendAllowance: WcSpendAllowanceUM, + onClickAllowToSpend: () -> Unit, + modifier: Modifier = Modifier, +) { Column( modifier = modifier - .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .clip(RoundedCornerShape(14.dp)) .background(TangemTheme.colors.background.action) .fillMaxWidth() - .padding( - end = TangemTheme.dimens.spacing12, - bottom = TangemTheme.dimens.spacing12, - ), + .padding(end = 12.dp, bottom = 12.dp) + .clickable { onClickAllowToSpend() }, ) { WcSmallTitleItem(R.string.wc_allow_to_spend) - Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) + Spacer(modifier = Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically, @@ -46,15 +51,16 @@ internal fun WcSpendAllowanceItem(spendAllowance: WcSpendAllowanceUM, modifier: AsyncImage( model = spendAllowance.tokenImageUrl, modifier = Modifier - .size(TangemTheme.dimens.size24) + .padding(start = 12.dp) + .size(24.dp) .clip(CircleShape), contentDescription = null, ) - Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing12)) + Spacer(modifier = Modifier.width(8.dp)) AnimatedContent( - targetState = spendAllowance.amountText, + targetState = "${spendAllowance.amountText.resolveReference()} ${spendAllowance.tokenSymbol}", label = "Animate Spend allowance text", ) { amount -> Text( @@ -75,7 +81,7 @@ internal fun WcSpendAllowanceItem(spendAllowance: WcSpendAllowanceUM, modifier: color = TangemTheme.colors.text.tertiary, ) - Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing8)) + Spacer(modifier = Modifier.width(8.dp)) Icon( painter = painterResource(id = R.drawable.ic_edit_new_12), diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/TransactionCheckResultsItem.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/TransactionCheckResultsItem.kt index 6a8cb9829e..0ff92d4802 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/TransactionCheckResultsItem.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/TransactionCheckResultsItem.kt @@ -19,12 +19,15 @@ import com.tangem.features.walletconnect.impl.R import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangeUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangesUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcSendReceiveTransactionCheckResultsUM +import com.tangem.features.walletconnect.transaction.ui.approve.WcSpendAllowanceItem import kotlinx.collections.immutable.persistentListOf @Composable -internal fun TransactionCheckResultsItem(item: WcSendReceiveTransactionCheckResultsUM, modifier: Modifier = Modifier) { - if (item.notificationText == null && item.estimatedWalletChanges == null) return - +internal fun TransactionCheckResultsItem( + item: WcSendReceiveTransactionCheckResultsUM, + onClickAllowToSpend: () -> Unit, + modifier: Modifier = Modifier, +) { Column( modifier = modifier .fillMaxWidth(), @@ -38,6 +41,8 @@ internal fun TransactionCheckResultsItem(item: WcSendReceiveTransactionCheckResu } if (item.estimatedWalletChanges != null) { WcEstimatedWalletChangesItem(item.estimatedWalletChanges) + } else if (item.spendAllowance != null) { + WcSpendAllowanceItem(item.spendAllowance, onClickAllowToSpend) } else { WcEstimatedWalletChangesNotLoadedItem() } @@ -56,7 +61,7 @@ private fun TransactionCheckResultsItemPreview( modifier = Modifier .background(TangemTheme.colors.background.tertiary), ) { - TransactionCheckResultsItem(item = item) + TransactionCheckResultsItem(item = item, {}) } } } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangeRow.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangeRow.kt index e4dcdea2ea..6e3dc9a87d 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangeRow.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangeRow.kt @@ -63,7 +63,7 @@ internal fun WcEstimatedWalletChangeRow(item: WcEstimatedWalletChangeUM, modifie } } - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(8.dp)) AsyncImage( model = item.tokenIconUrl, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangeUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangeUMConverter.kt new file mode 100644 index 0000000000..c51cc78fbb --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangeUMConverter.kt @@ -0,0 +1,40 @@ +package com.tangem.features.walletconnect.transaction.ui.blockaid + +import com.domain.blockaid.models.transaction.simultation.AmountInfo +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangeUM +import com.tangem.utils.converter.Converter +import java.math.BigDecimal +import javax.inject.Inject + +internal class WcEstimatedWalletChangeUMConverter @Inject constructor() : + Converter { + + override fun convert(value: Input): WcEstimatedWalletChangeUM { + return when (val amountInfo = value.amountInfo) { + is AmountInfo.FungibleTokens -> WcEstimatedWalletChangeUM( + iconRes = value.iconRes, + title = resourceReference(value.titleRes), + description = "${value.sign} ${amountInfo.amount.amountText()} ${amountInfo.token.symbol}", + tokenIconUrl = amountInfo.token.logoUrl, + ) + is AmountInfo.NonFungibleTokens -> WcEstimatedWalletChangeUM( + iconRes = value.iconRes, + title = resourceReference(value.titleRes), + description = amountInfo.name, + tokenIconUrl = amountInfo.logoUrl, + ) + } + } + + private fun BigDecimal.amountText() = format { crypto("", DECIMALS_AMOUNT) } + + data class Input( + val amountInfo: AmountInfo, + val iconRes: Int, + val titleRes: Int, + val sign: String? = null, + ) +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangesItem.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangesItem.kt index fb730d52bb..25e8b53eba 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangesItem.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangesItem.kt @@ -4,6 +4,7 @@ import android.content.res.Configuration import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background 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.material3.Icon @@ -68,9 +69,14 @@ internal fun WcEstimatedWalletChangesItem(item: WcEstimatedWalletChangesUM, modi } if (item.items.size > MAX_CHANGES_SIZE) { + val interactionSource = remember { MutableInteractionSource() } Row( modifier = Modifier - .clickable { isExpanded = !isExpanded } + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { isExpanded = !isExpanded }, + ) .padding(bottom = 12.dp, start = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangesLoadingItem.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangesLoadingItem.kt index abd604faa3..6fee1c04e0 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangesLoadingItem.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangesLoadingItem.kt @@ -36,7 +36,7 @@ internal fun WcEstimatedWalletChangesLoadingItem(modifier: Modifier = Modifier) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 14.dp), + modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 14.dp), ) { CircularProgressIndicator( color = TangemTheme.colors.icon.accent, @@ -45,7 +45,8 @@ internal fun WcEstimatedWalletChangesLoadingItem(modifier: Modifier = Modifier) ) Text( modifier = modifier - .fillMaxWidth(), + .fillMaxWidth() + .padding(start = 8.dp), text = stringResourceSafe(R.string.wc_common_loading), color = TangemTheme.colors.text.disabled, style = TangemTheme.typography.body2, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangesNotLoadedItem.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangesNotLoadedItem.kt index 04671ce12c..de2a38ce6a 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangesNotLoadedItem.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcEstimatedWalletChangesNotLoadedItem.kt @@ -35,7 +35,7 @@ internal fun WcEstimatedWalletChangesNotLoadedItem(modifier: Modifier = Modifier Text( modifier = modifier .fillMaxWidth() - .padding(start = 12.dp, bottom = 14.dp), + .padding(start = 12.dp, end = 12.dp, bottom = 14.dp), text = stringResourceSafe(R.string.wc_estimated_wallet_changes_not_simulated), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.body2, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSendAndReceiveBlockAidUiConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSendAndReceiveBlockAidUiConverter.kt index 89890d3b27..aa9eb45eb3 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSendAndReceiveBlockAidUiConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSendAndReceiveBlockAidUiConverter.kt @@ -6,54 +6,74 @@ import com.domain.blockaid.models.transaction.ValidationResult import com.domain.blockaid.models.transaction.simultation.AmountInfo import com.domain.blockaid.models.transaction.simultation.SimulationData import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.walletconnect.model.WcApprovedAmount import com.tangem.features.walletconnect.impl.R -import com.tangem.features.walletconnect.transaction.entity.blockaid.WcSendReceiveTransactionCheckResultsUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangeUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangesUM +import com.tangem.features.walletconnect.transaction.entity.blockaid.WcSendReceiveTransactionCheckResultsUM +import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal import javax.inject.Inject -private const val DECIMALS_AMOUNT = 2 +internal const val DECIMALS_AMOUNT = 2 -internal class WcSendAndReceiveBlockAidUiConverter @Inject constructor() : - Converter { - override fun convert(value: CheckTransactionResult): WcSendReceiveTransactionCheckResultsUM { +@Suppress("CyclomaticComplexMethod") +internal class WcSendAndReceiveBlockAidUiConverter @Inject constructor( + private val estimatedWalletChangeUMConverter: WcEstimatedWalletChangeUMConverter, + private val spendAllowanceUMConverter: WcSpendAllowanceUMConverter, +) : Converter { + override fun convert(value: Input): WcSendReceiveTransactionCheckResultsUM { + val description = value.result.description return WcSendReceiveTransactionCheckResultsUM( isLoading = false, - notificationText = when (value.validation) { + notificationText = when (value.result.validation) { ValidationResult.SAFE, ValidationResult.FAILED_TO_VALIDATE -> null - ValidationResult.UNSAFE -> TextReference.Str("") // TODO("Add text after approve with designer") + ValidationResult.UNSAFE -> if (!description.isNullOrEmpty()) TextReference.Str(description) else null }, - estimatedWalletChanges = (value.simulation as? SimulationResult.Success)?.data?.let { data -> + estimatedWalletChanges = (value.result.simulation as? SimulationResult.Success)?.data?.let { data -> when (data) { is SimulationData.Approve -> null is SimulationData.SendAndReceive -> { val items: ImmutableList = ( data.send.map { when (it) { - is AmountInfo.FungibleTokens -> it.toUM( - iconRes = R.drawable.ic_send_new_24, - titleRes = R.string.common_send, + is AmountInfo.FungibleTokens -> estimatedWalletChangeUMConverter.convert( + WcEstimatedWalletChangeUMConverter.Input( + amountInfo = it, + iconRes = R.drawable.ic_receive_new_24, + titleRes = R.string.common_send, + sign = StringsSigns.MINUS, + ), ) - is AmountInfo.NonFungibleTokens -> it.toUM( - iconRes = R.drawable.ic_send_new_24, - titleRes = R.string.common_send, + is AmountInfo.NonFungibleTokens -> estimatedWalletChangeUMConverter.convert( + WcEstimatedWalletChangeUMConverter.Input( + amountInfo = it, + iconRes = R.drawable.ic_send_new_24, + titleRes = R.string.common_send, + ), ) } } + data.receive.map { when (it) { - is AmountInfo.FungibleTokens -> it.toUM( - iconRes = R.drawable.ic_receive_new_24, - titleRes = R.string.common_receive, + is AmountInfo.FungibleTokens -> estimatedWalletChangeUMConverter.convert( + WcEstimatedWalletChangeUMConverter.Input( + amountInfo = it, + iconRes = R.drawable.ic_receive_new_24, + titleRes = R.string.common_receive, + sign = StringsSigns.PLUS, + ), ) - is AmountInfo.NonFungibleTokens -> it.toUM( - iconRes = R.drawable.ic_receive_new_24, - titleRes = R.string.common_receive, + is AmountInfo.NonFungibleTokens -> estimatedWalletChangeUMConverter.convert( + WcEstimatedWalletChangeUMConverter.Input( + amountInfo = it, + iconRes = R.drawable.ic_receive_new_24, + titleRes = R.string.common_receive, + ), ) } } @@ -62,25 +82,21 @@ internal class WcSendAndReceiveBlockAidUiConverter @Inject constructor() : } } }, + spendAllowance = (value.result.simulation as? SimulationResult.Success)?.data?.let { data -> + when (data) { + is SimulationData.SendAndReceive -> null + is SimulationData.Approve -> value.approvedAmount?.let { + spendAllowanceUMConverter.convert(it) + } + } + }, ) } -} -private fun AmountInfo.FungibleTokens.toUM(iconRes: Int, titleRes: Int): WcEstimatedWalletChangeUM { - val amountText = amount.format { crypto(token.symbol, DECIMALS_AMOUNT) } - return WcEstimatedWalletChangeUM( - iconRes = iconRes, - title = resourceReference(titleRes), - description = "- $amountText", - tokenIconUrl = token.logoUrl, + data class Input( + val result: CheckTransactionResult, + val approvedAmount: WcApprovedAmount?, ) } -private fun AmountInfo.NonFungibleTokens.toUM(iconRes: Int, titleRes: Int): WcEstimatedWalletChangeUM { - return WcEstimatedWalletChangeUM( - iconRes = iconRes, - title = resourceReference(titleRes), - description = name, - tokenIconUrl = logoUrl, - ) -} \ No newline at end of file +internal fun BigDecimal.amountText() = format { crypto("", DECIMALS_AMOUNT) } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt new file mode 100644 index 0000000000..4f7bf34268 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.features.walletconnect.transaction.ui.blockaid + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.domain.walletconnect.model.WcApprovedAmount +import com.tangem.features.walletconnect.impl.R +import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM +import com.tangem.utils.converter.Converter +import javax.inject.Inject + +internal class WcSpendAllowanceUMConverter @Inject constructor() : Converter { + + override fun convert(value: WcApprovedAmount): WcSpendAllowanceUM { + val amount = value.amount?.value ?: 0.0.toBigDecimal() + val isUnlimited = value.amount?.value == null + return WcSpendAllowanceUM( + amountValue = amount, + amountText = if (value.amount?.value == null) { + TextReference.Res(R.string.wc_common_unlimited) + } else { + TextReference.Str(amount.amountText()) + }, + isUnlimited = isUnlimited, + tokenSymbol = value.amount?.currencySymbol ?: "", + tokenImageUrl = value.logoUrl, + networkIconRes = value.chainId?.toString()?.let { getActiveIconRes(it) }, + ) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcNetworkFeeItem.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcNetworkFeeItem.kt deleted file mode 100644 index df6080b3e4..0000000000 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcNetworkFeeItem.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.tangem.features.walletconnect.transaction.ui.common - -import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -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 com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.walletconnect.impl.R - -@Composable -internal fun WcNetworkFeeItem(networkFeeText: String, modifier: Modifier = Modifier) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - modifier = Modifier.size(TangemTheme.dimens.size24), - painter = painterResource(R.drawable.ic_fee_new_24), - contentDescription = null, - tint = TangemTheme.colors.icon.accent, - ) - - Text( - modifier = Modifier.padding(start = TangemTheme.dimens.spacing8), - text = stringResourceSafe(R.string.common_network_fee_title), - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.primary1, - ) - - Icon( - modifier = Modifier - .padding(start = TangemTheme.dimens.spacing6) - .size(TangemTheme.dimens.size16), - painter = painterResource(id = R.drawable.ic_information_24), - tint = TangemTheme.colors.icon.informative, - contentDescription = null, - ) - - Spacer(modifier = Modifier.weight(1f)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - ) { - AnimatedContent( - targetState = networkFeeText, - label = "Animate Network fee text", - ) { fee -> - Text( - text = fee, - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.tertiary, - ) - } - Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing6)) - Icon( - modifier = Modifier - .clip(CircleShape) - .size(width = TangemTheme.dimens.size18, height = TangemTheme.dimens.size24), - painter = painterResource(id = R.drawable.ic_select_18_24), - tint = TangemTheme.colors.icon.informative, - contentDescription = null, - ) - } - } -} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcSendTransactionItems.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcSendTransactionItems.kt index 0767feb930..e0495da0ba 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcSendTransactionItems.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcSendTransactionItems.kt @@ -12,14 +12,17 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.divider.DividerWithPadding import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.send.v2.api.FeeSelectorBlockComponent import com.tangem.features.walletconnect.transaction.entity.common.WcNetworkInfoUM +import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionFeeState @Composable internal fun WcSendTransactionItems( walletName: String, networkInfo: WcNetworkInfoUM, + feeState: WcTransactionFeeState, + feeSelectorBlockComponent: FeeSelectorBlockComponent, modifier: Modifier = Modifier, - networkFee: String? = null, address: String? = null, ) { Column( @@ -50,12 +53,9 @@ internal fun WcSendTransactionItems( addressText = address, ) } - if (!networkFee.isNullOrEmpty()) { - DividerWithPadding(start = TangemTheme.dimens.spacing40, end = TangemTheme.dimens.spacing12) - WcNetworkFeeItem( - modifier = itemsModifier, - networkFeeText = networkFee, - ) + DividerWithPadding(start = 40.dp, end = 12.dp) + if (feeState != WcTransactionFeeState.None) { + feeSelectorBlockComponent.Content(Modifier) } } } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcSmallTitleItem.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcSmallTitleItem.kt index 501084213c..5cff971145 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcSmallTitleItem.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcSmallTitleItem.kt @@ -15,7 +15,7 @@ fun WcSmallTitleItem(@StringRes textRex: Int, modifier: Modifier = Modifier) { Text( modifier = modifier .fillMaxWidth() - .padding(top = 12.dp, start = 12.dp), + .padding(top = 12.dp, start = 12.dp, end = 12.dp), text = stringResourceSafe(textRex), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt index f6a2782b93..0c56447574 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt @@ -24,14 +24,17 @@ import com.tangem.core.ui.extensions.TextReference 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.send.v2.api.FeeSelectorBlockComponent import com.tangem.features.walletconnect.connections.entity.VerifiedDAppState import com.tangem.features.walletconnect.connections.ui.WcAppInfoItem import com.tangem.features.walletconnect.impl.R +import com.tangem.features.walletconnect.transaction.components.PreviewFeeSelectorBlockComponent import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangeUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangesUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcSendReceiveTransactionCheckResultsUM import com.tangem.features.walletconnect.transaction.entity.common.WcNetworkInfoUM import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionAppInfoContentUM +import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionFeeState import com.tangem.features.walletconnect.transaction.entity.send.WcSendTransactionItemUM import com.tangem.features.walletconnect.transaction.ui.blockaid.TransactionCheckResultsItem import com.tangem.features.walletconnect.transaction.ui.common.WcSendTransactionItems @@ -40,12 +43,15 @@ import com.tangem.features.walletconnect.transaction.ui.common.WcTransactionRequ import com.tangem.features.walletconnect.transaction.ui.common.WcTransactionRequestItem import kotlinx.collections.immutable.persistentListOf +@Suppress("LongParameterList", "LongMethod") @Composable internal fun WcSendTransactionModalBottomSheet( state: WcSendTransactionItemUM, + feeSelectorBlockComponent: FeeSelectorBlockComponent, onClickTransactionRequest: () -> Unit, onBack: () -> Unit, onDismiss: () -> Unit, + onClickAllowToSpend: () -> Unit, ) { TangemModalBottomSheetWithFooter( config = TangemBottomSheetConfig( @@ -92,13 +98,14 @@ internal fun WcSendTransactionModalBottomSheet( } Column(modifier = Modifier.padding(top = 16.dp)) { if (state.estimatedWalletChanges != null) { - TransactionCheckResultsItem(state.estimatedWalletChanges) + TransactionCheckResultsItem(state.estimatedWalletChanges, onClickAllowToSpend) } Spacer(Modifier.height(16.dp)) WcSendTransactionItems( walletName = state.walletName, networkInfo = state.networkInfo, - networkFee = state.networkFee, + feeState = state.feeState, + feeSelectorBlockComponent = feeSelectorBlockComponent, address = state.address, ) } @@ -140,7 +147,14 @@ private fun WcSendTransactionBottomSheetPreview( ) }, content = { - WcSendTransactionModalBottomSheet(state, {}, {}, {}) + WcSendTransactionModalBottomSheet( + state = state, + feeSelectorBlockComponent = PreviewFeeSelectorBlockComponent(), + onClickTransactionRequest = {}, + onBack = {}, + onDismiss = {}, + onClickAllowToSpend = {}, + ) }, ) } @@ -180,7 +194,7 @@ private class WcSendTransactionStateProvider : CollectionPreviewParameterProvide ), walletName = "Tangem 2.0", networkInfo = WcNetworkInfoUM(name = "Ethereum", iconRes = R.drawable.img_eth_22), - networkFee = "~ 0.22 $", + feeState = WcTransactionFeeState.Loading, address = "0x345FF...34FA", ), ), diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/sign/WcSignTransactionModalBottomSheetContent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/sign/WcSignTransactionModalBottomSheetContent.kt index 3791a02ae4..80e3c3712f 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/sign/WcSignTransactionModalBottomSheetContent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/sign/WcSignTransactionModalBottomSheetContent.kt @@ -101,9 +101,9 @@ internal fun WcSignTransactionModalBottomSheetContent( } @Composable -private fun WcSignTransactionItems(state: WcSignTransactionItemUM) { +private fun WcSignTransactionItems(state: WcSignTransactionItemUM, modifier: Modifier = Modifier) { Column( - modifier = Modifier + modifier = modifier .clip(RoundedCornerShape(14.dp)) .background(color = TangemTheme.colors.background.action) .fillMaxWidth() @@ -130,6 +130,7 @@ private fun WcSignTransactionItems(state: WcSignTransactionItemUM) { addressText = state.addressText, ) } + DividerWithPadding(start = 40.dp, end = 12.dp) } } @@ -157,7 +158,12 @@ private fun WcSignTransactionBottomSheetPreview( ) }, content = { - WcSignTransactionModalBottomSheetContent(state, {}, {}, {}) + WcSignTransactionModalBottomSheetContent( + state = state, + onClickTransactionRequest = {}, + onBack = {}, + onDismiss = {}, + ) }, ) } diff --git a/features/welcome/api/.gitignore b/features/welcome/api/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/welcome/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/welcome/api/build.gradle.kts b/features/welcome/api/build.gradle.kts new file mode 100644 index 0000000000..92d3e16d53 --- /dev/null +++ b/features/welcome/api/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.welcome.api" +} + +dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + implementation(projects.common.routing) + + /** Domain models */ + implementation(projects.domain.wallets.models) + + /** Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/welcome/api/src/main/kotlin/com/tangem/features/welcome/WelcomeComponent.kt b/features/welcome/api/src/main/kotlin/com/tangem/features/welcome/WelcomeComponent.kt new file mode 100644 index 0000000000..50b914548b --- /dev/null +++ b/features/welcome/api/src/main/kotlin/com/tangem/features/welcome/WelcomeComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.welcome + +import com.tangem.common.routing.entity.SerializableIntent +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface WelcomeComponent : ComposableContentComponent { + + data class Params( + val intent: SerializableIntent?, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/welcome/impl/.gitignore b/features/welcome/impl/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/welcome/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/fee-selector/impl/build.gradle.kts b/features/welcome/impl/build.gradle.kts similarity index 74% rename from features/fee-selector/impl/build.gradle.kts rename to features/welcome/impl/build.gradle.kts index 90c8927b9b..499c0ab82e 100644 --- a/features/fee-selector/impl/build.gradle.kts +++ b/features/welcome/impl/build.gradle.kts @@ -8,50 +8,50 @@ plugins { } android { - namespace = "com.tangem.features.feeselector.impl" + namespace = "com.tangem.features.welcome.impl" } dependencies { - /** Project - API */ - implementation(projects.features.feeSelector.api) + implementation(projects.features.welcome.api) - /** Project - Core */ - implementation(projects.common.ui) - implementation(projects.common.routing) + /** Core */ + implementation(projects.core.configToggles) implementation(projects.core.decompose) implementation(projects.core.ui) - implementation(projects.core.pagination) - implementation(projects.core.navigation) + implementation(projects.common.routing) + implementation(projects.common.ui) - /** Project - Domain */ + /** Domain models */ implementation(projects.domain.appCurrency.models) implementation(projects.domain.models) - implementation(projects.domain.tokens) implementation(projects.domain.tokens.models) - implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) - /** AndroidX */ - implementation(deps.androidx.activity.compose) - implementation(deps.lifecycle.compose) - - /** Compose */ - implementation(deps.compose.ui) - implementation(deps.compose.ui.tooling) - implementation(deps.compose.foundation) - implementation(deps.compose.material3) - implementation(deps.compose.shimmer) - - /*** Tangem SDK */ - implementation(tangemDeps.blockchain) + /** Domain */ + implementation(projects.domain.appCurrency) + implementation(projects.domain.wallets) /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + /** AndroidX */ + implementation(deps.androidx.activity.compose) + + /** Compose */ + implementation(deps.compose.accompanist.systemUiController) + implementation(deps.compose.coil) + implementation(deps.compose.foundation) + implementation(deps.compose.material3) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + /** Other */ implementation(deps.arrow.core) - implementation(deps.kotlin.immutable.collections) + implementation(deps.decompose) implementation(deps.decompose.ext.compose) + implementation(deps.kotlin.immutable.collections) implementation(deps.timber) + implementation(tangemDeps.card.core) + implementation(tangemDeps.blockchain) } \ No newline at end of file diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/DefaultWelcomeComponent.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/DefaultWelcomeComponent.kt new file mode 100644 index 0000000000..7bc6a1d982 --- /dev/null +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/DefaultWelcomeComponent.kt @@ -0,0 +1,37 @@ +package com.tangem.features.welcome.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.welcome.WelcomeComponent +import com.tangem.features.welcome.impl.model.WelcomeModel +import com.tangem.features.welcome.impl.ui.Welcome +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultWelcomeComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: WelcomeComponent.Params, +) : WelcomeComponent, AppComponentContext by context { + + private val model: WelcomeModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + Welcome( + modifier = modifier, + state = state, + ) + } + + @AssistedFactory + interface Factory : WelcomeComponent.Factory { + override fun create(context: AppComponentContext, params: WelcomeComponent.Params): DefaultWelcomeComponent + } +} \ No newline at end of file diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/di/FeatureModule.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/di/FeatureModule.kt new file mode 100644 index 0000000000..fa56c72481 --- /dev/null +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/di/FeatureModule.kt @@ -0,0 +1,31 @@ +package com.tangem.features.welcome.impl.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.welcome.WelcomeComponent +import com.tangem.features.welcome.impl.DefaultWelcomeComponent +import com.tangem.features.welcome.impl.model.WelcomeModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(SingletonComponent::class) +internal interface FeatureModule { + + @Binds + fun bindComponentFactory(impl: DefaultWelcomeComponent.Factory): WelcomeComponent.Factory +} + +@Module +@InstallIn(ModelComponent::class) +internal interface ModelModule { + + @Binds + @IntoMap + @ClassKey(WelcomeModel::class) + fun provideModel(model: WelcomeModel): Model +} \ No newline at end of file diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt new file mode 100644 index 0000000000..da1f8c9c21 --- /dev/null +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt @@ -0,0 +1,18 @@ +package com.tangem.features.welcome.impl.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.features.welcome.impl.ui.state.WelcomeUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ModelScoped +internal class WelcomeModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + val uiState: StateFlow + field = MutableStateFlow(WelcomeUM.Plain) +} \ No newline at end of file diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/AddWalletBottomSheet.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/AddWalletBottomSheet.kt new file mode 100644 index 0000000000..4331461929 --- /dev/null +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/AddWalletBottomSheet.kt @@ -0,0 +1,89 @@ +package com.tangem.features.welcome.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.inputrow.InputRowDefault +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.welcome.impl.ui.state.AddWalletBottomSheetContentUM + +@Composable +fun AddWalletBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + titleText = TextReference.Str("Add Wallet"), + containerColor = TangemTheme.colors.background.tertiary, + content = { Content(it) }, + ) +} + +@Composable +private fun Content(content: AddWalletBottomSheetContentUM) { + Column( + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) { + InputRowDefault( + text = TextReference.Str("Create New Wallet"), + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = 0, + lastIndex = 3, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action) + .clickable { content.onOptionClick(AddWalletBottomSheetContentUM.Option.Create) }, + ) + InputRowDefault( + text = TextReference.Str("Add Existing Wallet"), + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = 1, + lastIndex = 2, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action) + .clickable { content.onOptionClick(AddWalletBottomSheetContentUM.Option.Add) }, + ) + InputRowDefault( + text = TextReference.Str("Buy Tangem Wallet"), + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = 2, + lastIndex = 2, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action) + .clickable { content.onOptionClick(AddWalletBottomSheetContentUM.Option.Buy) }, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SecurityScoreBottomSheetPreview() { + TangemThemePreview { + AddWalletBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = AddWalletBottomSheetContentUM(), + ), + ) + } +} \ No newline at end of file diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/Welcome.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/Welcome.kt new file mode 100644 index 0000000000..caf6ecd9bd --- /dev/null +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/Welcome.kt @@ -0,0 +1,89 @@ +package com.tangem.features.welcome.impl.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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.tooling.preview.Preview +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.welcome.impl.ui.state.WalletUM +import com.tangem.features.welcome.impl.ui.state.WelcomeUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun Welcome(state: WelcomeUM, modifier: Modifier = Modifier) { + Box( + modifier + .fillMaxSize() + .background(TangemTheme.colors.background.primary), + ) { + AnimatedContent( + targetState = state, + contentKey = { it::class.java.simpleName }, + ) { st -> + when (st) { + is WelcomeUM.Plain -> WelcomePlain(modifier = modifier) + is WelcomeUM.SelectWallet -> WelcomeSelectWallet( + state = st, + modifier = modifier, + ) + is WelcomeUM.EnterAccessCode -> WelcomeEnterAccessCode( + state = st, + modifier = modifier, + ) + } + } + } +} + +@Preview +@Composable +private fun Preview() { + TangemThemePreview { + val state = WelcomeUM.SelectWallet( + wallets = persistentListOf( + WalletUM( + name = TextReference.Str("Wallet 1"), + subtitle = TextReference.Str("3 cards"), + imageState = WalletUM.ImageState.Loading, + onClick = {}, + ), + WalletUM( + name = TextReference.Str("Wallet 1"), + subtitle = TextReference.Str("Mobile wallet"), + imageState = WalletUM.ImageState.MobileWallet, + onClick = {}, + ), + ), + ) + + var currentState by remember { mutableStateOf(WelcomeUM.EnterAccessCode()) } + + Box { + Welcome(currentState) + + Button( + modifier = Modifier.align(Alignment.BottomStart), + onClick = { + currentState = when (currentState) { + is WelcomeUM.Plain -> state + is WelcomeUM.SelectWallet -> WelcomeUM.EnterAccessCode( + value = "", + onValueChange = {}, + ) + is WelcomeUM.EnterAccessCode -> WelcomeUM.Plain + } + }, + ) { + Text("Switch") + } + } + } +} \ No newline at end of file diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeEnterAccessCode.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeEnterAccessCode.kt new file mode 100644 index 0000000000..3cd6085b58 --- /dev/null +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeEnterAccessCode.kt @@ -0,0 +1,94 @@ +package com.tangem.features.welcome.impl.ui + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.appbar.TopAppBarButton +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.fields.PinTextField +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.welcome.impl.ui.state.WelcomeUM + +@Suppress("MagicNumber") +@Composable +internal fun AnimatedContentScope.WelcomeEnterAccessCode( + state: WelcomeUM.EnterAccessCode, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .statusBarsPadding(), + ) { + Column { + TopAppBarButton( + modifier = Modifier + .padding(12.dp), + button = TopAppBarButtonUM.Back(onBackClicked = state.onBackClick), + tint = TangemTheme.colors.icon.primary1, + ) + + SpacerH(68.dp) + + Text( + modifier = Modifier + .animateEnterExit( + enter = slideInVertically( + tween(delayMillis = 300), + initialOffsetY = { it + 200 }, + ) + fadeIn(tween(delayMillis = 300)), + exit = fadeOut(), + ) + .align(Alignment.CenterHorizontally), + text = "Enter Access Code", + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + + SpacerH24() + + Box( + modifier = Modifier + .animateEnterExit( + enter = slideInVertically( + tween(delayMillis = 300), + initialOffsetY = { it + 200 }, + ) + fadeIn(tween(delayMillis = 300)), + exit = fadeOut(), + ) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + PinTextField( + length = 6, + isPasswordVisual = true, + value = state.value, + onValueChange = state.onValueChange, + ) + } + } + + SecondaryButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(16.dp) + .navigationBarsPadding() + .imePadding() + .animateEnterExit(fadeIn(), fadeOut()), + text = "Log in with biometric", + onClick = state.onUnlockWithBiometricClick, + ) + } +} \ No newline at end of file diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomePlain.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomePlain.kt new file mode 100644 index 0000000000..13aaae14db --- /dev/null +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomePlain.kt @@ -0,0 +1,29 @@ +package com.tangem.features.welcome.impl.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +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.vectorResource +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.welcome.impl.R + +@Composable +internal fun WelcomePlain(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(84.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_tangem_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + } +} \ No newline at end of file diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeSelectWallet.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeSelectWallet.kt new file mode 100644 index 0000000000..ed41fe5ad4 --- /dev/null +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeSelectWallet.kt @@ -0,0 +1,230 @@ +package com.tangem.features.welcome.impl.ui + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +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.CircleShape +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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.userwallet.CardImage +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.welcome.impl.R +import com.tangem.features.welcome.impl.ui.state.WalletUM +import com.tangem.features.welcome.impl.ui.state.WelcomeUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay + +@Suppress("MagicNumber") +@Composable +internal fun AnimatedContentScope.WelcomeSelectWallet(state: WelcomeUM.SelectWallet, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .statusBarsPadding(), + ) { + TopBar(state) + TitleText() + SpacerH12() + + var actualWallets by remember { mutableStateOf>(persistentListOf()) } + + Box(modifier = Modifier.weight(1f)) { + LazyColumn( + modifier = Modifier + .animateEnterExit( + enter = slideInVertically( + tween(delayMillis = 300), + initialOffsetY = { it / 10 }, + ) + fadeIn(tween(delayMillis = 300)), + exit = fadeOut(), + ) + .fillMaxHeight() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed(actualWallets) { index, walletState -> + WalletItem( + state = walletState, + modifier = Modifier, + ) + } + } + + BottomFade(modifier = Modifier.align(Alignment.BottomCenter)) + + SecondaryButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(16.dp) + .navigationBarsPadding() + .animateEnterExit(fadeIn(), fadeOut()), + text = "Unlock all with biometric", + onClick = state.onUnlockWithBiometricClick, + ) + } + + LaunchedEffect(state.wallets) { + delay(200) + actualWallets = state.wallets + } + } + + AddWalletBottomSheet(state.addWalletBottomSheet) +} + +@Suppress("MagicNumber") +@Composable +private fun AnimatedContentScope.TopBar(state: WelcomeUM.SelectWallet, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .animateEnterExit( + enter = slideInVertically( + tween(delayMillis = 100), + initialOffsetY = { it + 100 }, + ) + fadeIn(tween(delayMillis = 100)), + exit = fadeOut(), + ) + .padding( + top = 16.dp, + bottom = 16.dp, + start = 16.dp, + ) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.img_tangem_logo_90_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + + TextButton( + modifier = Modifier.clip(TangemTheme.shapes.roundedCornersLarge), + text = "Add Wallet", + colors = TangemButtonsDefaults.defaultTextButtonColors.copy( + contentColor = TangemTheme.colors.text.primary1, + ), + textStyle = TangemTheme.typography.body1, + onClick = state.addWalletClick, + ) + } +} + +@Suppress("MagicNumber") +@Composable +private fun AnimatedContentScope.TitleText(modifier: Modifier = Modifier) { + Column( + modifier.padding(16.dp), + ) { + Text( + modifier = Modifier.animateEnterExit( + enter = slideInVertically( + tween(delayMillis = 300), + initialOffsetY = { it + 200 }, + ) + fadeIn(tween(delayMillis = 300)), + exit = fadeOut(), + ), + text = "Welcome back!", + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + + SpacerH16() + + Text( + modifier = Modifier.animateEnterExit( + enter = slideInVertically( + tween(delayMillis = 300), + initialOffsetY = { it + 200 }, + ) + fadeIn(tween(delayMillis = 300)), + exit = fadeOut(), + ), + text = "Select a wallet to log in", + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + ) + } +} + +@Suppress("MagicNumber") +@Composable +private fun WalletItem(state: WalletUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.secondary, TangemTheme.shapes.roundedCornersXMedium) + .clickable(onClick = state.onClick) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + WalletImage(state.imageState) + + SpacerW12() + + Column(Modifier.weight(1f)) { + Text( + text = state.name.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + + Text( + text = state.subtitle.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} + +@Composable +private fun WalletImage(state: WalletUM.ImageState, modifier: Modifier = Modifier) { + when (state) { + WalletUM.ImageState.MobileWallet -> { + Box( + modifier = modifier + .size(36.dp) + .background(TangemTheme.colors.icon.accent.copy(alpha = 0.1f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_wallet_filled_24), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + } + } + else -> { + CardImage( + imageState = when (state) { + is WalletUM.ImageState.Image -> UserWalletItemUM.ImageState.Image(state.artwork) + WalletUM.ImageState.Loading -> UserWalletItemUM.ImageState.Loading + else -> error("") + }, + modifier = modifier, + ) + } + } +} \ No newline at end of file diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/state/AddWalletBottomSheetContentUM.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/state/AddWalletBottomSheetContentUM.kt new file mode 100644 index 0000000000..40a14eb1bb --- /dev/null +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/state/AddWalletBottomSheetContentUM.kt @@ -0,0 +1,14 @@ +package com.tangem.features.welcome.impl.ui.state + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +internal data class AddWalletBottomSheetContentUM( + val onOptionClick: (Option) -> Unit = {}, +) : TangemBottomSheetConfigContent { + + enum class Option { + Create, + Add, + Buy, + } +} \ No newline at end of file diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/state/WalletUM.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/state/WalletUM.kt new file mode 100644 index 0000000000..216a28f7d2 --- /dev/null +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/state/WalletUM.kt @@ -0,0 +1,22 @@ +package com.tangem.features.welcome.impl.ui.state + +import com.tangem.core.ui.components.artwork.ArtworkUM +import com.tangem.core.ui.extensions.TextReference +import javax.annotation.concurrent.Immutable + +internal data class WalletUM( + val name: TextReference, + val subtitle: TextReference, + val imageState: ImageState, + val onClick: () -> Unit, +) { + + @Immutable + sealed class ImageState { + data object MobileWallet : ImageState() + data object Loading : ImageState() + data class Image( + val artwork: ArtworkUM, + ) : ImageState() + } +} \ No newline at end of file diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/state/WelcomeUM.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/state/WelcomeUM.kt new file mode 100644 index 0000000000..1e2e509d58 --- /dev/null +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/state/WelcomeUM.kt @@ -0,0 +1,27 @@ +package com.tangem.features.welcome.impl.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal sealed class WelcomeUM { + + data object Plain : WelcomeUM() + + data class SelectWallet( + val wallets: ImmutableList = persistentListOf(), + val showUnlockWithBiometricButton: Boolean = false, + val addWalletBottomSheet: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty, + val onUnlockWithBiometricClick: () -> Unit = {}, + val addWalletClick: () -> Unit = {}, + ) : WelcomeUM() + + data class EnterAccessCode( + val value: String = "", + val onUnlockWithBiometricClick: () -> Unit = {}, + val onValueChange: (String) -> Unit = {}, + val onBackClick: () -> Unit = {}, + ) : WelcomeUM() +} \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 34c29de01a..92b3c84211 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -3,7 +3,7 @@ [versions] # region Classpath -androidGradlePlugin = "8.2.2" +androidGradlePlugin = "8.10.1" firebaseCrashlytics = "3.0.1" googleServices = "4.4.1" kotlin = "2.1.10" @@ -15,7 +15,7 @@ firebasePerf = "1.4.2" androidxActivityCompose = "1.8.0" androidxAppCompat = "1.5.1" androidxBrowser = "1.4.0" -androidxConstraintLayout = "2.1.4" +androidxConstraintLayout = "2.2.1" androidxKtx = "1.9.0" androidxSplashScreen = "1.0.1" androidxFragment = "1.8.5" @@ -90,6 +90,7 @@ room = "2.6.1" markdown = "0.7.2" markdownComposeView = "0.5.4" usedesk = "4.4.0" +sumsub = "1.36.0" # endregion Other libraries # region Tools @@ -106,11 +107,11 @@ junitAndroidExt = "1.1.5" mockk = "1.13.4" turbine = "1.2.0" truth = "1.1.3" -ultron-android = "2.5.4" -ultron-compose = "2.5.4" -ultron-allure ="2.5.4" +kaspresso = "1.5.4" +kaspresso-compose = "1.5.4" compose-junit = "1.6.2" hamcrest = "2.2" +orchestrator = "1.5.1" # endregion Testing [plugins] @@ -206,13 +207,13 @@ test-junit-android = { module = "androidx.test.ext:junit", version.ref = "junitA test-truth = { module = "com.google.truth:truth", version.ref = "truth" } test-mockk = { module = "io.mockk:mockk", version.ref = "mockk" } test-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } -test-ultron-android = { module = "com.atiurin:ultron-android", version.ref = "ultron-android" } -test-ultron-compose = { module = "com.atiurin:ultron-compose", version.ref = "ultron-compose" } -test-ultron-allure = { module = "com.atiurin:ultron-allure", version.ref = "ultron-allure" } +test-kaspresso = { module = "com.kaspersky.android-components:kaspresso", version.ref = "kaspresso" } +test-kaspresso-compose = { module = "com.kaspersky.android-components:kaspresso-compose-support", version.ref = "kaspresso-compose" } test-compose-junit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose-junit" } test-hamcrest = { module = "org.hamcrest:hamcrest", version.ref = "hamcrest" } test-hilt = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } test-hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "orchestrator" } # endregion Test # region Other @@ -277,4 +278,5 @@ markdown = { module = "org.jetbrains:markdown", version.ref = "markdown" } markdown-composeview = { module = "com.github.jeziellago:compose-markdown", version.ref = "markdownComposeView" } usedesk-chat-sdk = { module = "com.github.Usedesk.Android_SDK:chat-sdk", version.ref = "usedesk" } usedesk-chat-gui = { module = "com.github.Usedesk.Android_SDK:chat-gui", version.ref = "usedesk" } +sumsub-sdk = { module = "com.sumsub.sns:idensic-mobile-sdk", version.ref = "sumsub" } # endregion Other diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f5e4b2a14c..911e41ca23 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Feb 13 17:04:37 MSK 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt index 03d2b74d0c..6f03ce1a3a 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt @@ -38,6 +38,7 @@ private fun AppExtension.configureDefaultConfig(project: Project) { buildFeatures.buildConfig = true testInstrumentationRunner = "com.tangem.common.HiltTestRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "true" } } 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 3db7478662..5ddd7ba12d 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 @@ -6,6 +6,6 @@ internal object AppConfig { 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 + const val targetSdkVersion = 35 + const val compileSdkVersion = 35 } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index c46c443982..faba1334a8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -118,6 +118,7 @@ dependencyResolutionManagement { } } maven("https://jitpack.io") + maven("https://maven.sumsub.com/repository/maven-public/") } versionCatalogs { @@ -185,6 +186,8 @@ include(":features:swap:domain") include(":features:swap:domain:models") include(":features:swap:domain:api") include(":features:swap:impl") +include(":features:swap-v2:api") +include(":features:swap-v2:impl") include(":features:tester:api") include(":features:tester:impl") @@ -243,8 +246,17 @@ include(":features:nft:impl") include(":features:walletconnect:api") include(":features:walletconnect:impl") -include(":features:fee-selector:api") -include(":features:fee-selector:impl") +include(":features:hot-wallet:api") +include(":features:hot-wallet:impl") + +include(":features:kyc:api") +include(":features:kyc:impl") + +include(":features:create-wallet-selection:api") +include(":features:create-wallet-selection:impl") + +include(":features:welcome:api") +include(":features:welcome:impl") // endregion Feature modules // region Domain modules @@ -299,6 +311,10 @@ include(":domain:blockaid:models") include(":domain:notifications") include(":domain:notifications:models") include(":domain:notifications:toggles") +include(":domain:express") +include(":domain:express:models") +include(":domain:swap") +include(":domain:swap:models") // endregion Domain modules // region Data modules @@ -328,4 +344,6 @@ include(":data:onramp") include(":data:quotes") include(":data:notifications") include(":data:blockaid") +include(":data:swap") +include(":data:express") // endregion Data modules \ No newline at end of file diff --git a/tangem-android-tools b/tangem-android-tools index 53e359bc2e..034ad04fdf 160000 --- a/tangem-android-tools +++ b/tangem-android-tools @@ -1 +1 @@ -Subproject commit 53e359bc2ead37feeb9e70d7004c91cde07ccf9b +Subproject commit 034ad04fdf0810e338cb8898f50554874cec96d7