diff --git a/.gitignore b/.gitignore index 6106311a37..a4d9c4c73d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,12 +3,24 @@ # Built application files /build /buildSrc +**/build/.transforms/** +**/build/classes/** +**/build/generated/** +**/build/intermediates/** +**/build/kotlin/** +**/build/libs/** +**/build/outputs/** +**/build/tmp/** # Local configuration file (sdk path, etc) local.properties # Google services + +# Huawei services +app/agconnect-services.json + app/src/debug/google-services.json app/src/internal/google-services.json app/src/external/google-services.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 17ee83e2e2..a046ec5c66 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,9 +11,14 @@ plugins { alias(deps.plugins.firebase.crashlytics) alias(deps.plugins.firebase.perf) alias(deps.plugins.ksp) + id(deps.plugins.agconnect.get().pluginId) id("configuration") } +agcp { + manifest = false +} + android { namespace = "com.tangem.wallet" testOptions { @@ -53,12 +58,35 @@ android { keyPassword = keystoreProperties["key_password"] as String } } + + flavorDimensions += "services" + + productFlavors { + create("google") { + dimension = "services" + buildConfigField("String", "FLAVOR_NAME", "\"google\"") + } + create("huawei") { + dimension = "services" + buildConfigField("String", "FLAVOR_NAME", "\"huawei\"") + } + } + + buildTypes { + debug { + buildConfigField("String", "BUILD_TYPE", "\"debug\"") + } + release { + buildConfigField("String", "BUILD_TYPE", "\"release\"") + } + } } configurations.all { exclude(group = "org.bouncycastle", module = "bcprov-jdk15to18") exclude(group = "com.github.komputing.kethereum") + exclude(group = "com.android.tools.build", module = "gradle") resolutionStrategy { dependencySubstitution { @@ -121,11 +149,11 @@ dependencies { implementation(projects.domain.quotes) 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.domain.walletManager) implementation(projects.domain.walletManager.models) + implementation(projects.domain.yieldSupply) implementation(projects.common) implementation(projects.common.routing) @@ -174,6 +202,7 @@ dependencies { implementation(projects.data.notifications) implementation(projects.data.swap) implementation(projects.data.walletManager) + implementation(projects.data.yieldSupply) /** Features */ implementation(projects.features.referral.impl) @@ -229,8 +258,11 @@ dependencies { implementation(projects.features.hotWallet.api) implementation(projects.features.hotWallet.impl) implementation(projects.features.kyc.api) - //TODO disable for release because of the permissions - // implementation(projects.features.kyc.impl) + debugImplementation(projects.features.kyc.impl) + internalImplementation(projects.features.kyc.impl) + mockedImplementation(projects.features.kyc.impl) + releaseImplementation(projects.features.kyc.mock) + externalImplementation(projects.features.kyc.mock) implementation(projects.features.welcome.api) implementation(projects.features.welcome.impl) implementation(projects.features.createWalletSelection.api) @@ -243,8 +275,12 @@ dependencies { implementation(projects.features.tangempay.details.impl) implementation(projects.features.tangempay.main.api) implementation(projects.features.tangempay.main.impl) + implementation(projects.features.tangempay.onboarding.api) + implementation(projects.features.tangempay.onboarding.impl) implementation(projects.features.tokenRecieve.api) implementation(projects.features.tokenRecieve.impl) + implementation(projects.features.yieldSupply.api) + implementation(projects.features.yieldSupply.impl) /** AndroidX libraries */ implementation(deps.androidx.core.ktx) @@ -318,7 +354,7 @@ dependencies { implementation(deps.coil.gif) implementation(deps.coil.svg) implementation(deps.amplitude) - implementation(deps.kotsonGson) + implementation(deps.appsflyer) implementation(deps.spongecastle.core) implementation(deps.lottie) implementation(deps.compose.accompanist.appCompatTheme) @@ -379,4 +415,9 @@ dependencies { // excludes version 9999.0-empty-to-avoid-conflict-with-guava exclude(group = "com.google.guava", module = "listenablefuture") } + + /** Huawei flavor-specific dependencies */ + "huaweiImplementation"(deps.huawei.push) + "huaweiImplementation"(deps.agconnect.agcp) + "huaweiImplementation"(deps.agconnect.core) } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 2a7b205c9d..5c7c51645b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -7,6 +7,13 @@ -keep class com.google.android.gms.internal.** { *; } -keepclasseswithmembers class com.google.firebase.FirebaseException +# huawei push kit +-ignorewarnings +-keepattributes SourceFile,LineNumberTable +-keep class com.huawei.hianalytics.**{*;} +-keep class com.huawei.updatesdk.**{*;} +-keep class com.huawei.hms.**{*;} + # hedera sdk -keep class com.hedera.hashgraph.sdk.** { *; } -keep interface com.hedera.hashgraph.sdk.** { *; } @@ -206,3 +213,7 @@ -keep class **.R$* { ; } + +# appsflyer +-keep class com.appsflyer.** { *; } +-keep class kotlin.jvm.internal.** { *; } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 823d7df27c..c27c9c93db 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -13,12 +13,19 @@ import com.kaspersky.components.composesupport.config.addComposeSupport import com.kaspersky.kaspresso.kaspresso.Kaspresso import com.kaspersky.kaspresso.testcases.api.testcase.TestCase import com.tangem.common.allure.FailedStepScreenshotInterceptor +import com.tangem.common.constants.TestConstants.ALLURE_LABEL_NAME +import com.tangem.common.constants.TestConstants.ALLURE_LABEL_VALUE import com.tangem.common.rules.ApiEnvironmentRule +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.domain.promo.PromoRepository +import com.tangem.domain.promo.models.PromoId import com.tangem.tap.MainActivity import dagger.hilt.android.testing.HiltAndroidRule +import io.qameta.allure.kotlin.Allure import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.rules.RuleChain @@ -46,6 +53,12 @@ abstract class BaseTestCase : TestCase( @Inject lateinit var appPreferencesStore: AppPreferencesStore + @Inject + lateinit var featureTogglesManager: FeatureTogglesManager + + @Inject + lateinit var promoRepository: PromoRepository + private val hiltRule = HiltAndroidRule(this) private val apiEnvironmentRule = ApiEnvironmentRule() private val permissionRule = GrantPermissionRule.grant( @@ -78,6 +91,7 @@ abstract class BaseTestCase : TestCase( additionalBeforeSection: () -> Unit = {}, additionalAfterSection: () -> Unit = {}, ) = before { + Allure.label(ALLURE_LABEL_NAME, ALLURE_LABEL_VALUE) hiltRule.inject() runBlocking { appPreferencesStore.editData { mutablePreferences -> @@ -86,10 +100,12 @@ abstract class BaseTestCase : TestCase( value = false ) } + promoRepository.setNeverToShowWalletPromo(PromoId.Sepa) } apiEnvironmentRule.setup(apiConfigsManager) ActivityScenario.launch(MainActivity::class.java) Intents.init() + setFeatureToggles() additionalBeforeSection() }.after { additionalAfterSection() @@ -113,4 +129,18 @@ abstract class BaseTestCase : TestCase( { composeTestRule.onRoot(useUnmergedTree = useUnmergedTree).printToLog(tag, maxDepth) } + + + private fun setFeatureToggles() { + runBlocking { + with(featureTogglesManager as MutableFeatureTogglesManager) { + changeToggle("WALLET_CONNECT_REDESIGN_ENABLED", true) + changeToggle("WALLET_BALANCE_FETCHER_ENABLED", true) + changeToggle("SEND_VIA_SWAP_ENABLED", true) + changeToggle("SWAP_REDESIGN_ENABLED", true) + changeToggle("SEND_REDESIGN_ENABLED", true) + changeToggle("NEW_ONRAMP_MAIN_ENABLED", true) + } + } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt index 00cfc3afe3..2cbb09b422 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -3,5 +3,12 @@ package com.tangem.common.constants object TestConstants { const val TOTAL_BALANCE = "$3,299.18" + const val RECIPIENT_ADDRESS = "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0" + const val WAIT_UNTIL_TIMEOUT = 20_000L + + const val MARKETS_MAIN_NETWORK_SUFFIX = "MAIN" + + const val ALLURE_LABEL_NAME = "Owner" + const val ALLURE_LABEL_VALUE = "Kaspresso" } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/BaseTestCaseExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/BaseTestCaseExt.kt index 72637225d9..daace0f71a 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/BaseTestCaseExt.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/BaseTestCaseExt.kt @@ -1,11 +1,15 @@ package com.tangem.common.extensions +import androidx.test.uiautomator.By import com.tangem.common.BaseTestCase +import com.tangem.wallet.R +import io.github.kakaocup.kakao.common.utilities.getResourceString -fun BaseTestCase.swipeUp( - startHeightRatio: Float = 0.8f, - endHeightRatio: Float = 0.03f, - steps: Int = 15 +fun BaseTestCase.swipeVertical( + direction: SwipeDirection, + startHeightRatio: Float = if (direction == SwipeDirection.UP) 0.8f else 0.03f, + endHeightRatio: Float = if (direction == SwipeDirection.UP) 0.03f else 0.8f, + steps: Int = 15, ) { device.uiDevice.swipe( device.uiDevice.displayWidth / 2, @@ -14,4 +18,41 @@ fun BaseTestCase.swipeUp( (device.uiDevice.displayHeight * endHeightRatio).toInt(), steps ) +} + +fun BaseTestCase.pullToRefresh() { + swipeVertical( + direction = SwipeDirection.DOWN, + startHeightRatio = 0.2f, + endHeightRatio = 0.8f, + steps = 2000 + ) +} + +fun BaseTestCase.swipeMarketsBlock(direction: SwipeDirection) { + val searchBarText = device.uiDevice + .findObject(By.textContains(getResourceString(R.string.markets_search_header_title))) + val bounds = searchBarText.visibleBounds + + val centerX = bounds.centerX() + val startY = bounds.centerY() + val endY = when (direction) { + SwipeDirection.UP -> 50 + SwipeDirection.DOWN -> device.uiDevice.displayHeight - 100 + } + + device.uiDevice.swipe(centerX, startY, centerX, endY, 100) +} + +fun BaseTestCase.openTheAppFromRecents() { + device.uiDevice.pressRecentApps() + + val centerX = device.uiDevice.displayWidth / 2 + val centerY = device.uiDevice.displayHeight / 3 + device.uiDevice.click(centerX, centerY) + device.uiDevice.click(centerX, centerY) +} + +enum class SwipeDirection { + UP, DOWN } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt new file mode 100644 index 0000000000..e1040b8ffc --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt @@ -0,0 +1,25 @@ +package com.tangem.common.extensions + +import io.github.kakaocup.compose.node.element.KNode + +fun assertElementDoesNotExist( + elementProvider: () -> KNode, + elementDescription: String, +) { + try { + elementProvider().assertExists() + throw AssertionError("$elementDescription should not exist but was found") + } catch (e: AssertionError) { + val isNotFoundError = e.message?.let { message -> + message.contains("No node found") || + message.contains("scrollable container") || + message.contains("There are no existing nodes") || + message.contains("There are no existing nodes for that selector") + } ?: false + if (isNotFoundError) { + return + } else { + throw e + } + } +} \ 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 index a7baa714a4..7c275607c9 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt @@ -5,4 +5,5 @@ 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/KViewExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/KViewExt.kt new file mode 100644 index 0000000000..c0b6c55687 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/KViewExt.kt @@ -0,0 +1,7 @@ +package com.tangem.common.extensions + +import io.github.kakaocup.kakao.common.views.KBaseView + +fun > T.withDialogRoot(): T { + return this.also { inRoot { isDialog() } } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt new file mode 100644 index 0000000000..da1573d026 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt @@ -0,0 +1,105 @@ +package com.tangem.common.utils + +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * + */ +fun getWcUri( + network: String = "ethereum", + baseUrl: String = "[REDACTED_ENV_URL]" +): String? { + Timber.i("Getting WC URI for network: $network") + + val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) // Таймаут подключения + .readTimeout(60, TimeUnit.SECONDS) // Таймаут чтения ответа + .writeTimeout(30, TimeUnit.SECONDS) // Таймаут записи + .callTimeout(90, TimeUnit.SECONDS) // Общий таймаут запроса + .build() + + val request = Request.Builder() + .url("$baseUrl/wc_uri?network=$network") + .get() + .build() + + return try { + client.newCall(request).execute().use { response -> + Timber.i("Response code: ${response.code}") + + if (response.isSuccessful) { + val body = response.body?.string() ?: "" + Timber.i("Response body: $body") + + val jsonObject = JSONObject(body) + + if (jsonObject.getBoolean("success")) { + val wcUri = jsonObject.getString("wcUri") + Timber.i("Got WC URI successfully: $wcUri") + + wcUri + } else { + Timber.e("API returned error: ${jsonObject.optString("error", "Unknown")}") + null + } + } else { + val errorBody = response.body?.string() ?: "No error body" + Timber.e("Request failed: ${response.code}, body: $errorBody") + null + } + } + } catch (e: Exception) { + Timber.e(e, "Error getting WC URI") + null + } +} + +fun checkServiceHealth( + baseUrl: String = "[REDACTED_ENV_URL]" +): String? { + Timber.i("Checking service health") + + val client = OkHttpClient() + val request = Request.Builder() + .url("$baseUrl/health") + .get() + .build() + + return try { + client.newCall(request).execute().use { response -> + Timber.i("Response code: ${response.code}") + + if (response.isSuccessful) { + val body = response.body?.string() ?: "" + Timber.i("Response body: $body") + + if (body.isEmpty()) { + Timber.e("Response body is empty") + return null + } + + val jsonObject = JSONObject(body) + val status = jsonObject.optString("status", "") + + if (status.isNotEmpty()) { + Timber.i("Got status successfully: $status") + status + } else { + Timber.e("Status field is missing or empty") + null + } + } else { + val errorBody = response.body?.string() ?: "No error body" + Timber.e("Request failed: ${response.code}, body: $errorBody") + null + } + } + } catch (e: Exception) { + Timber.e(e, "Error checking health") + null + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt new file mode 100644 index 0000000000..ad6edeb6d9 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -0,0 +1,62 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.domain.models.scan.ProductType +import com.tangem.screens.* +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.thisIsMyWalletButton +import com.tangem.tap.domain.sdk.mocks.MockContent +import com.tangem.tap.domain.sdk.mocks.MockProvider +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.openMainScreen( + productType: ProductType? = null, + mockContent: MockContent? = null, + alreadyActivatedDialogIsShown : Boolean = false +) { + if (productType != null) { + MockProvider.setMocks(productType) + } + if (mockContent != null) { + MockProvider.setMocks(mockContent) + } + step("Click on 'Accept' button") { + onDisclaimerScreen { acceptButton.clickWithAssertion() } + } + step("Click on 'Scan' button") { + onStoriesScreen { scanButton.clickWithAssertion() } + } + if (alreadyActivatedDialogIsShown) { + step("Click on 'This is my wallet' button") { + composeTestRule.waitForIdle() + AlreadyUsedWalletDialogPageObject { thisIsMyWalletButton.click() } + } + } + step("Assert 'Main' screen is displayed") { + onMainScreen { screenContainer.assertIsDisplayed() } + } + step("Click on 'Market Tooltip' screen") { + onMarketsTooltipScreen { contentContainer.clickWithAssertion() } + } +} + +fun BaseTestCase.synchronizeAddresses(balance: String) { + step("Click on 'Synchronize addresses' button") { + onMainScreen { synchronizeAddressesButton.clickWithAssertion() } + } + step("Assert wallet balance = '$balance'") { + onMainScreen { totalBalanceText.assertTextContains(balance) } + } +} + +fun BaseTestCase.openDeviceSettingsScreen() { + step("Open wallet details") { + onTopBar { moreButton.clickWithAssertion() } + } + step("Open 'Wallet settings' screen") { + onDetailsScreen { walletNameButton.performClick() } + } + step("Click on 'Device settings' button") { + onWalletSettingsScreen { deviceSettingsButton.clickWithAssertion() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/DeepLinksScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/DeepLinksScenarios.kt new file mode 100644 index 0000000000..e141a7cc1c --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/DeepLinksScenarios.kt @@ -0,0 +1,26 @@ +package com.tangem.scenarios + +import android.content.Intent +import android.content.Intent.* +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import io.github.kakaocup.kakao.intent.KIntent + +fun openAppByDeepLink(deepLinkUri: String?) { + val deeplinkScheme = "tangem://wc?uri=" + val context = ApplicationProvider.getApplicationContext() + val intent = Intent(ACTION_VIEW, Uri.parse(deeplinkScheme + deepLinkUri)).apply { + addFlags(FLAG_ACTIVITY_NEW_TASK) + } + + context.startActivity(intent) +} + +fun checkSendEMailIntentCalled() { + val expectedIntent = KIntent { + hasAction(ACTION_CHOOSER) + hasExtra(EXTRA_TITLE, "Send mail...") + hasExtraWithKey(EXTRA_INTENT) + } + expectedIntent.intended() +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/DeviceSetingsScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/DeviceSetingsScenarios.kt new file mode 100644 index 0000000000..d58f6ba8e3 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/DeviceSetingsScenarios.kt @@ -0,0 +1,21 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.screens.onDeviceSettingsScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.openResetCardScreen(withBackup: Boolean = false) { + step("Click on 'Scan card or ring' button") { + onDeviceSettingsScreen { scanCardOrRingButton.clickWithAssertion() } + } + step("Assert 'Reset to Factory Settings' button title is displayed") { + onDeviceSettingsScreen { resetToFactorySettingsButtonTitle.assertIsDisplayed() } + } + step("Assert 'Reset to Factory Settings' button subtitle is displayed") { + onDeviceSettingsScreen { resetToFactorySettingsButtonSubtitle(withBackup).assertIsDisplayed() } + } + step("Click on 'Reset to Factory Settings' button") { + onDeviceSettingsScreen { resetToFactorySettingsButtonTitle.performClick() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt new file mode 100644 index 0000000000..c7f2d7f954 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt @@ -0,0 +1,66 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.screens.AlreadyUsedWalletDialogPageObject +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.cancelButton +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.message +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.requestSupportButton +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.thisIsMyWalletButton +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.title +import com.tangem.screens.ScanWarningDialogPageObject +import com.tangem.screens.onFailedTransactionDialog +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.checkFailedTransactionDialog() { + step("Assert failed transaction dialog is displayed") { + onFailedTransactionDialog { dialogContainer.assertIsDisplayed() } + } + step("Assert failed transaction dialog title is displayed") { + onFailedTransactionDialog { title.assertIsDisplayed() } + } + step("Assert failed transaction dialog text is displayed") { + onFailedTransactionDialog { text.assertIsDisplayed() } + } + step("Assert 'Cancel' button is displayed") { + onFailedTransactionDialog { cancelButton.assertIsDisplayed() } + } + step("Assert 'Support' button is displayed") { + onFailedTransactionDialog { supportButton.assertIsDisplayed() } + } +} + +fun checkScanWarningDialog() { + step("Assert 'Scan warning' dialog title is displayed") { + ScanWarningDialogPageObject { warningTitle.isDisplayed() } + } + step("Assert warning dialog message is displayed") { + ScanWarningDialogPageObject { warningMessage.isDisplayed() } + } + step("Assert 'Cancel' button is displayed") { + ScanWarningDialogPageObject { cancelButton.isDisplayed() } + } + step("Assert 'How to scan' button is displayed") { + ScanWarningDialogPageObject { howToScanButton.isDisplayed() } + } + step("Assert 'Request support' button is displayed") { + ScanWarningDialogPageObject { requestSupportButton.isDisplayed() } + } +} + +fun checkAlreadyUsedWalletDialog() { + step("Assert 'Already used Wallet' dialog title is displayed") { + AlreadyUsedWalletDialogPageObject { title.isDisplayed() } + } + step("Assert dialog message is displayed") { + AlreadyUsedWalletDialogPageObject { message.isDisplayed() } + } + step("Assert 'This is my wallet' button is displayed") { + AlreadyUsedWalletDialogPageObject { thisIsMyWalletButton.isDisplayed() } + } + step("Assert 'Cancel' button is displayed") { + AlreadyUsedWalletDialogPageObject { cancelButton.isDisplayed() } + } + step("Assert 'Request support' button is displayed") { + AlreadyUsedWalletDialogPageObject { requestSupportButton.isDisplayed() } + } +} \ 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 deleted file mode 100644 index dd160f1b25..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/OpenMainScreenScenario.kt +++ /dev/null @@ -1,41 +0,0 @@ -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.* -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"){ - contentContainer.performClick() - } - } - } -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/ResetCardScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/ResetCardScenarios.kt new file mode 100644 index 0000000000..670cfbce63 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/ResetCardScenarios.kt @@ -0,0 +1,80 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.screens.onResetCardScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.checkResetCardScreen(withBackup: Boolean = false) { + step("Assert title is displayed") { + onResetCardScreen { title.assertIsDisplayed() } + } + step("Assert 'Attention' image is displayed") { + onResetCardScreen { attentionImage.assertIsDisplayed() } + } + step("Assert 'Attention' subtitle is displayed") { + onResetCardScreen { subtitle.assertIsDisplayed() } + } + step("Assert description is displayed") { + onResetCardScreen { description.assertIsDisplayed() } + } + step("Assert 'Lost wallet' checkbox is displayed") { + onResetCardScreen { lostWalletAccessCheckBox.assertIsDisplayed() } + } + if (withBackup) { + step("Assert 'Lost password restore' checkbox is displayed") { + onResetCardScreen { lostPasswordRestoreCheckBox.assertIsDisplayed() } + } + } else { + step("Assert 'Lost password restore' checkbox doesn't exist") { + onResetCardScreen { lostPasswordRestoreCheckBox.assertDoesNotExist() } + } + } +} + +fun BaseTestCase.checkCheckBoxLogic(withBackup: Boolean = false) { + if (withBackup) { + step("Click on 'Lost wallet' checkbox") { + onResetCardScreen { lostWalletAccessCheckBox.performClick() } + } + step("Assert 'Lost wallet' checkbox is enabled") { + onResetCardScreen { lostWalletAccessCheckBox.assertIsEnabled() } + } + step("Assert 'Reset the card' button is disabled") { + onResetCardScreen { resetCardButton.assertIsNotEnabled() } + } + step("Click on 'Lost password restore' checkbox") { + onResetCardScreen { lostPasswordRestoreCheckBox.performClick() } + } + step("Assert 'Lost password restore' checkbox is enabled") { + onResetCardScreen { lostPasswordRestoreCheckBox.assertIsEnabled() } + } + step("Assert 'Reset the card' button is enabled") { + onResetCardScreen { resetCardButton.assertIsEnabled() } + } + step("Click on 'Lost password restore' checkbox") { + onResetCardScreen { lostPasswordRestoreCheckBox.performClick() } + } + step("Assert 'Reset the card' button is disabled") { + onResetCardScreen { resetCardButton.assertIsNotEnabled() } + } + step("Click on 'Lost wallet' checkbox") { + onResetCardScreen { lostWalletAccessCheckBox.performClick() } + } + step("Click on 'Lost password restore' checkbox") { + onResetCardScreen { lostPasswordRestoreCheckBox.performClick() } + } + step("Assert 'Reset the card' button is disabled") { + onResetCardScreen { resetCardButton.assertIsNotEnabled() } + } + } else { + step("Click on 'Lost wallet' checkbox") { + onResetCardScreen { lostWalletAccessCheckBox.performClick() } + } + step("Assert 'Lost wallet' checkbox is enabled") { + onResetCardScreen { lostWalletAccessCheckBox.assertIsEnabled() } + } + step("Assert 'Reset the card' button is enabled") { + onResetCardScreen { resetCardButton.assertIsEnabled() } + } + } +} diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt new file mode 100644 index 0000000000..107806004a --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt @@ -0,0 +1,139 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.screens.onWalletConnectBottomSheet +import com.tangem.screens.onWalletConnectDetailsBottomSheet +import com.tangem.screens.onWalletConnectScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.checkWalletConnectBottomSheet() { + step("Assert 'Wallet Connect' bottom sheet title is displayed") { + onWalletConnectBottomSheet { title.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet app icon is displayed") { + onWalletConnectBottomSheet { appIcon.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet app name is displayed") { + onWalletConnectBottomSheet { appName.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet approve icon is displayed") { + onWalletConnectBottomSheet { approveIcon.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet app URL is displayed") { + onWalletConnectBottomSheet { appUrl.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet connection request icon is displayed") { + onWalletConnectBottomSheet { connectionRequestIcon.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet connection request text is displayed") { + onWalletConnectBottomSheet { connectionRequestText.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet connection request chevron is displayed") { + onWalletConnectBottomSheet { connectionRequestChevron.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet wallet icon is displayed") { + onWalletConnectBottomSheet { walletIcon.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet wallet title is displayed") { + onWalletConnectBottomSheet { walletNameTitle.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet wallet name is displayed") { + onWalletConnectBottomSheet { walletName.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet networks icon is displayed") { + onWalletConnectBottomSheet { networksIcon.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet networks title is displayed") { + onWalletConnectBottomSheet { networksTitle.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet right networks icons is displayed") { + onWalletConnectBottomSheet { networksIcons.assertIsDisplayed() } + } + step("'Wallet Connect' bottom sheet networks selector icon is displayed") { + onWalletConnectBottomSheet { networksSelectorIcon.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet 'Cancel' button is displayed") { + onWalletConnectBottomSheet { cancelButton.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet 'Connect' button is displayed") { + onWalletConnectBottomSheet { connectButton.assertIsDisplayed() } + } +} + +fun BaseTestCase.checkWalletConnectScreen() { + step("Assert 'Wallet Connect' title is displayed") { + onWalletConnectScreen { title.assertIsDisplayed() } + } + step("Assert 'More' button is displayed") { + onWalletConnectScreen { moreButton.assertIsDisplayed() } + } + step("Assert wallet name is displayed") { + onWalletConnectScreen { walletName.assertIsDisplayed() } + } + step("Assert app icon is displayed") { + onWalletConnectScreen { appIcon.assertIsDisplayed() } + } + step("Assert app name is displayed") { + onWalletConnectScreen { appName.assertIsDisplayed() } + } + step("Assert approve icon is displayed") { + onWalletConnectScreen { approveIcon.assertIsDisplayed() } + } + step("Assert app URL is displayed") { + onWalletConnectScreen { appUrl.assertIsDisplayed() } + } + step("Assert 'New Connection' button is displayed") { + onWalletConnectScreen { newConnectionButton.assertIsDisplayed() } + } +} + +fun BaseTestCase.checkWalletConnectDetailsBottomSheet(dAppName: String) { + step("Assert connection details title is displayed") { + onWalletConnectDetailsBottomSheet { title.assertIsDisplayed() } + } + step("Assert date is displayed") { + onWalletConnectDetailsBottomSheet { date.assertIsDisplayed() } + } + step("Assert 'Close' button is displayed") { + onWalletConnectDetailsBottomSheet { closeButton.assertIsDisplayed() } + } + step("Assert app icon is displayed") { + onWalletConnectDetailsBottomSheet { appIcon.assertIsDisplayed() } + } + step("Assert app name is displayed") { + onWalletConnectDetailsBottomSheet { appName.assertIsDisplayed() } + } + step("Assert approve icon is displayed") { + onWalletConnectDetailsBottomSheet { approveIcon.assertIsDisplayed() } + } + step("Assert app URL is displayed") { + onWalletConnectDetailsBottomSheet { appUrl.assertIsDisplayed() } + } + step("Assert wallet icon is displayed") { + onWalletConnectDetailsBottomSheet { walletIcon.assertIsDisplayed() } + } + step("Assert wallet title is displayed") { + onWalletConnectDetailsBottomSheet { walletTitle.assertIsDisplayed() } + } + step("Assert wallet name is displayed") { + onWalletConnectDetailsBottomSheet { walletName.assertIsDisplayed() } + } + step("Assert 'Connected networks' title is displayed") { + onWalletConnectDetailsBottomSheet { connectedNetworksTitle.assertIsDisplayed() } + } + step("Assert connected network item is displayed") { + onWalletConnectDetailsBottomSheet { connectedNetworkItem.assertIsDisplayed() } + } + step("Assert connected network icon is displayed") { + onWalletConnectDetailsBottomSheet { connectedNetworkIcon.assertIsDisplayed() } + } + step("Assert connected dApp name: '$dAppName'") { + onWalletConnectDetailsBottomSheet { connectedNetworkName.assertTextContains(dAppName) } + } + step("Assert connected network symbol is displayed") { + onWalletConnectDetailsBottomSheet { connectedNetworkSymbol.assertIsDisplayed() } + } + step("Assert 'Disconnect button' is displayed") { + onWalletConnectDetailsBottomSheet { disconnectButton.assertIsDisplayed() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AlreadyUsedWalletDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AlreadyUsedWalletDialogPageObject.kt new file mode 100644 index 0000000000..e7e11531ba --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/AlreadyUsedWalletDialogPageObject.kt @@ -0,0 +1,29 @@ +package com.tangem.screens + +import com.tangem.common.extensions.withDialogRoot +import com.tangem.wallet.R +import io.github.kakaocup.kakao.text.KButton +import io.github.kakaocup.kakao.text.KTextView + +object AlreadyUsedWalletDialogPageObject : BaseDialog() { + + val title = KTextView { + withText(R.string.security_alert_title) + }.withDialogRoot() + + val message = KTextView { + withText(R.string.wallet_been_activated_message) + }.withDialogRoot() + + val cancelButton = KButton { + withText(R.string.common_cancel) + }.withDialogRoot() + + val requestSupportButton = KButton { + withText(R.string.alert_button_request_support) + }.withDialogRoot() + + val thisIsMyWalletButton = KButton { + withText(R.string.this_is_my_wallet_title) + }.withDialogRoot() +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/BaseBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/BaseBottomSheetPageObject.kt new file mode 100644 index 0000000000..d40a96c38a --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/BaseBottomSheetPageObject.kt @@ -0,0 +1,22 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseBottomSheetTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class BaseBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val hideButton: KNode = child { + hasText(getResourceString(R.string.token_details_hide_token)) + hasTestTag(BaseBottomSheetTestTags.ACTION_TITLE) + } +} + +internal fun BaseTestCase.onBottomSheet(function: BaseBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/BaseDialog.kt b/app/src/androidTest/kotlin/com/tangem/screens/BaseDialog.kt new file mode 100644 index 0000000000..383331368a --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/BaseDialog.kt @@ -0,0 +1,14 @@ +package com.tangem.screens + +import io.github.kakaocup.kakao.common.views.KBaseView + +abstract class BaseDialog : KBaseView({ isRoot() }) { + + init { + setDialogRoot() + } + + private fun setDialogRoot() { + inRoot { isDialog() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenDetailsPageObject.kt index 4193f3ab8b..2b4e330704 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenDetailsPageObject.kt @@ -38,7 +38,7 @@ class BuyTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProv } val errorNotificationText: KNode = child { - hasTestTag(NotificationTestTags.TEXT) + hasTestTag(NotificationTestTags.MESSAGE) hasText(getResourceString(R.string.common_unknown_error)) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DeviceSettingsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DeviceSettingsPageObject.kt new file mode 100644 index 0000000000..7822f4b34e --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/DeviceSettingsPageObject.kt @@ -0,0 +1,61 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.DeviceSettingsScreenTestTags +import com.tangem.core.ui.utils.LazyListItemPositionSemantics +import com.tangem.wallet.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class DeviceSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + private val lazyList = KLazyListNode( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(DeviceSettingsScreenTestTags.LAZY_LIST) }, + itemTypeBuilder = { itemType(::LazyListItemNode) }, + positionMatcher = { position -> + SemanticsMatcher.expectValue( + LazyListItemPositionSemantics, + position + ) + } + ) + + val imageBlock: KNode = child { + hasTestTag(DeviceSettingsScreenTestTags.IMAGE_BLOCK) + useUnmergedTree = true + } + + val resetToFactorySettingsButtonTitle: KNode = child { + hasTestTag(DeviceSettingsScreenTestTags.ITEM_TITLE) + hasText(getResourceString(R.string.card_settings_reset_card_to_factory)) + useUnmergedTree = true + } + + val scanCardOrRingButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.scan_card_settings_button)) + useUnmergedTree = true + } + + fun resetToFactorySettingsButtonSubtitle(withBackup: Boolean = false): KNode = child { + hasTestTag(DeviceSettingsScreenTestTags.ITEM_SUBTITLE) + useUnmergedTree = true + if (withBackup) { + hasText(getResourceString(R.string.reset_card_with_backup_to_factory_message)) + } else { + hasText(getResourceString(R.string.reset_card_without_backup_to_factory_message)) + } + } +} + +internal fun BaseTestCase.onDeviceSettingsScreen(function: DeviceSettingsPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt index 5bac035afc..9543830d26 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt @@ -4,7 +4,7 @@ import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.R import com.tangem.core.ui.test.BaseButtonTestTags -import com.tangem.core.ui.test.DialogTestTags +import com.tangem.core.ui.test.BaseDialogTestTags import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode @@ -14,7 +14,7 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { val dialogContainer: KNode = child { - hasTestTag(DialogTestTags.DIALOG_CONTAINER) + hasTestTag(BaseDialogTestTags.CONTAINER) } val cancelButton: KNode = child { @@ -31,6 +31,16 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasTestTag(BaseButtonTestTags.BUTTON) hasText(getResourceString(R.string.common_confirm)) } + + val continueButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_continue)) + } + + val okButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_ok)) + } } internal fun BaseTestCase.onDialog(function: DialogPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/FailedCardVerificationDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/FailedCardVerificationDialogPageObject.kt new file mode 100644 index 0000000000..a5ef4d40fb --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/FailedCardVerificationDialogPageObject.kt @@ -0,0 +1,28 @@ +package com.tangem.screens + +import com.kaspersky.kaspresso.screens.KScreen +import com.tangem.core.ui.R +import io.github.kakaocup.kakao.text.KButton +import io.github.kakaocup.kakao.text.KTextView + +object FailedCardVerificationDialogPageObject : KScreen() { + + override val layoutId: Int? = null + override val viewClass: Class<*>? = null + + val title = KTextView { + withId(R.id.alertTitle) + } + + val message = KTextView { + withId(R.id.message) + } + + val cancelButton = KButton { + withText(R.string.common_cancel) + } + + val understandButton = KButton { + withText(R.string.common_understand) + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/FailedTransactionDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/FailedTransactionDialogPageObject.kt new file mode 100644 index 0000000000..4a00a7e1b8 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/FailedTransactionDialogPageObject.kt @@ -0,0 +1,44 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.BaseDialogTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import com.tangem.common.ui.R as CommonUIR + +class FailedTransactionDialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val dialogContainer: KNode = child { + hasTestTag(BaseDialogTestTags.CONTAINER) + } + + val title: KNode = child { + hasTestTag(BaseDialogTestTags.TITLE) + hasText(getResourceString(CommonUIR.string.send_alert_transaction_failed_title)) + useUnmergedTree = true + } + + val text: KNode = child { + hasTestTag(BaseDialogTestTags.TEXT) + useUnmergedTree = true + } + + val cancelButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_cancel)) + } + + val supportButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_support)) + } +} + +internal fun BaseTestCase.onFailedTransactionDialog(function: FailedTransactionDialogPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index ce3a3181b3..f8abf2f869 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -3,11 +3,11 @@ package com.tangem.screens import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.hasAnyAncestor import com.tangem.common.BaseTestCase import com.tangem.common.extensions.hasLazyListItemPosition import com.tangem.common.utils.LazyListItemNode -import com.tangem.core.ui.test.TokenElementsTestTags -import com.tangem.core.ui.test.MainScreenTestTags +import com.tangem.core.ui.test.* import com.tangem.core.ui.utils.LazyListItemPositionSemantics import com.tangem.feature.wallet.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen @@ -35,7 +35,11 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } ) - val synchronizeAddressesButton: KNode = child { + val screenContainer: KNode = child { + hasTestTag(MainScreenTestTags.SCREEN_CONTAINER) + } + + val synchronizeAddressesButton: KNode = lazyList.child { hasText(getResourceString(R.string.common_generate_addresses)) } @@ -44,6 +48,116 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasText(getResourceString(R.string.common_buy)) } + val walletNameText: KNode = child { + hasTestTag(MainScreenTestTags.CARD_TITLE) + useUnmergedTree = true + } + + val walletImage: KNode = child { + hasTestTag(MainScreenTestTags.CARD_IMAGE) + useUnmergedTree = true + } + + val marketPriceBlock: KNode = child { + hasTestTag(MarketPriceBlockTestTags.BLOCK) + useUnmergedTree = true + } + + val marketPriceText: KNode = child { + hasTestTag(MarketPriceBlockTestTags.TEXT) + useUnmergedTree = true + } + + val transactionsExplorerIcon: KNode = child { + hasTestTag(TransactionHistoryBlockTestTags.EXPLORER_ICON) + useUnmergedTree = true + } + + val transactionsTitle: KNode = child { + hasTestTag(TransactionHistoryBlockTestTags.TITLE_TEXT) + hasText(getResourceString(R.string.common_transactions)) + useUnmergedTree = true + } + + fun transactionsExplorer(): KNode { + return child { + hasTestTag(TransactionHistoryBlockTestTags.EXPLORER_TEXT) + hasText(getResourceString(R.string.common_explorer)) + useUnmergedTree = true + } + } + + val notificationContainer: KNode = child { + hasTestTag(NotificationTestTags.CONTAINER) + useUnmergedTree = true + } + + val devCardNotificationIcon: KNode = child { + hasAnySibling(withText(getResourceString(R.string.warning_developer_card_title))) + hasTestTag(NotificationTestTags.ICON) + useUnmergedTree = true + } + + val devCardNotificationTitle: KNode = child { + hasTestTag(NotificationTestTags.TITLE) + hasText(getResourceString(R.string.warning_developer_card_title)) + useUnmergedTree = true + } + + val devCardNotificationMessage: KNode = child { + hasTestTag(NotificationTestTags.MESSAGE) + hasText(getResourceString(R.string.warning_developer_card_message)) + useUnmergedTree = true + } + + val seedPhraseNotificationIcon: KNode = child { + hasAnySibling(withText(getResourceString(R.string.warning_seedphrase_issue_title))) + hasTestTag(NotificationTestTags.ICON) + useUnmergedTree = true + } + + val seedPhraseNotificationTitle: KNode = child { + hasTestTag(NotificationTestTags.TITLE) + hasText(getResourceString(R.string.warning_seedphrase_issue_title)) + useUnmergedTree = true + } + + val seedPhraseNotificationMessage: KNode = child { + hasTestTag(NotificationTestTags.MESSAGE) + hasText(getResourceString(R.string.warning_seedphrase_issue_message)) + useUnmergedTree = true + } + + val totalBalanceContainer: KNode = child { + hasTestTag(MainScreenTestTags.WALLET_LIST_ITEM) + } + + val totalBalanceMenuRenameWallet: KNode = child { + hasTestTag(MainScreenTestTags.TOTAL_BALANCE_MENU_ITEM) + hasText(getResourceString(R.string.common_rename)) + } + + val totalBalanceMenuDeleteWallet: KNode = child { + hasTestTag(MainScreenTestTags.TOTAL_BALANCE_MENU_ITEM) + hasText(getResourceString(R.string.common_delete)) + } + + val totalBalanceText: KNode = child { + hasParent(withTestTag(MainScreenTestTags.WALLET_BALANCE)) + } + + val notificationYesButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_yes)) + useUnmergedTree = true + } + + val notificationNoButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_no)) + useUnmergedTree = true + } + /** * Find token list item with title and address */ @@ -58,19 +172,6 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } - /** - * Find node with wallet balance using lazyList. This construction doesn't affect next step with lazyList. - */ - @OptIn(ExperimentalTestApi::class) - fun walletBalance(): KNode { - return lazyList.childWith { - hasAnyDescendant(withTestTag(MainScreenTestTags.WALLET_LIST_ITEM)) - }.child { - hasTestTag(MainScreenTestTags.WALLET_BALANCE) - useUnmergedTree = true - } - } - @OptIn(ExperimentalTestApi::class) fun organizeTokensButton(): KNode { return lazyList.childWith { @@ -81,6 +182,14 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } + fun organizeTokensButtonWithoutLazySearch(): KNode { + return child { + hasTestTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON) + hasText(getResourceString(R.string.organize_tokens_title)) + useUnmergedTree = true + } + } + fun tokenNetworkGroupTitle(tokenNetwork: String): KNode { return lazyList.child { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) @@ -101,6 +210,12 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } + fun KNode.assertIsUnreachable() { + this { + hasAnyAncestor(withText(getResourceString(R.string.common_unreachable))) + assertIsDisplayed() + } + } /** * This assertion is required to properly verify the token's absence in the semantic tree. diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt new file mode 100644 index 0000000000..4f6707a071 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt @@ -0,0 +1,60 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.MARKETS_MAIN_NETWORK_SUFFIX +import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.MarketsTestTags +import com.tangem.core.ui.test.TopAppBarTestTags +import com.tangem.core.ui.utils.LazyListItemPositionSemantics +import com.tangem.features.onramp.impl.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasText as withText + +class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + private val lazyList = KLazyListNode( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(MarketsTestTags.TOKENS_LIST) }, + itemTypeBuilder = { itemType(::LazyListItemNode) }, + positionMatcher = { position -> + SemanticsMatcher.expectValue( + LazyListItemPositionSemantics, + position + ) + } + ) + + val addToPortfolioButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_add_to_portfolio)) + useUnmergedTree = true + } + + val mainNetworkSwitch: KNode = child { + hasAnyDescendant(withText(MARKETS_MAIN_NETWORK_SUFFIX)) + useUnmergedTree = true + }.child { hasTestTag(MarketsTestTags.ADD_TO_PORTFOLIO_SWITCH) } + + val topBarBackButton: KNode = child { + hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) + useUnmergedTree = true + } + + fun tokenWithTitle(title: String): KNode { + return lazyList.child { + hasText(title) + useUnmergedTree = true + } + } +} + +internal fun BaseTestCase.onMarketsScreen(function: MarketsPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt index f7a76181e8..1d5bd3418b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt @@ -5,8 +5,8 @@ import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.common.extensions.hasLazyListItemPosition import com.tangem.common.utils.LazyListItemNode -import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.test.OrganizeTokensScreenTestTags +import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.utils.LazyListItemPositionSemantics import com.tangem.feature.wallet.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen @@ -14,10 +14,10 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString -import androidx.compose.ui.test.hasText as withText -import androidx.compose.ui.test.hasTestTag as withTestTag -import androidx.compose.ui.test.hasAnySibling as withAnySibling import androidx.compose.ui.test.hasAnyChild as withAnyChild +import androidx.compose.ui.test.hasAnySibling as withAnySibling +import androidx.compose.ui.test.hasTestTag as withTestTag +import androidx.compose.ui.test.hasText as withText class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -25,10 +25,12 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi // region TopBar val title: KNode = child { hasText(getResourceString(R.string.organize_tokens_title)) + useUnmergedTree = true } private val topBarGroupButton: KNode = child { hasTestTag(OrganizeTokensScreenTestTags.GROUP_BUTTON) + useUnmergedTree = true } val groupButton: KNode = topBarGroupButton.child { @@ -100,10 +102,14 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi return lazyList.child { hasTestTag(OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE) useUnmergedTree = true - hasParent(withTestTag(TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK) - .and(withAnySibling(withTestTag(TokenElementsTestTags.TOKEN_TITLE) - .and(withAnyChild(withText(tokenTitle)))) - ) + hasParent( + withTestTag(TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK) + .and( + withAnySibling( + withTestTag(TokenElementsTestTags.TOKEN_TITLE) + .and(withAnyChild(withText(tokenTitle))) + ) + ) ) } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ResetCardPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ResetCardPageObject.kt new file mode 100644 index 0000000000..9bbb4eccce --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/ResetCardPageObject.kt @@ -0,0 +1,65 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.ResetCardScreenTestTags +import com.tangem.wallet.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasTestTag as withTestTag +import androidx.compose.ui.test.hasText as withText + +class ResetCardPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasTestTag(ResetCardScreenTestTags.TITLE) + hasText(getResourceString(R.string.card_settings_reset_card_to_factory)) + useUnmergedTree = true + } + + val attentionImage: KNode = child { + hasTestTag(ResetCardScreenTestTags.ATTENTION_IMAGE) + useUnmergedTree = true + } + + val subtitle: KNode = child { + hasTestTag(ResetCardScreenTestTags.SUBTITLE) + hasText(getResourceString(R.string.common_attention)) + useUnmergedTree = true + } + + val description: KNode = child { + hasTestTag(ResetCardScreenTestTags.DESCRIPTION) + useUnmergedTree = true + } + + val lostWalletAccessCheckBox: KNode = child { + hasTestTag(ResetCardScreenTestTags.CHECKBOX) + hasAnySibling( + withTestTag(ResetCardScreenTestTags.CHECKBOX_TEXT) and + withText(getResourceString(R.string.reset_card_to_factory_condition_1)) + ) + useUnmergedTree = true + } + + val lostPasswordRestoreCheckBox: KNode = child { + hasTestTag(ResetCardScreenTestTags.CHECKBOX) + hasAnySibling( + withTestTag(ResetCardScreenTestTags.CHECKBOX_TEXT) and + withText(getResourceString(R.string.reset_card_to_factory_condition_2)) + ) + useUnmergedTree = true + } + + val resetCardButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.reset_card_to_factory_button_title)) + } +} + +internal fun BaseTestCase.onResetCardScreen(function: ResetCardPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ScanWarningDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ScanWarningDialogPageObject.kt new file mode 100644 index 0000000000..245f08e61d --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/ScanWarningDialogPageObject.kt @@ -0,0 +1,36 @@ +package com.tangem.screens + +import com.kaspersky.kaspresso.screens.KScreen +import com.tangem.wallet.R +import io.github.kakaocup.kakao.text.KTextView +import io.github.kakaocup.kakao.text.KButton + +object ScanWarningDialogPageObject : KScreen() { + + override val layoutId: Int? = null + override val viewClass: Class<*>? = null + + val warningTitle = KTextView { + withText(R.string.common_warning) + } + + val warningMessage = KTextView { + withText(R.string.alert_troubleshooting_scan_card_title) + } + + val tryAgainButton = KButton { + withId(R.id.try_again_button) + } + + val howToScanButton = KButton { + withId(R.id.how_to_scan_button) + } + + val requestSupportButton = KButton { + withId(R.id.request_support_button) + } + + val cancelButton = KButton { + withId(R.id.cancel_button) + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SearchBarPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SearchBarPageObject.kt new file mode 100644 index 0000000000..45e3fae83b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SearchBarPageObject.kt @@ -0,0 +1,19 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseSearchBarTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode + +class SearchBarPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val searchField: KNode = child { + hasTestTag(BaseSearchBarTestTags.SEARCH_BAR) + } +} + +internal fun BaseTestCase.onSearchBar(function: SearchBarPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SelectCountryPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SelectCountryPageObject.kt index b3a6a9d573..c3178f7435 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SelectCountryPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SelectCountryPageObject.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.test.BaseSearchBarTestTags import com.tangem.core.ui.test.SelectCountryBottomSheetTestTags import com.tangem.core.ui.utils.LazyListItemPositionSemantics import com.tangem.features.onramp.impl.R @@ -12,9 +13,8 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString -import androidx.compose.ui.test.hasText as withText import androidx.compose.ui.test.hasTestTag as withTestTag - +import androidx.compose.ui.test.hasText as withText class SelectCountryPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -33,7 +33,7 @@ class SelectCountryPageObject(semanticsProvider: SemanticsNodeInteractionsProvid ) val searchBar: KNode = child { - hasTestTag(SelectCountryBottomSheetTestTags.SEARCH_BAR) + hasTestTag(BaseSearchBarTestTags.SEARCH_BAR) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt new file mode 100644 index 0000000000..8186ff1683 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt @@ -0,0 +1,30 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.SendAddressScreenTestTags +import com.tangem.features.send.v2.impl.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val addressTextField: KNode = child { + hasTestTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD) + useUnmergedTree = true + } + + val nextButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_next)) + useUnmergedTree = true + } + +} + +internal fun BaseTestCase.onSendAddressScreen(function: SendAddressPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt new file mode 100644 index 0000000000..900ead781c --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt @@ -0,0 +1,30 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.TopAppBarTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasTestTag(TopAppBarTestTags.TITLE) + useUnmergedTree = true + } + + val sendButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_send)) + useUnmergedTree = true + } + +} + +internal fun BaseTestCase.onSendConfirmScreen(function: SendConfirmPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/StakingSendPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt similarity index 61% rename from app/src/androidTest/kotlin/com/tangem/screens/StakingSendPageObject.kt rename to app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt index f71c9ad210..f988ad6de5 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/StakingSendPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt @@ -3,7 +3,7 @@ package com.tangem.screens import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.test.BaseButtonTestTags -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags import com.tangem.core.ui.test.TopAppBarTestTags import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen @@ -12,11 +12,11 @@ import io.github.kakaocup.kakao.common.utilities.getResourceString import com.tangem.features.send.v2.impl.R as SendR import androidx.compose.ui.test.hasTestTag as withTestTag -class StakingSendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen(semanticsProvider = semanticsProvider) { +class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { val screenContainer: KNode = child { - hasTestTag(StakingSendScreenTestTags.SCREEN_CONTAINER) + hasTestTag(SendScreenTestTags.SCREEN_CONTAINER) } val title: KNode = child { @@ -25,44 +25,44 @@ class StakingSendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider } val amountContainerTitle: KNode = child { - hasTestTag(StakingSendScreenTestTags.AMOUNT_CONTAINER_TITLE) + hasTestTag(SendScreenTestTags.AMOUNT_CONTAINER_TITLE) useUnmergedTree = true } val amountContainerText: KNode = child { - hasTestTag(StakingSendScreenTestTags.AMOUNT_CONTAINER_TEXT) + hasTestTag(SendScreenTestTags.AMOUNT_CONTAINER_TEXT) useUnmergedTree = true } val amountInputTextField: KNode = child { - hasTestTag(StakingSendScreenTestTags.INPUT_TEXT_FIELD) + hasTestTag(SendScreenTestTags.INPUT_TEXT_FIELD) useUnmergedTree = true } val secondaryAmount: KNode = child { - hasTestTag(StakingSendScreenTestTags.SECONDARY_AMOUNT) + hasTestTag(SendScreenTestTags.SECONDARY_AMOUNT) useUnmergedTree = true } val currencyButton: KNode = child { - hasTestTag(StakingSendScreenTestTags.CURRENCY_BUTTON) - hasAnyChild(withTestTag(StakingSendScreenTestTags.CURRENCY_ICON)) + hasTestTag(SendScreenTestTags.CURRENCY_BUTTON) + hasAnyChild(withTestTag(SendScreenTestTags.CURRENCY_ICON)) useUnmergedTree = true } val fiatButton: KNode = child { - hasTestTag(StakingSendScreenTestTags.CURRENCY_BUTTON) - hasAnyChild(withTestTag(StakingSendScreenTestTags.FIAT_ICON)) + hasTestTag(SendScreenTestTags.CURRENCY_BUTTON) + hasAnyChild(withTestTag(SendScreenTestTags.FIAT_ICON)) useUnmergedTree = true } val maxButton: KNode = child { - hasTestTag(StakingSendScreenTestTags.MAX_BUTTON) + hasTestTag(SendScreenTestTags.MAX_BUTTON) useUnmergedTree = true } val previousButton: KNode = child { - hasTestTag(StakingSendScreenTestTags.PREVIOUS_BUTTON) + hasTestTag(SendScreenTestTags.PREVIOUS_BUTTON) useUnmergedTree = true } @@ -74,5 +74,5 @@ class StakingSendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider } -internal fun BaseTestCase.onStakingSendScreen(function: StakingSendPageObject.() -> Unit) = +internal fun BaseTestCase.onSendScreen(function: SendPageObject.() -> Unit) = onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/StakingSendDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/StakingConfirmPageObject.kt similarity index 75% rename from app/src/androidTest/kotlin/com/tangem/screens/StakingSendDetailsPageObject.kt rename to app/src/androidTest/kotlin/com/tangem/screens/StakingConfirmPageObject.kt index 79b38d7c9c..e9a7eb2983 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/StakingSendDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/StakingConfirmPageObject.kt @@ -3,6 +3,7 @@ package com.tangem.screens import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseAmountBlockTestTags import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.StakingSendDetailsScreenTestTags import com.tangem.core.ui.test.TopAppBarTestTags @@ -11,8 +12,8 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString -class StakingSendDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen(semanticsProvider = semanticsProvider) { +class StakingConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { val title: KNode = child { hasTestTag(TopAppBarTestTags.TITLE) @@ -20,12 +21,12 @@ class StakingSendDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsP } val primaryAmount: KNode = child { - hasTestTag(StakingSendDetailsScreenTestTags.PRIMARY_AMOUNT) + hasTestTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT) useUnmergedTree = true } val secondaryAmount: KNode = child { - hasTestTag(StakingSendDetailsScreenTestTags.SECONDARY_AMOUNT) + hasTestTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT) useUnmergedTree = true } @@ -47,5 +48,5 @@ class StakingSendDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsP } -internal fun BaseTestCase.onStakingSendDetailsScreen(function: StakingSendDetailsPageObject.() -> Unit) = +internal fun BaseTestCase.onStakingConfirmScreen(function: StakingConfirmPageObject.() -> Unit) = onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index dd53bc1dc5..d069f7abe1 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -57,7 +57,7 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } val errorNotificationText: KNode = child { - hasTestTag(NotificationTestTags.TEXT) + hasTestTag(NotificationTestTags.MESSAGE) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index 128a24ad03..3a0cd0962d 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -4,16 +4,18 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase -import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.NotificationTestTags import com.tangem.core.ui.test.TokenDetailsScreenTestTags -import com.tangem.features.tokendetails.impl.R import com.tangem.core.ui.utils.LazyListItemPositionSemantics +import com.tangem.features.tokendetails.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode -import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasText as withText class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -111,6 +113,49 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide hasText(getResourceString(R.string.common_buy)) } + @OptIn(ExperimentalTestApi::class) + val sendButton: LazyListItemNode = horizontalActionChips.childWith { + hasTestTag(TokenDetailsScreenTestTags.ACTION_BUTTON) + hasText(getResourceString(R.string.common_send)) + } + + fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child { + hasAnySibling(withText(getResourceString(R.string.warning_send_blocked_funds_for_fee_title, feeCurrencyName))) + hasTestTag(NotificationTestTags.ICON) + useUnmergedTree = true + } + + fun networkFeeNotificationTitle(feeCurrencyName: String): KNode = child { + hasTestTag(NotificationTestTags.TITLE) + hasText(getResourceString(R.string.warning_send_blocked_funds_for_fee_title, feeCurrencyName)) + useUnmergedTree = true + } + + fun networkFeeNotificationMessage( + currencyName: String, + networkName: String, + feeCurrencyName: String, + feeCurrencySymbol: String, + ): KNode = child { + hasTestTag(NotificationTestTags.MESSAGE) + hasText( + getResourceString( + R.string.warning_send_blocked_funds_for_fee_message, + currencyName, + networkName, + currencyName, + feeCurrencyName, + feeCurrencySymbol + ) + ) + useUnmergedTree = true + } + + fun goToBuyCurrencyButton(feeCurrencySymbol: String): KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_buy_currency, feeCurrencySymbol)) + useUnmergedTree = true + } } internal fun BaseTestCase.onTokenDetailsScreen(function: TokenDetailsPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt new file mode 100644 index 0000000000..c111e92406 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt @@ -0,0 +1,108 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import com.tangem.features.walletconnect.impl.R as WalletConnectImplR + +class WalletConnectBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasText(getResourceString(WalletConnectImplR.string.wc_wallet_connect)) + hasTestTag(WalletConnectBottomSheetTestTags.TITLE) + useUnmergedTree = true + } + + val appIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APP_ICON) + useUnmergedTree = true + } + + val appName: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APP_NAME) + useUnmergedTree = true + } + + val approveIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APPROVE_ICON) + useUnmergedTree = true + } + + val appUrl: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APP_URL) + useUnmergedTree = true + } + + val connectionRequestIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.CONNECTION_REQUEST_ICON) + useUnmergedTree = true + } + + val connectionRequestText: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.CONNECTION_REQUEST_TEXT) + useUnmergedTree = true + } + + val connectionRequestChevron: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.CONNECTION_REQUEST_CHEVRON) + useUnmergedTree = true + } + + val walletIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.WALLET_ICON) + useUnmergedTree = true + } + + val walletNameTitle: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.WALLET_NAME_TITLE) + useUnmergedTree = true + } + + val walletName: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.WALLET_NAME) + useUnmergedTree = true + } + + val networksIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.NETWORKS_ICON) + useUnmergedTree = true + } + + val networksTitle: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.NETWORKS_TITLE) + useUnmergedTree = true + } + + val networksIcons: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.NETWORKS_ICONS) + useUnmergedTree = true + } + + val networksSelectorIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.NETWORKS_SELECTOR_ICON) + useUnmergedTree = true + } + + val cancelButton: KNode = child { + hasText(getResourceString(R.string.common_cancel)) + hasTestTag(BaseButtonTestTags.TEXT) + useUnmergedTree = true + } + + val connectButton: KNode = child { + hasText(getResourceString(R.string.wc_common_connect)) + hasTestTag(BaseButtonTestTags.TEXT) + useUnmergedTree = true + } + +} + +internal fun BaseTestCase.onWalletConnectBottomSheet(function: WalletConnectBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectDetailsBottonSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectDetailsBottonSheetPageObject.kt new file mode 100644 index 0000000000..e4eb48fde7 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectDetailsBottonSheetPageObject.kt @@ -0,0 +1,107 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags +import com.tangem.core.ui.test.WalletConnectDetailsBottomSheetTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import com.tangem.features.walletconnect.impl.R as WalletConnectImplR + +class WalletConnectDetailsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.TITLE) + hasText(getResourceString(WalletConnectImplR.string.wc_connected_app_title)) + useUnmergedTree = true + } + + val date: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.DATE) + useUnmergedTree = true + } + + val closeButton: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.CLOSE_BUTTON) + useUnmergedTree = true + } + + val networksBlockTitle: KNode = child { + hasText(getResourceString(WalletConnectImplR.string.wc_connected_networks)) + useUnmergedTree = true + } + + val appIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APP_ICON) + useUnmergedTree = true + } + + val approveIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APPROVE_ICON) + useUnmergedTree = true + } + + val appName: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APP_NAME) + useUnmergedTree = true + } + + val appUrl: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APP_URL) + useUnmergedTree = true + } + + val walletIcon: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.WALLET_ICON) + useUnmergedTree = true + } + + val walletTitle: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.WALLET_TITLE) + useUnmergedTree = true + } + + val walletName: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.WALLET_NAME) + useUnmergedTree = true + } + + val connectedNetworksTitle: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.NETWORKS_TITLE) + useUnmergedTree = true + } + + val connectedNetworkItem: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_ITEM) + useUnmergedTree = true + } + + val connectedNetworkIcon: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_ICON) + useUnmergedTree = true + } + + val connectedNetworkName: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_NAME) + useUnmergedTree = true + } + + val connectedNetworkSymbol: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_SYMBOL) + useUnmergedTree = true + } + + val disconnectButton: KNode = child { + hasText(getResourceString(WalletConnectImplR.string.common_disconnect)) + hasTestTag(BaseButtonTestTags.TEXT) + useUnmergedTree = true + } + +} + +internal fun BaseTestCase.onWalletConnectDetailsBottomSheet(function: WalletConnectDetailsBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt new file mode 100644 index 0000000000..b9da100325 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt @@ -0,0 +1,59 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.WalletConnectScreenTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class WalletConnectPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasText(getResourceString(R.string.wc_connections)) + useUnmergedTree = true + } + + val moreButton: KNode = child { + hasTestTag(WalletConnectScreenTestTags.MORE_BUTTON) + useUnmergedTree = true + } + + val walletName: KNode = child { + hasTestTag(WalletConnectScreenTestTags.WALLET_NAME) + useUnmergedTree = true + } + + val appIcon: KNode = child { + hasTestTag(WalletConnectScreenTestTags.APP_ICON) + useUnmergedTree = true + } + + val appName: KNode = child { + hasTestTag(WalletConnectScreenTestTags.APP_NAME) + useUnmergedTree = true + } + + val approveIcon: KNode = child { + hasTestTag(WalletConnectScreenTestTags.APPROVE_ICON) + useUnmergedTree = true + } + + val appUrl: KNode = child { + hasTestTag(WalletConnectScreenTestTags.APP_URL) + useUnmergedTree = true + } + + val newConnectionButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.wc_new_connection)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onWalletConnectScreen(function: WalletConnectPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt index 6a2a606ef5..e8b554846b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt @@ -21,7 +21,7 @@ class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi val linkMoreCardsButton: KNode = walletSettingsItem.child { hasText(getResourceString(R.string.details_row_title_create_backup)) } - val cardSettingsButton: KNode = walletSettingsItem.child { + val deviceSettingsButton: KNode = walletSettingsItem.child { hasText(getResourceString(R.string.card_settings_title)) } val referralProgramButton: KNode = walletSettingsItem.child { diff --git a/app/src/androidTest/kotlin/com/tangem/steps/SingleCurrencyCardScenario.kt b/app/src/androidTest/kotlin/com/tangem/steps/SingleCurrencyCardScenario.kt new file mode 100644 index 0000000000..f9c68f5096 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/steps/SingleCurrencyCardScenario.kt @@ -0,0 +1,35 @@ +package com.tangem.steps + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.assertElementDoesNotExist +import com.tangem.screens.onMainScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.checkSingleCurrencyMainScreen(cardBlockchain: String, cardTitle: String) { + step("Assert card title equal '$cardTitle'") { + onMainScreen { walletNameText.assertTextEquals(cardTitle) } + } + step("Assert 'Transactions' block is displayed") { + onMainScreen { transactionsExplorer().assertExists() } + } + step("Assert 'Transactions' title is displayer") { + onMainScreen { transactionsTitle.assertIsDisplayed() } + } + step("Assert 'Explorer Icon' is displayed") { + onMainScreen { transactionsExplorerIcon.assertIsDisplayed() } + } + step("Assert card image is displayed") { //TODO: Придумать нормальную проверку / реализовать скриншот-тестинг + onMainScreen { walletImage.assertIsDisplayed() } + } + step("Assert 'Market Price' on single card main screen is displayed") { + onMainScreen { marketPriceBlock.assertIsDisplayed() } + } + step("Assert 'Market Price' title equals $cardBlockchain Market Price") { + onMainScreen { marketPriceText.assertTextContains("$cardBlockchain Market Price") } + } + step("Assert 'Organize tokens' button is not displayed") { + onMainScreen { + assertElementDoesNotExist({ organizeTokensButtonWithoutLazySearch() }, "Organize tokens button") + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt index f553c1cbe0..abea9913ef 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt @@ -6,8 +6,9 @@ import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState -import com.tangem.scenarios.OpenMainScreenScenario import com.tangem.screens.* +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -36,13 +37,10 @@ class BuyTokenTest : BaseTestCase() { setWireMockScenarioState(scenarioName, "Error") } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -84,13 +82,10 @@ class BuyTokenTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -175,13 +170,10 @@ class BuyTokenTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -250,13 +242,10 @@ class BuyTokenTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -339,13 +328,10 @@ class BuyTokenTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -437,13 +423,10 @@ class BuyTokenTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt index df318a9456..49fe556eba 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt @@ -3,7 +3,7 @@ package com.tangem.tests import com.tangem.common.BaseTestCase import com.tangem.common.extensions.clickWithAssertion import com.tangem.domain.models.scan.ProductType -import com.tangem.scenarios.OpenMainScreenScenario +import com.tangem.scenarios.openMainScreen import com.tangem.screens.onDetailsScreen import com.tangem.screens.onReferralProgramScreen import com.tangem.screens.onTopBar @@ -19,7 +19,9 @@ class DetailsTest : BaseTestCase() { @Test fun walletWithoutBackupDetailsTest() = setupHooks().run { - scenario(OpenMainScreenScenario(composeTestRule)) + step("Open 'Main Screen'") { + openMainScreen() + } onTopBar { step("Open wallet details") { moreButton.clickWithAssertion() @@ -53,7 +55,7 @@ class DetailsTest : BaseTestCase() { linkMoreCardsButton.assertIsDisplayed() } step("Assert 'Card Settings' button is visible") { - cardSettingsButton.assertIsDisplayed() + deviceSettingsButton.assertIsDisplayed() } step("Assert 'Referral program' button is visible") { referralProgramButton.assertIsDisplayed() @@ -67,7 +69,9 @@ class DetailsTest : BaseTestCase() { // @Test fun wallet2DetailsTest() = setupHooks().run { - scenario(OpenMainScreenScenario(composeTestRule, ProductType.Wallet2)) + step("Open 'Main Screen'") { + openMainScreen(productType = ProductType.Wallet2, alreadyActivatedDialogIsShown = true) + } onTopBar { step("Open wallet details") { moreButton.clickWithAssertion() @@ -101,7 +105,7 @@ class DetailsTest : BaseTestCase() { linkMoreCardsButton.assertIsNotDisplayed() } step("Assert 'Card Settings' button is visible") { - cardSettingsButton.assertIsDisplayed() + deviceSettingsButton.assertIsDisplayed() } step("Assert 'Referral program' button is visible") { referralProgramButton.assertIsDisplayed() @@ -115,7 +119,9 @@ class DetailsTest : BaseTestCase() { @Test fun noteDetailsTest() = setupHooks().run { - scenario(OpenMainScreenScenario(composeTestRule, ProductType.Note)) + step("Open 'Main Screen'") { + openMainScreen(ProductType.Note) + } onTopBar { step("Open wallet details") { moreButton.clickWithAssertion() @@ -146,7 +152,7 @@ class DetailsTest : BaseTestCase() { } onWalletSettingsScreen { step("Assert 'Card Settings' button is visible") { - cardSettingsButton.assertIsDisplayed() + deviceSettingsButton.assertIsDisplayed() } step("Assert 'Referral program' button does not exist") { referralProgramButton.assertIsNotDisplayed() @@ -162,7 +168,9 @@ class DetailsTest : BaseTestCase() { @Test fun validateReferralProgramScreenTest() = setupHooks().run { - scenario(OpenMainScreenScenario(composeTestRule)) + step("Open 'Main Screen'") { + openMainScreen() + } step("Open wallet details") { onTopBar { moreButton.clickWithAssertion() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt new file mode 100644 index 0000000000..9742ee2362 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -0,0 +1,181 @@ +package com.tangem.tests + +import androidx.test.internal.runner.junit4.statement.UiThreadStatement.runOnUiThread +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT +import com.tangem.common.core.TangemSdkError +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.redux.StateDialog +import com.tangem.scenarios.* +import com.tangem.screens.* +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.requestSupportButton +import com.tangem.tap.common.redux.global.GlobalAction +import com.tangem.tap.domain.sdk.mocks.MockProvider +import com.tangem.tap.store +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.Allure +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class FeedbackTest : BaseTestCase() { + + @AllureId("894") + @DisplayName("Send feedback: from details") + @Test + fun sendFeedbackFromDetailsTest() { + setupHooks( + additionalAfterSection = { + device.uiDevice.pressBack() + } + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Click 'More' button on TopBar") { + onTopBar { moreButton.clickWithAssertion() } + } + step("Click 'Contact support' button") { + onDetailsScreen { contactSupportButton.clickWithAssertion() } + } + step("Check 'Contact support' intent is called") { + checkSendEMailIntentCalled() + } + } + } + + @AllureId("893") + @DisplayName("Send feedback: failed transaction") + @Test + fun sendFeedbackFromFailedTransactionTest() { + val balance = TOTAL_BALANCE + val tokenName = "Polygon" + val recipientAddress = RECIPIENT_ADDRESS + val sendAmount = "1" + + setupHooks( + additionalAfterSection = { + device.uiDevice.pressBack() + } + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Click 'Send' button") { + onTokenDetailsScreen { sendButton.performClick() } + } + step("Type '$sendAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(sendAmount) + } + } + step("Assert input text field has value: '$sendAmount'") { + onSendScreen { amountInputTextField.assertTextContains(value = sendAmount, substring = true) } + } + step("Click 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Enter address") { + onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) } + } + step("Click 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Click 'Send' button") { + onSendConfirmScreen { + sendButton.assertIsEnabled() + sendButton.performClick() + } + } + step("Check 'Failed transaction' dialog") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkFailedTransactionDialog() + } + } + step("Click on 'Support' button") { + onFailedTransactionDialog { supportButton.performClick() } + } + step("Check 'Contact support' intent is called") { + checkSendEMailIntentCalled() + } + } + } + + @AllureId("3985") + @DisplayName("Send feedback: from 'Warning' dialog after card scan") + @Test + fun sendFeedbackFromScanScreenTest() { + setupHooks( + additionalAfterSection = { + device.uiDevice.pressBack() + MockProvider.resetEmulateError() + } + ).run { + Allure.step("Click on 'Accept' button") { + onDisclaimerScreen { acceptButton.clickWithAssertion() } + } + step("Set scanning error") { + MockProvider.setEmulateError(TangemSdkError.TagLost()) + } + step("Click on 'Scan' button") { + onStoriesScreen { scanButton.performClick() } + } + step("Force show 'Scan warning' dialog"){ + runOnUiThread { + val scanFailsState = StateDialog.ScanFailsDialog(source = StateDialog.ScanFailsSource.MAIN) + store.dispatch(GlobalAction.ShowDialog(scanFailsState)) + } + } + step("Check 'Scan warning' dialog") { + checkScanWarningDialog() + } + step("Click on 'Request support' button") { + ScanWarningDialogPageObject { requestSupportButton.click() } + } + step("Check 'Contact support' intent is called") { + checkSendEMailIntentCalled() + } + } + } + + @AllureId("3986") + @DisplayName("Send feedback: from scan already used wallet alert dialog") + @Test + fun sendFeedbackAfterScanAlreadyUsedWalletTest() { + setupHooks( + additionalAfterSection = { + device.uiDevice.pressBack() + } + ).run { + step("Set mocks for Wallet2") { + MockProvider.setMocks(ProductType.Wallet2) + } + step("Click on 'Accept' button") { + onDisclaimerScreen { acceptButton.clickWithAssertion() } + } + step("Click on 'Scan' button") { + onStoriesScreen { scanButton.clickWithAssertion() } + } + step("Check 'Already used Wallet' dialog") { + checkAlreadyUsedWalletDialog() + } + step("Click on 'Request support' button") { + AlreadyUsedWalletDialogPageObject { requestSupportButton.click() } + } + step("Check 'Contact support' intent is called") { + checkSendEMailIntentCalled() + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt index 706bd497d5..fa4519eede 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt @@ -3,7 +3,8 @@ package com.tangem.tests import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.extensions.clickWithAssertion -import com.tangem.scenarios.OpenMainScreenScenario +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.* import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId @@ -21,13 +22,10 @@ class HideTokenTest : BaseTestCase() { val balance = TOTAL_BALANCE setupHooks().run { step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button" ) { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } @@ -35,7 +33,7 @@ class HideTokenTest : BaseTestCase() { step("Assert 'Token details screen' open") { onTokenDetailsScreen { screenContainer.assertIsDisplayed() } } - step("Click 'More button'") { + step("Click 'More' button") { onTokenDetailsTopBar { moreButton.clickWithAssertion() } } step("Click 'Hide token' button") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/MainScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/MainScreenTest.kt index c708b29087..cf7f5747e6 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/MainScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/MainScreenTest.kt @@ -1,7 +1,7 @@ package com.tangem.tests import com.tangem.common.BaseTestCase -import com.tangem.scenarios.OpenMainScreenScenario +import com.tangem.scenarios.openMainScreen import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @@ -11,7 +11,9 @@ class MainScreenTest : BaseTestCase() { @Test fun goToMain() { setupHooks().run { - scenario(OpenMainScreenScenario(composeTestRule)) + step("Open 'Main Screen'") { + openMainScreen() + } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt index 76d8e34a7a..76d0d0f0f6 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt @@ -3,9 +3,11 @@ package com.tangem.tests import androidx.compose.ui.test.onAllNodesWithText import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.common.extensions.SwipeDirection import com.tangem.common.extensions.clickWithAssertion -import com.tangem.common.extensions.swipeUp -import com.tangem.scenarios.OpenMainScreenScenario +import com.tangem.common.extensions.swipeVertical +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.onMainScreen import com.tangem.screens.onOrganizeTokensScreen import dagger.hilt.android.testing.HiltAndroidTest @@ -23,15 +25,16 @@ class OrganizeTokensTest : BaseTestCase() { setupHooks().run { val tokenTitle = "Ethereum" val tokenNetwork = "Ethereum network" + step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button" ) { + step("Click on 'Synchronize addresses' button") { onMainScreen { synchronizeAddressesButton.clickWithAssertion() } } step("Swipe to 'Organize tokens' button") { - swipeUp() - swipeUp() + swipeVertical(SwipeDirection.UP) + swipeVertical(SwipeDirection.UP) } step("Click 'Organize tokens' button") { onMainScreen { organizeTokensButton().clickWithAssertion() } @@ -55,8 +58,8 @@ class OrganizeTokensTest : BaseTestCase() { onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() } } step("Swipe to 'Organize tokens' button") { - swipeUp() - swipeUp() + swipeVertical(SwipeDirection.UP) + swipeVertical(SwipeDirection.UP) } step("Click 'Organize tokens' button") { onMainScreen { organizeTokensButton().clickWithAssertion() } @@ -90,14 +93,12 @@ class OrganizeTokensTest : BaseTestCase() { val ethereumTitle = "Ethereum" val bitcoinTitle = "Bitcoin" val balance = TOTAL_BALANCE + step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button" ) { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Check positions of tokens on 'Main Screen'") { onMainScreen { @@ -106,8 +107,8 @@ class OrganizeTokensTest : BaseTestCase() { } } step("Swipe to 'Organize tokens' button") { - swipeUp() - swipeUp() + swipeVertical(SwipeDirection.UP) + swipeVertical(SwipeDirection.UP) } step("Click 'Organize tokens' button") { onMainScreen { organizeTokensButton().clickWithAssertion() } @@ -129,10 +130,11 @@ class OrganizeTokensTest : BaseTestCase() { setupHooks().run { val ethereumTitle = "Ethereum" val bitcoinTitle = "Bitcoin" + step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button" ) { + step("Click on 'Synchronize addresses' button") { onMainScreen { synchronizeAddressesButton.clickWithAssertion() } } step("Check positions of tokens on 'Main Screen'") { @@ -142,8 +144,8 @@ class OrganizeTokensTest : BaseTestCase() { } } step("Swipe to 'Organize tokens' button") { - swipeUp() - swipeUp() + swipeVertical(SwipeDirection.UP) + swipeVertical(SwipeDirection.UP) } step("Click 'Organize tokens' button") { onMainScreen { organizeTokensButton().clickWithAssertion() } @@ -178,14 +180,12 @@ class OrganizeTokensTest : BaseTestCase() { val polygonTitle = "Polygon" val polExMaticTitle = "POL (ex-MATIC)" val balance = TOTAL_BALANCE + step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button" ) { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Check positions of tokens on 'Main Screen'") { onMainScreen { @@ -195,8 +195,8 @@ class OrganizeTokensTest : BaseTestCase() { } } step("Swipe to 'Organize tokens' button") { - swipeUp() - swipeUp() + swipeVertical(SwipeDirection.UP) + swipeVertical(SwipeDirection.UP) } step("Click 'Organize tokens' button") { onMainScreen { organizeTokensButton().clickWithAssertion() } @@ -235,6 +235,4 @@ class OrganizeTokensTest : BaseTestCase() { } } } - - } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/ResetCardTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/ResetCardTest.kt new file mode 100644 index 0000000000..50a5f44b6d --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/ResetCardTest.kt @@ -0,0 +1,83 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.domain.models.scan.ProductType +import com.tangem.scenarios.* +import com.tangem.tap.domain.sdk.mocks.content.BackupWalletMockContent +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class ResetCardTest : BaseTestCase() { + + @AllureId("3988") + @DisplayName("Reset card: reset Wallet 1.0 card without backup") + @Test + fun resetWallet1CardWithoutBackupTest() { + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Open 'Device settings' screen") { + openDeviceSettingsScreen() + } + step("Open 'Reset card' screen") { + openResetCardScreen() + } + step("Check 'Reset card' screen") { + checkResetCardScreen() + } + step("Check checkbox logic") { + checkCheckBoxLogic() + } + } + } + + @AllureId("3987") + @DisplayName("Reset card: reset Wallet 1.0 card with backup") + @Test + fun resetWallet1CardWithBackupTest() { + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen(mockContent = BackupWalletMockContent) + } + step("Open 'Device settings' screen") { + openDeviceSettingsScreen() + } + step("Open 'Reset card' screen") { + openResetCardScreen(withBackup = true) + } + step("Check 'Reset card' screen") { + checkResetCardScreen(withBackup = true) + } + step("Check checkbox logic") { + checkCheckBoxLogic(withBackup = true) + } + } + } + + @AllureId("3974") + @DisplayName("Reset card: reset Wallet 2.0 card with backup") + @Test + fun resetWallet2CardWithBackupTest() { + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen(productType = ProductType.Wallet2, alreadyActivatedDialogIsShown = true) + } + step("Open 'Device settings' screen") { + openDeviceSettingsScreen() + } + step("Open 'Reset card' screen") { + openResetCardScreen(withBackup = true) + } + step("Check 'Reset card' screen") { + checkResetCardScreen(withBackup = true) + } + step("Check checkbox logic") { + checkCheckBoxLogic(withBackup = true) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt new file mode 100644 index 0000000000..7cf0a321e3 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt @@ -0,0 +1,69 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onMainScreen +import com.tangem.screens.onTokenDetailsScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SendTest : BaseTestCase() { + + @AllureId("3645") + @DisplayName("Send: check fee notification") + @Test + fun checkFeeNotificationTest() { + val currencyName = "POL (ex-MATIC)" + val feeCurrencyName = "Ethereum" + val feeCurrencySymbol = "ETH" + val balance = "$763.55" + val scenarioName = "eth_network_balance" + val scenarioState = "Empty" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Click on token with name: $currencyName") { + onMainScreen { tokenWithTitleAndAddress(currencyName).clickWithAssertion() } + } + step("Assert 'Insufficient $feeCurrencyName to cover network fee' notification icon is displayed") { + onTokenDetailsScreen { networkFeeNotificationIcon(feeCurrencyName).assertIsDisplayed() } + } + step("Assert 'Insufficient $feeCurrencyName to cover network fee' notification title is displayed") { + onTokenDetailsScreen { networkFeeNotificationTitle(feeCurrencyName).assertIsDisplayed() } + } + step("Assert 'Insufficient $feeCurrencyName to cover network fee' notification text is displayed") { + onTokenDetailsScreen { + networkFeeNotificationMessage( + currencyName, + feeCurrencyName, + feeCurrencyName, + feeCurrencySymbol + ).assertIsDisplayed() + } + } + step("Assert 'Go to $feeCurrencySymbol' button is displayed") { + onTokenDetailsScreen { goToBuyCurrencyButton(feeCurrencySymbol).assertIsDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/SingleCurrencyCardsScan.kt b/app/src/androidTest/kotlin/com/tangem/tests/SingleCurrencyCardsScan.kt new file mode 100644 index 0000000000..34d745794f --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/SingleCurrencyCardsScan.kt @@ -0,0 +1,31 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.domain.models.scan.ProductType +import com.tangem.scenarios.openMainScreen +import com.tangem.steps.checkSingleCurrencyMainScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SingleCurrencyCardsScan : BaseTestCase() { + + @AllureId("868") + @DisplayName("Scan: Scanning single-currency cards") + @Test + fun singleTokenNoteScanTest() { + val cardBlockchain = "DOGE" + val cardType: ProductType = ProductType.Note + + setupHooks().run { + step("Open 'Main Screen' on ${cardType.name} card") { + openMainScreen(cardType) + } + step("Check 'Main' screen for ${cardType.name} $cardBlockchain card") { + checkSingleCurrencyMainScreen(cardBlockchain = cardBlockchain, cardTitle = cardType.name) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt index 083478f475..5eb2c98591 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt @@ -5,8 +5,9 @@ import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState -import com.tangem.scenarios.OpenMainScreenScenario import com.tangem.screens.* +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -20,7 +21,7 @@ class StakingTest : BaseTestCase() { @Test fun validateStakingBlockTest() { val tokenTitle = "POL (ex-MATIC)" - val balance = TOTAL_BALANCE + val balance = "$3,299.37" val scenarioName = "staking_eth_pol_balances_android" val scenarioState = "Staked" @@ -35,13 +36,13 @@ class StakingTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } + step("Synchronize addresses") { + synchronizeAddresses(balance) } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Assert 'Organize tokens' button is displayed") { + onMainScreen { organizeTokensButton().assertIsDisplayed() } } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } @@ -75,7 +76,7 @@ class StakingTest : BaseTestCase() { @Test fun validateStakingMoreScreensTest() { val tokenTitle = "POL (ex-MATIC)" - val balance = TOTAL_BALANCE + val balance = "$3,299.37" val scenarioName = "staking_eth_pol_balances_android" val scenarioState = "Staked" val stakingAmount = "1" @@ -91,13 +92,13 @@ class StakingTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } + step("Synchronize addresses") { + synchronizeAddresses(balance) } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Assert 'Organize tokens' button is displayed") { + onMainScreen { organizeTokensButton().assertIsDisplayed() } } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } @@ -151,73 +152,73 @@ class StakingTest : BaseTestCase() { onStakingDetailsScreen { stakeMoreButton.performClick() } } step("Assert 'Send' screen is displayed") { - onStakingSendScreen { screenContainer.assertIsDisplayed() } + onSendScreen { screenContainer.assertIsDisplayed() } } step("Assert 'Send' screen title is displayed") { - onStakingSendScreen { title.assertIsDisplayed() } + onSendScreen { title.assertIsDisplayed() } } step("Assert amount container title is displayed") { - onStakingSendScreen { amountContainerTitle.assertIsDisplayed() } + onSendScreen { amountContainerTitle.assertIsDisplayed() } } step("Assert amount container text is displayed") { - onStakingSendScreen { amountContainerText.assertIsDisplayed() } + onSendScreen { amountContainerText.assertIsDisplayed() } } step("Assert input text field is displayed") { - onStakingSendScreen { amountInputTextField.assertIsDisplayed() } + onSendScreen { amountInputTextField.assertIsDisplayed() } } step("Assert secondary amount is displayed") { - onStakingSendScreen { secondaryAmount.assertIsDisplayed() } + onSendScreen { secondaryAmount.assertIsDisplayed() } } step("Type '$stakingAmount' in input text field") { - onStakingSendScreen { + onSendScreen { amountInputTextField.performClick() amountInputTextField.performTextReplacement(stakingAmount) } } step("Assert input text field has value: '$stakingAmount'") { - onStakingSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } + onSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } } step("Assert currency button is displayed") { - onStakingSendScreen { currencyButton.assertIsDisplayed() } + onSendScreen { currencyButton.assertIsDisplayed() } } step("Assert fiat button is displayed") { - onStakingSendScreen { fiatButton.assertIsDisplayed() } + onSendScreen { fiatButton.assertIsDisplayed() } } step("Assert currency button is displayed") { - onStakingSendScreen { currencyButton.assertIsDisplayed() } + onSendScreen { currencyButton.assertIsDisplayed() } } step("Assert fiat button is displayed") { - onStakingSendScreen { fiatButton.assertIsDisplayed() } + onSendScreen { fiatButton.assertIsDisplayed() } } step("Assert 'Max' button is displayed") { - onStakingSendScreen { maxButton.assertIsDisplayed() } + onSendScreen { maxButton.assertIsDisplayed() } } step("Assert previous button is displayed") { - onStakingSendScreen { previousButton.assertIsDisplayed() } + onSendScreen { previousButton.assertIsDisplayed() } } step("Assert 'Next' button is displayed") { - onStakingSendScreen { nextButton.assertIsDisplayed() } + onSendScreen { nextButton.assertIsDisplayed() } } step("Click on 'Next' button") { - onStakingSendScreen { nextButton.performClick() } + onSendScreen { nextButton.performClick() } } step("Assert 'Send details' screen title is displayed") { - onStakingSendDetailsScreen { title.assertIsDisplayed() } + onStakingConfirmScreen { title.assertIsDisplayed() } } step("Assert primary amount is displayed") { - onStakingSendDetailsScreen { primaryAmount.assertIsDisplayed() } + onStakingConfirmScreen { primaryAmount.assertIsDisplayed() } } step("Assert secondary amount is displayed") { - onStakingSendDetailsScreen { secondaryAmount.assertIsDisplayed() } + onStakingConfirmScreen { secondaryAmount.assertIsDisplayed() } } step("Assert 'Validator' block is displayed") { - onStakingSendDetailsScreen { validatorBlock.assertIsDisplayed() } + onStakingConfirmScreen { validatorBlock.assertIsDisplayed() } } step("Assert 'Network Fee' block is displayed") { - onStakingSendDetailsScreen { networkFeeBlock.assertIsDisplayed() } + onStakingConfirmScreen { networkFeeBlock.assertIsDisplayed() } } step("Assert 'Stake' button is displayed") { - onStakingSendDetailsScreen { stakeButton.assertIsDisplayed() } + onStakingConfirmScreen { stakeButton.assertIsDisplayed() } } } } @@ -243,13 +244,13 @@ class StakingTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } + step("Synchronize addresses") { + synchronizeAddresses(balance) } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Assert 'Organize tokens' button is displayed") { + onMainScreen { organizeTokensButton().assertIsDisplayed() } } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } @@ -306,73 +307,73 @@ class StakingTest : BaseTestCase() { onStakingDetailsScreen { stakeButton.performClick() } } step("Assert 'Send' screen is displayed") { - onStakingSendScreen { screenContainer.assertIsDisplayed() } + onSendScreen { screenContainer.assertIsDisplayed() } } step("Assert 'Send' screen title is displayed") { - onStakingSendScreen { title.assertIsDisplayed() } + onSendScreen { title.assertIsDisplayed() } } step("Assert amount container title is displayed") { - onStakingSendScreen { amountContainerTitle.assertIsDisplayed() } + onSendScreen { amountContainerTitle.assertIsDisplayed() } } step("Assert amount container text is displayed") { - onStakingSendScreen { amountContainerText.assertIsDisplayed() } + onSendScreen { amountContainerText.assertIsDisplayed() } } step("Assert input text field is displayed") { - onStakingSendScreen { amountInputTextField.assertIsDisplayed() } + onSendScreen { amountInputTextField.assertIsDisplayed() } } step("Assert secondary amount is displayed") { - onStakingSendScreen { secondaryAmount.assertIsDisplayed() } + onSendScreen { secondaryAmount.assertIsDisplayed() } } step("Type '$stakingAmount' in input text field") { - onStakingSendScreen { + onSendScreen { amountInputTextField.performClick() amountInputTextField.performTextReplacement(stakingAmount) } } step("Assert input text field has value: '$stakingAmount'") { - onStakingSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } + onSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } } step("Assert currency button is displayed") { - onStakingSendScreen { currencyButton.assertIsDisplayed() } + onSendScreen { currencyButton.assertIsDisplayed() } } step("Assert fiat button is displayed") { - onStakingSendScreen { fiatButton.assertIsDisplayed() } + onSendScreen { fiatButton.assertIsDisplayed() } } step("Assert currency button is displayed") { - onStakingSendScreen { currencyButton.assertIsDisplayed() } + onSendScreen { currencyButton.assertIsDisplayed() } } step("Assert fiat button is displayed") { - onStakingSendScreen { fiatButton.assertIsDisplayed() } + onSendScreen { fiatButton.assertIsDisplayed() } } step("Assert 'Max' button is displayed") { - onStakingSendScreen { maxButton.assertIsDisplayed() } + onSendScreen { maxButton.assertIsDisplayed() } } step("Assert previous button is displayed") { - onStakingSendScreen { previousButton.assertIsDisplayed() } + onSendScreen { previousButton.assertIsDisplayed() } } step("Assert 'Next' button is displayed") { - onStakingSendScreen { nextButton.assertIsDisplayed() } + onSendScreen { nextButton.assertIsDisplayed() } } step("Click on 'Next' button") { - onStakingSendScreen { nextButton.performClick() } + onSendScreen { nextButton.performClick() } } step("Assert 'Send details' screen title is displayed") { - onStakingSendDetailsScreen { title.assertIsDisplayed() } + onStakingConfirmScreen { title.assertIsDisplayed() } } step("Assert primary amount is displayed") { - onStakingSendDetailsScreen { primaryAmount.assertIsDisplayed() } + onStakingConfirmScreen { primaryAmount.assertIsDisplayed() } } step("Assert secondary amount is displayed") { - onStakingSendDetailsScreen { secondaryAmount.assertIsDisplayed() } + onStakingConfirmScreen { secondaryAmount.assertIsDisplayed() } } step("Assert 'Validator' block is displayed") { - onStakingSendDetailsScreen { validatorBlock.assertIsDisplayed() } + onStakingConfirmScreen { validatorBlock.assertIsDisplayed() } } step("Assert 'Network Fee' block is displayed") { - onStakingSendDetailsScreen { networkFeeBlock.assertIsDisplayed() } + onStakingConfirmScreen { networkFeeBlock.assertIsDisplayed() } } step("Assert 'Stake' button is displayed") { - onStakingSendDetailsScreen { stakeButton.assertIsDisplayed() } + onStakingConfirmScreen { stakeButton.assertIsDisplayed() } } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt index 638218b875..887bffc37a 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt @@ -9,8 +9,9 @@ import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.extensions.clickWithAssertion import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiEnvironment -import com.tangem.scenarios.OpenMainScreenScenario import com.tangem.screens.* +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -32,13 +33,10 @@ class SwapTokenTest : BaseTestCase() { val balance = TOTAL_BALANCE step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } @@ -111,13 +109,10 @@ class SwapTokenTest : BaseTestCase() { val balance = TOTAL_BALANCE step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } @@ -149,7 +144,7 @@ class SwapTokenTest : BaseTestCase() { @ApiEnv( ApiEnvConfig(ApiConfig.ID.Express, ApiEnvironment.PROD) ) - @AllureId("3546") + @AllureId("3547") @DisplayName("Swap: change network fee") @Test fun changeNetworkFeeTest() { @@ -159,13 +154,10 @@ class SwapTokenTest : BaseTestCase() { val balance = TOTAL_BALANCE step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt index 7f729f1c3c..379b5b898e 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt @@ -2,8 +2,9 @@ package com.tangem.tests import androidx.test.InstrumentationRegistry.getTargetContext import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.SwipeDirection import com.tangem.common.extensions.clickWithAssertion -import com.tangem.common.extensions.swipeUp +import com.tangem.common.extensions.swipeVertical import com.tangem.screens.onDisclaimerScreen import com.tangem.screens.onStoriesScreen import dagger.hilt.android.testing.HiltAndroidTest @@ -37,7 +38,8 @@ class TermsOfServiceTest : BaseTestCase() { step("Assert 'Stories' screen is opened") { onStoriesScreen { scanButton.assertIsDisplayed() - orderButton.assertIsDisplayed()} + orderButton.assertIsDisplayed() + } } } } @@ -67,7 +69,7 @@ class TermsOfServiceTest : BaseTestCase() { device.uiDevice.pressRecentApps() } step("Stop app by swipe") { - swipeUp(startHeightRatio = 0.5f) + swipeVertical(SwipeDirection.UP, startHeightRatio = 0.5f) } step("Launch app") { device.apps.launch(packageName) @@ -90,7 +92,7 @@ class TermsOfServiceTest : BaseTestCase() { device.uiDevice.pressRecentApps() } step("Stop app by swipe") { - swipeUp(startHeightRatio = 0.5f) + swipeVertical(SwipeDirection.UP, startHeightRatio = 0.5f) } step("Launch app") { device.apps.launch(packageName) @@ -98,9 +100,9 @@ class TermsOfServiceTest : BaseTestCase() { step("Assert 'Stories' screen is opened") { onStoriesScreen { scanButton.assertIsDisplayed() - orderButton.assertIsDisplayed()} + orderButton.assertIsDisplayed() + } } } } - } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt new file mode 100644 index 0000000000..db5e364f20 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt @@ -0,0 +1,181 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.common.extensions.SwipeDirection +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.swipeVertical +import com.tangem.common.utils.getWcUri +import com.tangem.scenarios.* +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Ignore +import org.junit.Test + +@HiltAndroidTest +class WalletConnectTest : BaseTestCase() { + + @AllureId("3958") + @DisplayName("WC (React App): open session from deeplink on main screen") + @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") + @Test + fun openWalletConnectSessionOnMainScreen() { + val balance = TOTAL_BALANCE + val dAppName = "React App" + val deepLinkUri = getWcUri() + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Create WC session buy deeplink") { + openAppByDeepLink(deepLinkUri) + } + step("Check 'Wallet Connect' bottom sheet") { + checkWalletConnectBottomSheet() + } + step("Click on 'Connect' button") { + onWalletConnectBottomSheet { connectButton.performClick() } + } + step("Click 'More' button on TopBar") { + onTopBar { moreButton.clickWithAssertion() } + } + step("Click on 'Wallet Connect' button") { + onDetailsScreen { walletConnectButton.clickWithAssertion() } + } + step("Check 'Wallet Connect' screen") { + checkWalletConnectScreen() + } + step("Click on app icon") { + onWalletConnectScreen { appIcon.performClick() } + } + step("Check 'Wallet Connect' details bottom sheet") { + checkWalletConnectDetailsBottomSheet(dAppName) + } + step("Click on 'Disconnect button' is displayed") { + onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } + } + step("Assert connection is not displayed") { + onWalletConnectScreen { appName.assertIsNotDisplayed() } + } + } + } + + @AllureId("3959") + @DisplayName("WC (React App): open session from deeplink not on main screen") + @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") + @Test + fun openWalletConnectSessionNotOnMainScreen() { + val balance = TOTAL_BALANCE + val dAppName = "React App" + val deepLinkUri = getWcUri() + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Click on 'Buy' button") { + onMainScreen { buyButton.clickWithAssertion() } + } + step("Create WC session buy deeplink") { + openAppByDeepLink(deepLinkUri) + } + step("Check 'Wallet Connect' bottom sheet") { + checkWalletConnectBottomSheet() + } + step("Click on 'Connect' button") { + onWalletConnectBottomSheet { connectButton.performClick() } + } + step("Click 'More' button on TopBar") { + onTopBar { moreButton.clickWithAssertion() } + } + step("Click on 'Wallet Connect' button") { + onDetailsScreen { walletConnectButton.clickWithAssertion() } + } + step("Assert 'Wallet Connect' bottom sheet is displayed") { + onWalletConnectBottomSheet { connectButton.clickWithAssertion() } + } + step("Check 'Wallet Connect' screen") { + checkWalletConnectScreen() + } + step("Click on app icon") { + onWalletConnectScreen { appIcon.performClick() } + } + step("Check 'Wallet Connect' details bottom sheet") { + checkWalletConnectDetailsBottomSheet(dAppName) + } + step("Click on 'Disconnect button' is displayed") { + onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } + } + step("Assert connection is not displayed") { + onWalletConnectScreen { appName.assertIsNotDisplayed() } + } + } + } + + @AllureId("3957") + @DisplayName("WC (React App): open session from deeplink ") + @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") + @Test + fun openWalletConnectSession() { + val balance = TOTAL_BALANCE + val dAppName = "React App" + val deepLinkUri = getWcUri() + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Open recent apps") { + device.uiDevice.pressRecentApps() + } + step("Stop app by swipe") { + swipeVertical(SwipeDirection.UP, startHeightRatio = 0.8f) + } + step("Create WC session buy deeplink") { + openAppByDeepLink(deepLinkUri) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Check 'Wallet Connect' bottom sheet") { + checkWalletConnectBottomSheet() + } + step("Click on 'Connect' button") { + onWalletConnectBottomSheet { connectButton.performClick() } + } + step("Click 'More' button on TopBar") { + onTopBar { moreButton.clickWithAssertion() } + } + step("Click on 'Wallet Connect' button") { + onDetailsScreen { walletConnectButton.clickWithAssertion() } + } + step("Check 'Wallet Connect' screen") { + checkWalletConnectScreen() + } + step("Click on app icon") { + onWalletConnectScreen { appIcon.performClick() } + } + step("Check 'Wallet Connect' details bottom sheet") { + checkWalletConnectDetailsBottomSheet(dAppName) + } + step("Click on 'Disconnect button' is displayed") { + onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } + } + step("Assert connection is not displayed") { + onWalletConnectScreen { appName.assertIsNotDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt new file mode 100644 index 0000000000..cf0024f8bb --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt @@ -0,0 +1,97 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.openMainScreen +import com.tangem.screens.onMainScreen +import com.tangem.tap.domain.sdk.mocks.content.DevWalletMockContent +import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithSeedPhraseMockContent +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.Allure.step +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class WarningTest : BaseTestCase() { + + @AllureId("898") + @DisplayName("Warnings: check 'Dev card' warning") + @Test + fun devCardWarningTest() { + setupHooks().run { + step("Open 'Main' screen") { + openMainScreen(mockContent = DevWalletMockContent) + } + step("Assert 'Dev card' notification title is displayed") { + onMainScreen { devCardNotificationTitle.assertIsDisplayed() } + } + step("Assert 'Dev card' notification message is displayed") { + onMainScreen { devCardNotificationMessage.assertIsDisplayed() } + } + step("Assert 'Dev card' notification icon is displayed") { + onMainScreen { devCardNotificationIcon.assertIsDisplayed() } + } + } + } + + @AllureId("3991") + @DisplayName("Warnings: check 'Dev card' warning is not displayed for release card") + @Test + fun releaseCardWarningTest() { + setupHooks().run { + step("Open 'Main' screen") { + openMainScreen() + } + step("Assert 'Dev card' notification title is not displayed") { + onMainScreen { devCardNotificationTitle.assertIsNotDisplayed() } + } + step("Assert 'Dev card' notification message is not displayed") { + onMainScreen { devCardNotificationMessage.assertIsNotDisplayed() } + } + step("Assert 'Dev card' notification icon is not displayed") { + onMainScreen { devCardNotificationIcon.assertIsNotDisplayed() } + } + } + } + + @AllureId("227") + @DisplayName("Seed notify: check warning for wallet with seed phrase") + @Test + fun checkWarningForWalletWithSeedPhraseTest() { + val scenarioName = "seedphrase_notification" + val scenarioState = "Notified" + setupHooks( + additionalBeforeSection = { + step("Setup WireMock scenario '$scenarioName' for '$scenarioState' state") { + setWireMockScenarioState(scenarioName, scenarioState) + } + }, + additionalAfterSection = { + step("Reset WireMock scenario '$scenarioName' state") { + resetWireMockScenarioState(scenarioName) + } + } + ).run { + step("Open 'Main' screen") { + openMainScreen(mockContent = Wallet2WithSeedPhraseMockContent, alreadyActivatedDialogIsShown = true) + } + step("Assert 'Seed phrase' notification icon is displayed") { + onMainScreen { seedPhraseNotificationIcon.assertIsDisplayed() } + } + step("Assert 'Seed phrase' notification title is displayed") { + onMainScreen { seedPhraseNotificationTitle.assertIsDisplayed() } + } + step("Assert 'Seed phrase' notification message is displayed") { + onMainScreen { seedPhraseNotificationMessage.assertIsDisplayed() } + } + step("Assert notification 'Yes' button is displayed") { + onMainScreen { notificationYesButton.assertIsDisplayed() } + } + step("Assert notification 'No' button is displayed") { + onMainScreen { notificationNoButton.assertIsDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt new file mode 100644 index 0000000000..05e052bdd4 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt @@ -0,0 +1,43 @@ +package com.tangem.tests.balance + +import androidx.compose.ui.test.longClick +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onMainScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class TotalBalanceLongTapTest : BaseTestCase() { + + @Test + @AllureId("3965") + @DisplayName("Total balance: check long tap on block without biometry") + fun whenBiometryIsOffTest() { + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(TOTAL_BALANCE) + } + step("Long tap on total balance block") { + onMainScreen { + totalBalanceContainer.performTouchInput { + longClick() + } + } + } + step("Assert 'Rename' button is not displayed") { + onMainScreen { totalBalanceMenuRenameWallet.assertIsNotDisplayed() } + } + step("Assert 'Delete' button is not displayed") { + onMainScreen { totalBalanceMenuDeleteWallet.assertIsNotDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt new file mode 100644 index 0000000000..f44bf4ffa4 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt @@ -0,0 +1,145 @@ +package com.tangem.tests.balance + +import com.tangem.common.BaseTestCase +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onMainScreen +import com.tangem.utils.StringsSigns.DASH_SIGN +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class TotalBalanceUnavailableTest : BaseTestCase() { + + @Test + @AllureId("148") + @DisplayName("Total balance: check dash sign when addresses are not derived") + fun whenDerivationsDoesNotSyncedTest() { + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Do not synchronize addresses") { + onMainScreen { synchronizeAddressesButton.assertIsDisplayed() } + } + step("Assert dash sign is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } + } + } + } + + @Test + @AllureId("3993") + @DisplayName("Total balance: check dash sign when network is unreachable") + fun whenNetworkIsUnreachableTest() { + val scenarioName = "eth_network_balance" + val scenarioState = "Unreachable" + val tokenTitle = "Ethereum" + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(DASH_SIGN) + } + step("Assert 'Synchronize addresses' button does not exist") { + onMainScreen { + flakySafely { + synchronizeAddressesButton.assertDoesNotExist() + } + } + } + step("Assert dash sign is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } + } + step("Assert $tokenTitle is unreachable") { + onMainScreen { tokenWithTitleAndPosition(tokenTitle, 1).assertIsUnreachable() } + } + } + } + + @Test + @AllureId("3994") + @DisplayName("Total balance: check dash sign when quotes are failed") + fun whenQuotesAreFailedTest() { + val scenarioName = "quotes_api" + val scenarioState = "Error" + val tokenTitle = "Ethereum" + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(DASH_SIGN) + } + step("Assert 'Synchronize addresses' button does not exist") { + onMainScreen { + flakySafely { + synchronizeAddressesButton.assertDoesNotExist() + } + } + } + step("Assert dash sign is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } + } + step("Assert $tokenTitle is unreachable") { + onMainScreen { tokenWithTitleAndPosition(tokenTitle, 1).assertIsUnreachable() } + } + } + } + + @Test + @AllureId("3995") + @DisplayName("Total balance: check dash sign when added custom token without rate") + fun whenCustomTokenWithoutRateAddedTest() { + val scenarioName = "user_tokens_api" + val scenarioState = "CustomTokenAdded" + val tokenTitle = "Myria" + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(DASH_SIGN) + } + step("Assert 'Synchronize addresses' button does not exist") { + onMainScreen { + flakySafely { + synchronizeAddressesButton.assertDoesNotExist() + } + } + } + step("Assert dash sign is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } + } + step("Assert $tokenTitle is unreachable") { + onMainScreen { tokenWithTitleAndPosition(tokenTitle, 4).assertIsUnreachable() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt new file mode 100644 index 0000000000..1c7fd38068 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt @@ -0,0 +1,209 @@ +package com.tangem.tests.balance + +import androidx.compose.ui.test.longClick +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.common.extensions.* +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class TotalBalanceUpdateTest : BaseTestCase() { + + @Test + @AllureId("150") + @DisplayName("Total balance: check balance update after pull to refresh") + fun afterPullToRefreshTest() { + val scenarioName = "eth_network_balance" + val scenarioState = "Empty" + val updatedBalance = "$763.55" + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(TOTAL_BALANCE) + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Perform pull to refresh") { + pullToRefresh() + } + step("Assert Total balance is updated from $TOTAL_BALANCE to $updatedBalance") { + onMainScreen { totalBalanceText.assertTextContains(updatedBalance) } + } + } + } + + @Test + @AllureId("3997") + @DisplayName("Total balance: check balance update after token added") + fun afterAddTokenTest() { + val tokenTitle = "XRP" + val scenarioName = "quotes_api" + val scenarioState = "Ripple" + val updatedBalance = "$3,307.18" + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(TOTAL_BALANCE) + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + step("Open 'Markets screen'") { + swipeMarketsBlock(SwipeDirection.UP) + composeTestRule.waitForIdle() + } + step("Click on $tokenTitle token") { + onMarketsScreen { tokenWithTitle(tokenTitle).clickWithAssertion() } + } + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Click on 'Add to portfolio' button") { + onMarketsScreen { addToPortfolioButton.clickWithAssertion() } + } + step("Toggle the main network switch") { + onMarketsScreen { mainNetworkSwitch.performClick() } + } + step("Click on 'Continue' button") { + onDialog { continueButton.clickWithAssertion() } + } + step("Assert 'Continue' is not displayed") { + onDialog { continueButton.assertIsNotDisplayed() } + } + step("Go back to 'Markets: tokens list'") { + composeTestRule.waitForIdle() + onMarketsScreen { topBarBackButton.clickWithAssertion() } + } + step("Close 'Markets screen'") { + onSearchBar { searchField.assertIsDisplayed() } + swipeMarketsBlock(SwipeDirection.DOWN) + } + step("Assert $updatedBalance is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(updatedBalance) } + } + } + } + + @Test + @AllureId("4000") + @DisplayName("Total balance: check balance update after collapse/expand") + fun afterCollapseAndExpandTest() { + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(TOTAL_BALANCE) + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + step("Press 'Home' to collapse the app") { + device.uiDevice.pressHome() + } + step("Open the app from recent apps") { + openTheAppFromRecents() + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + } + } + + @Test + @AllureId("3998") + @DisplayName("Total balance: check balance update after hide token") + fun afterHideTokenTest() { + val tokenTitle = "Polygon" + val updatedBalance = "$3,114.34" + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(TOTAL_BALANCE) + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + step("Long click on token with name: '$tokenTitle'") { + composeTestRule.waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Click 'Hide token' button") { + onBottomSheet { hideButton.clickWithAssertion() } + } + step("Click 'Hide' button in dialog") { + onDialog { + dialogContainer.assertIsDisplayed() + okButton.clickWithAssertion() + } + } + step("Assert $updatedBalance is displayed in total balance") { + swipeVertical(SwipeDirection.DOWN) + onMainScreen { totalBalanceText.assertTextContains(updatedBalance) } + } + } + } + + @Test + @AllureId("4001") + @DisplayName("Total balance: check balance update after navigation") + fun afterNavigationTest() { + val tokenTitle = "Polygon" + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(TOTAL_BALANCE) + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + step("Click on token with name: '$tokenTitle'") { + onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } + } + step("Assert 'Token details screen' open") { + onTokenDetailsScreen { screenContainer.assertIsDisplayed() } + } + step("Click 'More' button") { + onTokenDetailsTopBar { backButton.clickWithAssertion() } + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/FirebasePushNotificationsTokenProvider.kt b/app/src/google/java/com/tangem/tap/FirebasePushNotificationsTokenProvider.kt similarity index 95% rename from app/src/main/java/com/tangem/tap/data/FirebasePushNotificationsTokenProvider.kt rename to app/src/google/java/com/tangem/tap/FirebasePushNotificationsTokenProvider.kt index 358bdd3d61..627e41f2cc 100644 --- a/app/src/main/java/com/tangem/tap/data/FirebasePushNotificationsTokenProvider.kt +++ b/app/src/google/java/com/tangem/tap/FirebasePushNotificationsTokenProvider.kt @@ -1,4 +1,4 @@ -package com.tangem.tap.data +package com.tangem.tap import com.google.firebase.messaging.FirebaseMessaging import com.tangem.utils.notifications.PushNotificationsTokenProvider diff --git a/app/src/main/java/com/tangem/tap/di/data/PushNotificationsModule.kt b/app/src/google/java/com/tangem/tap/di/GooglePushModule.kt similarity index 74% rename from app/src/main/java/com/tangem/tap/di/data/PushNotificationsModule.kt rename to app/src/google/java/com/tangem/tap/di/GooglePushModule.kt index 063efaf3dc..a8e4e87c08 100644 --- a/app/src/main/java/com/tangem/tap/di/data/PushNotificationsModule.kt +++ b/app/src/google/java/com/tangem/tap/di/GooglePushModule.kt @@ -1,6 +1,6 @@ -package com.tangem.tap.di.data +package com.tangem.tap.di -import com.tangem.tap.data.FirebasePushNotificationsTokenProvider +import com.tangem.tap.FirebasePushNotificationsTokenProvider import com.tangem.utils.notifications.PushNotificationsTokenProvider import dagger.Binds import dagger.Module @@ -10,7 +10,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal interface PushNotificationsModule { +internal interface GooglePushModule { @Binds @Singleton diff --git a/app/src/huawei/AndroidManifest.xml b/app/src/huawei/AndroidManifest.xml new file mode 100644 index 0000000000..0c769dbe3e --- /dev/null +++ b/app/src/huawei/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/huawei/java/com/tangem/tap/HuaweiPushNotificationsTokenProvider.kt b/app/src/huawei/java/com/tangem/tap/HuaweiPushNotificationsTokenProvider.kt new file mode 100644 index 0000000000..03c709cd6c --- /dev/null +++ b/app/src/huawei/java/com/tangem/tap/HuaweiPushNotificationsTokenProvider.kt @@ -0,0 +1,50 @@ +package com.tangem.tap + +import android.content.Context +import com.google.firebase.messaging.FirebaseMessaging +import com.huawei.agconnect.AGConnectOptionsBuilder +import com.huawei.hms.aaid.HmsInstanceId +import com.huawei.hms.common.ApiException +import com.tangem.google.GoogleServicesHelper +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.notifications.PushNotificationsTokenProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +internal class HuaweiPushNotificationsTokenProvider @Inject constructor( + @ApplicationContext private val context: Context, + private val coroutineDispatcherProvider: CoroutineDispatcherProvider, +) : PushNotificationsTokenProvider { + + override suspend fun getToken(): String { + val isGoogleServicesAvailable = GoogleServicesHelper.checkGoogleServicesAvailability(context) + return if (isGoogleServicesAvailable) { + try { + FirebaseMessaging.getInstance().token.await() + } catch (ex: Exception) { + Timber.e(ex) + "" + } + } else { + withContext(coroutineDispatcherProvider.io) { + try { + val appId = AGConnectOptionsBuilder().build(context).getString(APP_ID_KEY) + val token = HmsInstanceId.getInstance(context).getToken(appId, TOKEN_REQUEST_MODE) + Timber.i("Requested token from HuaweiService: $token") + token + } catch (e: ApiException) { + Timber.i("Fetching token from HuaweiService failed cause: ${e.message}") + "" + } + } + } + } + + companion object { + private const val APP_ID_KEY = "client/app_id" + private const val TOKEN_REQUEST_MODE = "HCM" + } +} \ No newline at end of file diff --git a/app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt b/app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt new file mode 100644 index 0000000000..b7a1de528d --- /dev/null +++ b/app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt @@ -0,0 +1,47 @@ +package com.tangem.tap + +import android.os.Bundle +import com.huawei.hms.push.HmsMessageService +import com.huawei.hms.push.RemoteMessage +import com.tangem.google.GoogleServicesHelper +import com.tangem.tap.common.pushes.PushNotificationDelegate +import timber.log.Timber + +class HuaweiPushService : HmsMessageService() { + + private val pushNotificationDelegate: PushNotificationDelegate by lazy { + PushNotificationDelegate(applicationContext) + } + + override fun onNewToken(token: String?, bundle: Bundle?) { + super.onNewToken(token, bundle) + Timber.i("HuaweiPushService: On new token from HuaweiService: $token") + } + + override fun onTokenError(e: Exception?, bundle: Bundle?) { + super.onTokenError(e, bundle) + Timber.i("HuaweiPushService: Fetching token from HuaweiService failed cause: ${e?.message}") + } + + override fun onMessageReceived(message: RemoteMessage?) { + super.onMessageReceived(message) + val isGoogleServicesAvailable = GoogleServicesHelper.checkGoogleServicesAvailability(this) + if (isGoogleServicesAvailable) return + val notification = message?.notification ?: return + val channelId = notification.channelId ?: TANGEM_CHANNEL_ID + + pushNotificationDelegate.showNotification( + dataMap = message.dataOfMap, + title = notification.title, + body = notification.body, + channelId = channelId, + priority = message.urgency, + imageUrl = notification.imageUrl, + vibratePattern = notification.vibrateConfig, + ) + } + + private companion object { + const val TANGEM_CHANNEL_ID = "Tangem General" // General channel for notifications + } +} \ No newline at end of file diff --git a/app/src/huawei/java/com/tangem/tap/di/HuaweiPushModule.kt b/app/src/huawei/java/com/tangem/tap/di/HuaweiPushModule.kt new file mode 100644 index 0000000000..f4379c5469 --- /dev/null +++ b/app/src/huawei/java/com/tangem/tap/di/HuaweiPushModule.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.di + +import com.tangem.tap.HuaweiPushNotificationsTokenProvider +import com.tangem.utils.notifications.PushNotificationsTokenProvider +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 HuaweiPushModule { + + @Binds + @Singleton + fun bindPushNotificationsTokenProvider(impl: HuaweiPushNotificationsTokenProvider): PushNotificationsTokenProvider +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index baf6ae4211..ee754f78cc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,6 +34,7 @@ + tools:replace="android:allowBackup, android:dataExtractionRules, android:fullBackupContent, android:label, android:largeHeap"> diff --git a/app/src/main/assets/testnet_tokens.json b/app/src/main/assets/testnet_tokens.json index ad10260b6b..ebb0fee019 100644 --- a/app/src/main/assets/testnet_tokens.json +++ b/app/src/main/assets/testnet_tokens.json @@ -1,160 +1,165 @@ { - "coins" : [ + "coins": [ { - "id" : "matic-token", - "symbol" : "MATIC", - "name" : "Polygon", - "networks" : [ + "id": "matic-token", + "symbol": "MATIC", + "name": "Polygon", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0x0000000000000000000000000000000000001010", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0x0000000000000000000000000000000000001010", + "decimalCount": 18 } ] }, { - "id" : "kaspa", - "symbol" : "KAS", - "name" : "Kaspa", - "networks" : [ + "id": "kaspa", + "symbol": "KAS", + "name": "Kaspa", + "networks": [ { - "networkId" : "kaspa/test" + "networkId": "kaspa/test" } ] }, { - "id" : "Dai Stablecoin-DAI", - "symbol" : "DAI", - "name" : "Dai Stablecoin", - "networks" : [ + "id": "Dai Stablecoin-DAI", + "symbol": "DAI", + "name": "Dai Stablecoin", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0xcB1e72786A6eb3b44C2a2429e317c8a2462CFeb1", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0xcB1e72786A6eb3b44C2a2429e317c8a2462CFeb1", + "decimalCount": 18 }, { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0xec5dcb5dbf4b114c9d0f65bccab49ec54f6a0867", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0xec5dcb5dbf4b114c9d0f65bccab49ec54f6a0867", + "decimalCount": 18 }, { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0x8a9424745056eb399fd19a0ec26a14316684e274", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0x8a9424745056eb399fd19a0ec26a14316684e274", + "decimalCount": 18 } ] }, { - "id" : "Dummy ERC20-DERC20", - "symbol" : "DERC20", - "name" : "Dummy ERC20", - "networks" : [ + "id": "Dummy ERC20-DERC20", + "symbol": "DERC20", + "name": "Dummy ERC20", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0xfe4F5145f6e09952a5ba9e956ED0C25e3Fa4c7F1", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0xfe4F5145f6e09952a5ba9e956ED0C25e3Fa4c7F1", + "decimalCount": 18 } ] }, { - "id" : "Ether-ETH", - "symbol" : "ETH", - "name" : "Ether", - "networks" : [ + "id": "Ether-ETH", + "symbol": "ETH", + "name": "Ether", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0x714550C2C1Ea08688607D86ed8EeF4f5E4F22323", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0x714550C2C1Ea08688607D86ed8EeF4f5E4F22323", + "decimalCount": 18 }, { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0xd66c6b4f0be8ce5b39d52e0fd1344c389929b378", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0xd66c6b4f0be8ce5b39d52e0fd1344c389929b378", + "decimalCount": 18 } ] }, { - "id" : "Test Token-TST", - "symbol" : "TST", - "name" : "Test Token", - "networks" : [ + "id": "Test Token-TST", + "symbol": "TST", + "name": "Test Token", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0x2d7882bedcbfddce29ba99965dd3cdf7fcb10a1e", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0x2d7882bedcbfddce29ba99965dd3cdf7fcb10a1e", + "decimalCount": 18 } ] }, { - "id" : "tether", - "symbol" : "USDT", - "name" : "Tether USD", - "networks" : [ + "id": "tether", + "symbol": "USDT", + "name": "Tether USD", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0x3813e82e6f7098b9583FC0F33a962D02018B6803", - "decimalCount" : 6 + "networkId": "ethereum/test", + "contractAddress": "0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0", + "decimalCount": 18 }, { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0x337610d27c682e347c9cd60bd4b3b107c9d34ddd", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0x3813e82e6f7098b9583FC0F33a962D02018B6803", + "decimalCount": 6 }, { - "networkId" : "tron/test", - "contractAddress" : "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj", - "decimalCount" : 6 + "networkId": "binance-smart-chain/test", + "contractAddress": "0x337610d27c682e347c9cd60bd4b3b107c9d34ddd", + "decimalCount": 18 }, { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0x7ef95a0fee0dd31b22626fa2e10ee6a223f8a684", - "decimalCount" : 18 + "networkId": "tron/test", + "contractAddress": "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj", + "decimalCount": 6 + }, + { + "networkId": "binance-smart-chain/test", + "contractAddress": "0x7ef95a0fee0dd31b22626fa2e10ee6a223f8a684", + "decimalCount": 18 } ] }, { - "id" : "just-gov", - "symbol" : "JST", - "name" : "JUST GOV", - "networks" : [ + "id": "just-gov", + "symbol": "JST", + "name": "JUST GOV", + "networks": [ { - "networkId" : "tron/test", - "contractAddress" : "TF17BgPaZYbz8oxbjhriubPDsA7ArKoLX3", - "decimalCount" : 18 + "networkId": "tron/test", + "contractAddress": "TF17BgPaZYbz8oxbjhriubPDsA7ArKoLX3", + "decimalCount": 18 } ] }, { - "id" : "Wrapped Ether-WETH", - "symbol" : "WETH", - "name" : "Wrapped Ether", - "networks" : [ + "id": "Wrapped Ether-WETH", + "symbol": "WETH", + "name": "Wrapped Ether", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0xA6FA4fB5f76172d178d61B04b0ecd319C5d1C0aa", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0xA6FA4fB5f76172d178d61B04b0ecd319C5d1C0aa", + "decimalCount": 18 } ] }, { - "id" : "Wrapped Matic-WMATIC", - "symbol" : "WMATIC", - "name" : "Wrapped Matic", - "networks" : [ + "id": "Wrapped Matic-WMATIC", + "symbol": "WMATIC", + "name": "Wrapped Matic", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0xd0A1E359811322d97991E03f863a0C30C2cF029C", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0xd0A1E359811322d97991E03f863a0C30C2cF029C", + "decimalCount": 18 } ] }, { - "id" : "solana", - "symbol" : "SOL", - "name" : "Solana", - "networks" : [ + "id": "solana", + "symbol": "SOL", + "name": "Solana", + "networks": [ { - "networkId" : "solana/test" + "networkId": "solana/test" } ] }, @@ -162,263 +167,262 @@ "id": "the-open-network", "symbol": "TON", "name": "Toncoin", - "networks": - [ + "networks": [ { "networkId": "the-open-network/test" } ] }, { - "id" : "Tangem Coin A-TCA", - "symbol" : "TCA", - "name" : "Tangem Coin A", - "networks" : [ + "id": "Tangem Coin A-TCA", + "symbol": "TCA", + "name": "Tangem Coin A", + "networks": [ { - "networkId" : "solana/test", - "contractAddress" : "22PTNbX31Zuztd6nD82fC8nQdT2hUfWv9XWXKuDkrFqR", - "decimalCount" : 9 + "networkId": "solana/test", + "contractAddress": "22PTNbX31Zuztd6nD82fC8nQdT2hUfWv9XWXKuDkrFqR", + "decimalCount": 9 } ] }, { - "id" : "Tangem Coin B-TCB", - "symbol" : "TCB", - "name" : "Tangem Coin B", - "networks" : [ + "id": "Tangem Coin B-TCB", + "symbol": "TCB", + "name": "Tangem Coin B", + "networks": [ { - "networkId" : "solana/test", - "contractAddress" : "HmSghNPg6KCk711YJA92aPejt8auyFkvmaED6jbHfUs4", - "decimalCount" : 9 + "networkId": "solana/test", + "contractAddress": "HmSghNPg6KCk711YJA92aPejt8auyFkvmaED6jbHfUs4", + "decimalCount": 9 } ] }, { - "id" : "binancecoin", - "symbol" : "BNB", - "name" : "Binance", - "networks" : [ + "id": "binancecoin", + "symbol": "BNB", + "name": "Binance", + "networks": [ { - "networkId" : "binancecoin/test" + "networkId": "binancecoin/test" }, { - "networkId" : "binance-smart-chain/test" + "networkId": "binance-smart-chain/test" } ] }, { - "id" : "Hemster - 452-HEM", - "symbol" : "HEM", - "name" : "Hemster - 452", - "networks" : [ + "id": "Hemster - 452-HEM", + "symbol": "HEM", + "name": "Hemster - 452", + "networks": [ { - "networkId" : "binancecoin/test", - "contractAddress" : "HEM-452", - "decimalCount" : 8 + "networkId": "binancecoin/test", + "contractAddress": "HEM-452", + "decimalCount": 8 } ] }, { - "id" : "Binance-Peg BTCB Token-BTCB", - "symbol" : "BTCB", - "name" : "Binance-Peg BTCB Token", - "networks" : [ + "id": "Binance-Peg BTCB Token-BTCB", + "symbol": "BTCB", + "name": "Binance-Peg BTCB Token", + "networks": [ { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0x6ce8da28e2f864420840cf74474eff5fd80e65b8", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0x6ce8da28e2f864420840cf74474eff5fd80e65b8", + "decimalCount": 18 } ] }, { - "id" : "Binance-Peg BUSD Token-BUSD", - "symbol" : "BUSD", - "name" : "Binance-Peg BUSD Token", - "networks" : [ + "id": "Binance-Peg BUSD Token-BUSD", + "symbol": "BUSD", + "name": "Binance-Peg BUSD Token", + "networks": [ { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0xed24fc36d5ee211ea25a80239fb8c4cfd80f12ee", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0xed24fc36d5ee211ea25a80239fb8c4cfd80f12ee", + "decimalCount": 18 } ] }, { - "id" : "Binance-Peg USDC Token-USDC", - "symbol" : "USDC", - "name" : "Binance-Peg USDC Token", - "networks" : [ + "id": "Binance-Peg USDC Token-USDC", + "symbol": "USDC", + "name": "Binance-Peg USDC Token", + "networks": [ { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0x64544969ed7ebf5f083679233325356ebe738930", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0x64544969ed7ebf5f083679233325356ebe738930", + "decimalCount": 18 } ] }, { - "id" : "Binance-Peg XRP-XRP", - "symbol" : "XRP", - "name" : "Binance-Peg XRP", - "networks" : [ + "id": "Binance-Peg XRP-XRP", + "symbol": "XRP", + "name": "Binance-Peg XRP", + "networks": [ { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0xa83575490d7df4e2f47b7d38ef351a2722ca45b9", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0xa83575490d7df4e2f47b7d38ef351a2722ca45b9", + "decimalCount": 18 } ] }, { - "id" : "ethereum", - "symbol" : "ETH", - "name" : "Ethereum", - "networks" : [ + "id": "ethereum", + "symbol": "ETH", + "name": "Ethereum", + "networks": [ { - "networkId" : "ethereum/test" + "networkId": "ethereum/test" } ] }, { - "id" : "ethereum-classic", - "symbol" : "ETC", - "name" : "Ethereum Classic", - "networks" : [ + "id": "ethereum-classic", + "symbol": "ETC", + "name": "Ethereum Classic", + "networks": [ { - "networkId" : "ethereum-classic/test" + "networkId": "ethereum-classic/test" } ] }, { - "id" : "Weenus-WEENUS", - "symbol" : "WEENUS", - "name" : "Weenus", - "networks" : [ + "id": "Weenus-WEENUS", + "symbol": "WEENUS", + "name": "Weenus", + "networks": [ { - "networkId" : "ethereum/test", - "contractAddress" : "0xaFF4481D10270F50f203E0763e2597776068CBc5", - "decimalCount" : 18 + "networkId": "ethereum/test", + "contractAddress": "0xaFF4481D10270F50f203E0763e2597776068CBc5", + "decimalCount": 18 } ] }, { - "id" : "Xeenus-XEENUS", - "symbol" : "XEENUS", - "name" : "Xeenus", - "networks" : [ + "id": "Xeenus-XEENUS", + "symbol": "XEENUS", + "name": "Xeenus", + "networks": [ { - "networkId" : "ethereum/test", - "contractAddress" : "0x022E292b44B5a146F2e8ee36Ff44D3dd863C915c", - "decimalCount" : 18 + "networkId": "ethereum/test", + "contractAddress": "0x022E292b44B5a146F2e8ee36Ff44D3dd863C915c", + "decimalCount": 18 } ] }, { - "id" : "Yeenus-YEENUS", - "symbol" : "YEENUS", - "name" : "Yeenus", - "networks" : [ + "id": "Yeenus-YEENUS", + "symbol": "YEENUS", + "name": "Yeenus", + "networks": [ { - "networkId" : "ethereum/test", - "contractAddress" : "0xc6fDe3FD2Cc2b173aEC24cc3f267cb3Cd78a26B7", - "decimalCount" : 8 + "networkId": "ethereum/test", + "contractAddress": "0xc6fDe3FD2Cc2b173aEC24cc3f267cb3Cd78a26B7", + "decimalCount": 8 } ] }, { - "id" : "Zeenus-ZEENUS", - "symbol" : "ZEENUS", - "name" : "Zeenus", - "networks" : [ + "id": "Zeenus-ZEENUS", + "symbol": "ZEENUS", + "name": "Zeenus", + "networks": [ { - "networkId" : "ethereum/test", - "contractAddress" : "0x1f9061B953bBa0E36BF50F21876132DcF276fC6e", - "decimalCount" : 0 + "networkId": "ethereum/test", + "contractAddress": "0x1f9061B953bBa0E36BF50F21876132DcF276fC6e", + "decimalCount": 0 } ] }, { - "id" : "avalanche-2", - "symbol" : "AVAX", - "name" : "Avalanche", - "networks" : [ + "id": "avalanche-2", + "symbol": "AVAX", + "name": "Avalanche", + "networks": [ { - "networkId" : "avalanche/test" + "networkId": "avalanche/test" } ] }, { - "id" : "The Fuji stablecoin-FUJISTABLE", - "symbol" : "FUJISTABLE", - "name" : "The Fuji stablecoin", - "networks" : [ + "id": "The Fuji stablecoin-FUJISTABLE", + "symbol": "FUJISTABLE", + "name": "The Fuji stablecoin", + "networks": [ { - "networkId" : "avalanche/test", - "contractAddress" : "0x2058ec2791dD28b6f67DB836ddf87534F4Bbdf22", - "decimalCount" : 6 + "networkId": "avalanche/test", + "contractAddress": "0x2058ec2791dD28b6f67DB836ddf87534F4Bbdf22", + "decimalCount": 6 } ] }, { - "id" : "To the Moon-FUJIMOON", - "symbol" : "FUJIMOON", - "name" : "To the Moon", - "networks" : [ + "id": "To the Moon-FUJIMOON", + "symbol": "FUJIMOON", + "name": "To the Moon", + "networks": [ { - "networkId" : "avalanche/test", - "contractAddress" : "0x97132C109c6816525F7f338DCb7435E1412A7668", - "decimalCount" : 9 + "networkId": "avalanche/test", + "contractAddress": "0x97132C109c6816525F7f338DCb7435E1412A7668", + "decimalCount": 9 } ] }, { - "id" : "fantom", - "symbol" : "FTM", - "name" : "Fantom", - "networks" : [ + "id": "fantom", + "symbol": "FTM", + "name": "Fantom", + "networks": [ { - "networkId" : "fantom/test" + "networkId": "fantom/test" } ] }, { - "id" : "Fantom USD-FUSD", - "symbol" : "FUSD", - "name" : "Fantom USD", - "networks" : [ + "id": "Fantom USD-FUSD", + "symbol": "FUSD", + "name": "Fantom USD", + "networks": [ { - "networkId" : "fantom/test", - "contractAddress" : "0x91ea991bd52EE3C40EdA2509701d905e1Ee54074", - "decimalCount" : 18 + "networkId": "fantom/test", + "contractAddress": "0x91ea991bd52EE3C40EdA2509701d905e1Ee54074", + "decimalCount": 18 } ] }, { - "id" : "Wrapped Fantom-WFTM", - "symbol" : "WFTM", - "name" : "Wrapped Fantom", - "networks" : [ + "id": "Wrapped Fantom-WFTM", + "symbol": "WFTM", + "name": "Wrapped Fantom", + "networks": [ { - "networkId" : "fantom/test", - "contractAddress" : "0xf1277d1Ed8AD466beddF92ef448A132661956621", - "decimalCount" : 18 + "networkId": "fantom/test", + "contractAddress": "0xf1277d1Ed8AD466beddF92ef448A132661956621", + "decimalCount": 18 } ] }, { - "id" : "bitcoin", - "symbol" : "BTC", - "name" : "Bitcoin", - "networks" : [ + "id": "bitcoin", + "symbol": "BTC", + "name": "Bitcoin", + "networks": [ { - "networkId" : "bitcoin/test" + "networkId": "bitcoin/test" } ] }, { - "id" : "vechain", - "symbol" : "VET", - "name" : "VeChain", - "networks" : [ + "id": "vechain", + "symbol": "VET", + "name": "VeChain", + "networks": [ { - "networkId" : "vechain/test" + "networkId": "vechain/test" } ] }, @@ -435,34 +439,34 @@ ] }, { - "id" : "tron", - "symbol" : "TRX", - "name" : "Tron", - "networks" : [ - { - "networkId" : "tron/test" - } - ] + "id": "tron", + "symbol": "TRX", + "name": "Tron", + "networks": [ + { + "networkId": "tron/test" + } + ] }, { - "id" : "algorand", - "symbol" : "ALGO", - "name" : "Algorand", - "networks" : [ - { - "networkId" : "algorand/test" - } - ] + "id": "algorand", + "symbol": "ALGO", + "name": "Algorand", + "networks": [ + { + "networkId": "algorand/test" + } + ] }, { - "id" : "arbitrum-one", - "symbol" : "ETH", - "name" : "Arbitrum", - "networks" : [ - { - "networkId" : "arbitrum-one/test" - } - ] + "id": "arbitrum-one", + "symbol": "ETH", + "name": "Arbitrum", + "networks": [ + { + "networkId": "arbitrum-one/test" + } + ] }, { "id": "stellar", @@ -485,12 +489,12 @@ ] }, { - "id" : "polkadot", - "symbol" : "DOT", - "name" : "Polkadot", - "networks" : [ + "id": "polkadot", + "symbol": "DOT", + "name": "Polkadot", + "networks": [ { - "networkId" : "polkadot/test" + "networkId": "polkadot/test" } ] }, @@ -509,8 +513,7 @@ "id": "kava", "symbol": "KAVA", "name": "Kava EVM", - "networks": - [ + "networks": [ { "networkId": "kava/test" } @@ -520,8 +523,7 @@ "id": "telos", "symbol": "TLOS", "name": "Telos EVM", - "networks": - [ + "networks": [ { "networkId": "telos/test" } @@ -531,8 +533,7 @@ "id": "ravencoin", "symbol": "RVN", "name": "Ravencoin", - "networks": - [ + "networks": [ { "networkId": "ravencoin/test" } @@ -542,8 +543,7 @@ "id": "cosmos", "symbol": "ATOM", "name": "Cosmos Hub", - "networks": - [ + "networks": [ { "networkId": "cosmos/test" } @@ -798,6 +798,30 @@ "networkId": "alephium/test" } ] + }, + { + "id": "chainlink", + "name": "Chainlink", + "symbol": "LINK", + "networks": [ + { + "networkId": "ethereum/test", + "contractAddress": "0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5", + "decimalCount": 18 + } + ] + }, + { + "id": "gho", + "name": "GHO", + "symbol": "GHO", + "networks": [ + { + "networkId": "ethereum/test", + "contractAddress": "0xc4bF5CbDaBE595361438F8c6a187bDc330539c60", + "decimalCount": 18 + } + ] } ] } diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index aa3d4f2a9a..2f4c389818 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -29,12 +29,13 @@ import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.core.wallets.UserWalletsListRepository -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.legacy.UserWalletsListManager @@ -50,7 +51,6 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository @EntryPoint @InstallIn(SingletonComponent::class) @@ -71,8 +71,6 @@ interface ApplicationEntryPoint { fun getCardScanningFeatureToggles(): CardScanningFeatureToggles - fun getWalletConnect2Repository(): WalletConnect2Repository - fun getScanCardProcessor(): ScanCardProcessor fun getAppCurrencyRepository(): AppCurrencyRepository @@ -107,7 +105,7 @@ interface ApplicationEntryPoint { fun getSendFeedbackEmailUseCase(): SendFeedbackEmailUseCase - fun getGetCardInfoUseCase(): GetCardInfoUseCase + fun getWalletMetaInfoUseCase(): GetWalletMetaInfoUseCase fun getUrlOpener(): UrlOpener @@ -151,4 +149,6 @@ interface ApplicationEntryPoint { fun getTangemHotSdk(): TangemHotSdk fun getHotWalletFeatureToggles(): HotWalletFeatureToggles + + fun getWcInitializeUseCase(): WcInitializeUseCase } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt b/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt index e3d0a18067..4a26816830 100644 --- a/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt +++ b/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt @@ -11,6 +11,7 @@ import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.legacy.asLockable import com.tangem.domain.core.wallets.UserWalletsListRepository +import com.tangem.domain.wallets.usecase.ClearAllHotWalletContextualUnlockUseCase import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.tap.LockTimerWorker.Companion.TAG import com.tangem.tap.common.extensions.dispatchNavigationAction @@ -22,6 +23,7 @@ import timber.log.Timber import java.util.concurrent.TimeUnit import kotlin.time.Duration +@Suppress("LongParameterList") internal class LockUserWalletsTimer( private val context: Context, private val settingsRepository: SettingsRepository, @@ -30,6 +32,7 @@ internal class LockUserWalletsTimer( private val userWalletsListRepository: UserWalletsListRepository, private val hotWalletFeatureToggles: HotWalletFeatureToggles, private val coroutineScope: CoroutineScope, + private val clearAllHotWalletContextualUnlockUseCase: ClearAllHotWalletContextualUnlockUseCase, ) : LifecycleOwner by context as LifecycleOwner, DefaultLifecycleObserver { @@ -55,6 +58,9 @@ internal class LockUserWalletsTimer( ) if (shouldOpenWelcomeScreenOnResume) { + if (hotWalletFeatureToggles.isHotWalletEnabled) { + clearAllHotWalletContextualUnlockUseCase.invoke() + } store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) } settingsRepository.setShouldOpenWelcomeScreenOnResume(value = false) } @@ -120,6 +126,7 @@ internal class LockUserWalletsTimer( start() } .onRight { + clearAllHotWalletContextualUnlockUseCase.invoke() store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) } } } diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index 9cb6b6cd62..c047e66353 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -26,10 +26,8 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import arrow.core.getOrElse -import com.tangem.common.routing.AppRoute import com.tangem.common.routing.deeplink.DeeplinkConst.WEBLINK_KEY import com.tangem.common.routing.deeplink.PayloadToDeeplinkConverter -import com.tangem.common.routing.entity.SerializableIntent import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.di.RootAppComponentContext @@ -40,7 +38,7 @@ import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.settings.SetGooglePayAvailabilityUseCase import com.tangem.domain.settings.SetGoogleServicesAvailabilityUseCase import com.tangem.domain.settings.ShouldInitiallyAskPermissionUseCase @@ -49,12 +47,11 @@ import com.tangem.domain.staking.SendUnsubmittedHashesUseCase import com.tangem.domain.tokens.GetPolkadotCheckHasImmortalUseCase import com.tangem.domain.tokens.GetPolkadotCheckHasResetUseCase import com.tangem.domain.wallets.legacy.UserWalletsListManager -import com.tangem.domain.core.wallets.UserWalletsListRepository +import com.tangem.domain.wallets.usecase.ClearAllHotWalletContextualUnlockUseCase import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.tester.api.TesterMenuLauncher -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles import com.tangem.google.GoogleServicesHelper import com.tangem.operations.backup.BackupService import com.tangem.sdk.api.BackupServiceHolder @@ -62,13 +59,9 @@ import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.common.ActivityResultCallbackHolder import com.tangem.tap.common.DialogManager import com.tangem.tap.common.OnActivityResultCallback +import com.tangem.tap.common.analytics.events.Push import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor -import com.tangem.tap.features.intentHandler.IntentProcessor import com.tangem.tap.features.intentHandler.handlers.BackgroundScanIntentHandler -import com.tangem.tap.features.intentHandler.handlers.OnPushClickedIntentHandler -import com.tangem.tap.features.intentHandler.handlers.WalletConnectLinkIntentHandler import com.tangem.tap.features.main.MainViewModel import com.tangem.tap.proxy.redux.DaggerGraphAction import com.tangem.tap.routing.component.RoutingComponent @@ -116,9 +109,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { @Inject lateinit var scanCardUseCase: ScanCardUseCase - @Inject - lateinit var walletConnectInteractor: WalletConnectInteractor - @Inject lateinit var settingsRepository: SettingsRepository @@ -171,24 +161,12 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { @Inject internal lateinit var deeplinkFactory: DeepLinkFactory - @Inject - internal lateinit var walletConnectFeatureToggles: WalletConnectFeatureToggles - @Inject internal lateinit var urlOpener: UrlOpener @Inject internal lateinit var testerMenuLauncher: TesterMenuLauncher - @Inject - internal lateinit var intentProcessor: IntentProcessor - - @Inject - internal lateinit var walletConnectLinkIntentHandler: WalletConnectLinkIntentHandler - - @Inject - internal lateinit var onPushClickedIntentHandler: OnPushClickedIntentHandler - @Inject internal lateinit var backgroundScanIntentHandler: BackgroundScanIntentHandler @@ -198,10 +176,13 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { @Inject internal lateinit var hotWalletFeatureToggles: HotWalletFeatureToggles + @Inject + internal lateinit var clearAllHotWalletContextualUnlockUseCase: ClearAllHotWalletContextualUnlockUseCase + @Inject internal lateinit var tangemPayFeatureToggles: TangemPayFeatureToggles - internal val viewModel: MainViewModel by viewModels() + private val viewModel: MainViewModel by viewModels() private lateinit var appThemeModeFlow: SharedFlow @@ -256,6 +237,10 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { if (BuildConfig.TESTER_MENU_ENABLED) { lifecycle.addObserver(testerMenuLauncher.launchOnKeyEventObserver) } + + if (intent != null) { + handleDeepLink(intent = intent, isFromOnNewIntent = false) + } } private fun setRootContent() { @@ -265,6 +250,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { val routingComponent = routingComponentFactory.create( context = rootComponentContext, initialStack = appRouterConfig.stack, + launchMode = backgroundScanIntentHandler.getInitScreenLaunchMode(intent), ) setContent { @@ -286,14 +272,12 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { coroutineScope = mainScope, userWalletsListRepository = userWalletsListRepository, hotWalletFeatureToggles = hotWalletFeatureToggles, + clearAllHotWalletContextualUnlockUseCase = clearAllHotWalletContextualUnlockUseCase, ) - initIntentHandlers() - store.dispatch( DaggerGraphAction.SetActivityDependencies( scanCardUseCase = scanCardUseCase, - walletConnectInteractor = walletConnectInteractor, cardSdkConfigRepository = cardSdkConfigRepository, ), ) @@ -347,18 +331,12 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { dialogManager.onStart(this) } - override fun onResume() { - super.onResume() - navigateToInitialScreenIfNeeded(intent) - } - override fun onStop() { dialogManager.onStop() super.onStop() } override fun onDestroy() { - intentProcessor.removeAll() // workaround: kill process when activity destroy to avoid state when lock() wallets // and navigation to unlock screen was skipped because system kills activity but not process if (BuildConfig.BUILD_TYPE != MOCKED_BUILD_TYPE) { @@ -367,14 +345,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { super.onDestroy() } - private fun initIntentHandlers() { - intentProcessor.addHandler(onPushClickedIntentHandler) - - if (!walletConnectFeatureToggles.isRedesignedWalletConnectEnabled) { - intentProcessor.addHandler(walletConnectLinkIntentHandler) - } - } - private fun updateAppTheme(appThemeMode: AppThemeMode) { val mode = when (appThemeMode) { AppThemeMode.FORCE_DARK -> AppCompatDelegate.MODE_NIGHT_YES @@ -400,8 +370,9 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) - lifecycleScope.launch { - intentProcessor.handleIntent(intent = intent, isFromForeground = true) + val fromPush = intent?.extras?.containsKey(OPENED_FROM_GCM_PUSH) ?: false + if (fromPush) { + analyticsEventsHandler.send(Push.PushNotificationOpened) } if (intent != null) { @@ -443,131 +414,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { } } - private fun navigateToInitialScreenIfNeeded(intentWhichStartedActivity: Intent?) { - // TODO refactor this method to return a route instead of navigating directly - if (hotWalletFeatureToggles.isHotWalletEnabled) { - navigateToInitialScreenIfNeededNew(intentWhichStartedActivity) - return - } - - val backStack = appRouterConfig.stack ?: emptyList() - // TODO move inital navigation to navigation component ([REDACTED_JIRA]) - val isOnlyInitialRoute = backStack.all { it is AppRoute.Initial } - val isOnInitialScreen = backStack.all { it is AppRoute.Welcome || it is AppRoute.Home } - val isNotScannedBefore = store.state.globalState.scanResponse == null - val isOnboardingServiceNotActive = !store.state.globalState.onboardingState.onboardingStarted - - when { - !isOnInitialScreen && isNotScannedBefore && isOnboardingServiceNotActive -> { - navigateToInitialScreen(intentWhichStartedActivity) - } - backStack.isEmpty() -> { - navigateToInitialScreen(intentWhichStartedActivity) - } - isOnlyInitialRoute -> navigateToInitialScreen(intentWhichStartedActivity) - else -> Unit - } - } - - @Deprecated("Refactor this method to return a route instead of navigating directly") - private fun navigateToInitialScreenIfNeededNew(intentWhichStartedActivity: Intent?) { - lifecycleScope.launch { - val userWallets = userWalletsListRepository.userWalletsSync() - val launchMode = backgroundScanIntentHandler.getInitScreenLaunchMode(intentWhichStartedActivity) - if (userWallets.isEmpty()) { - val shouldShowTos = !cardRepository.isTangemTOSAccepted() - - val route = if (shouldShowTos) { - AppRoute.Disclaimer(isTosAccepted = false) - } else { - AppRoute.Home(launchMode = launchMode) - } - - store.dispatchNavigationAction { replaceAll(route) } - intentProcessor.handleIntent( - intent = intentWhichStartedActivity, - isFromForeground = false, - skipNavigationHandlers = false, - ) - } else { - if (userWallets.any { it.isLocked }) { - store.dispatchNavigationAction { - replaceAll( - AppRoute.Welcome( - launchMode = launchMode, - intent = intentWhichStartedActivity?.let(::SerializableIntent), - ), - ) - } - } else { - store.dispatchNavigationAction { - replaceAll(AppRoute.Wallet) - } - } - - intentProcessor.handleIntent( - intent = intentWhichStartedActivity, - isFromForeground = false, - skipNavigationHandlers = true, - ) - } - - if (intent != null) { - handleDeepLink(intent = intent, isFromOnNewIntent = false) - } - - viewModel.checkForUnfinishedBackup() - } - } - - private fun navigateToInitialScreen(intentWhichStartedActivity: Intent?) { - val launchMode = backgroundScanIntentHandler.getInitScreenLaunchMode(intentWhichStartedActivity) - - // Workaround to navigate to TangemPayDetails screen. Will be deleted in next PRs - if (tangemPayFeatureToggles.isTangemPayEnabled) { - store.dispatchNavigationAction { - replaceAll(AppRoute.TangemPayDetails) - } - } else if (userWalletsListManager.isLockable && userWalletsListManager.hasUserWallets) { - store.dispatchNavigationAction { - replaceAll( - AppRoute.Welcome( - launchMode = launchMode, - intent = intentWhichStartedActivity?.let(::SerializableIntent), - ), - ) - } - intentProcessor.handleIntent( - intent = intentWhichStartedActivity, - isFromForeground = false, - skipNavigationHandlers = true, - ) - } else { - lifecycleScope.launch { - val shouldShowTos = !cardRepository.isTangemTOSAccepted() - - val route = if (shouldShowTos) { - AppRoute.Disclaimer(isTosAccepted = false) - } else { - AppRoute.Home(launchMode = launchMode) - } - - store.dispatchNavigationAction { replaceAll(route) } - intentProcessor.handleIntent( - intent = intentWhichStartedActivity, - isFromForeground = false, - skipNavigationHandlers = false, - ) - } - } - - if (intent != null) { - handleDeepLink(intent = intent, isFromOnNewIntent = false) - } - - viewModel.checkForUnfinishedBackup() - } - private fun handleDeepLink(intent: Intent, isFromOnNewIntent: Boolean) { val deepLinkExtras = PayloadToDeeplinkConverter.convertBundle(intent.extras)?.toUri() val webLink = intent.getStringExtra(WEBLINK_KEY) @@ -636,5 +482,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { companion object { private const val APP_THEME_LOAD_TIMEOUT = 2 private const val MOCKED_BUILD_TYPE = "mocked" + private const val OPENED_FROM_GCM_PUSH = "google.sent_time" // every bundle from FCM contains this key } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 09c556cae1..9a3f617366 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -47,7 +47,7 @@ import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.common.LogConfig -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase @@ -63,6 +63,7 @@ import com.tangem.tap.common.analytics.AnalyticsFactory import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler import com.tangem.tap.common.analytics.handlers.amplitude.AmplitudeAnalyticsHandler +import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerAnalyticsHandler import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandler import com.tangem.tap.common.images.createCoilImageLoader import com.tangem.tap.common.log.TangemAppLoggerInitializer @@ -75,9 +76,12 @@ import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.wallet.BuildConfig import dagger.hilt.EntryPoints -import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.rekotlin.Store -import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository +import timber.log.Timber lateinit var store: Store @@ -111,9 +115,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat private val cardScanningFeatureToggles: CardScanningFeatureToggles get() = entryPoint.getCardScanningFeatureToggles() - private val walletConnect2Repository: WalletConnect2Repository - get() = entryPoint.getWalletConnect2Repository() - private val scanCardProcessor: ScanCardProcessor get() = entryPoint.getScanCardProcessor() @@ -165,8 +166,8 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase get() = entryPoint.getSendFeedbackEmailUseCase() - private val getCardInfoUseCase: GetCardInfoUseCase - get() = entryPoint.getGetCardInfoUseCase() + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase + get() = entryPoint.getWalletMetaInfoUseCase() private val urlOpener get() = entryPoint.getUrlOpener() @@ -236,6 +237,9 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat private val hotWalletFeatureToggles get() = entryPoint.getHotWalletFeatureToggles() + private val wcInitializeUseCase + get() = entryPoint.getWcInitializeUseCase() + // endregion private val appScope = MainScope() @@ -289,15 +293,16 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat tangemAppLoggerInitializer.initialize() + Timber.i("APP STARTED") + if (BuildConfig.TESTER_MENU_ENABLED) { + Timber.i(featureTogglesManager.toString()) + Timber.i(excludedBlockchainsManager.toString()) + } + foregroundActivityObserver = ForegroundActivityObserver() registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks) - // We need to initialize the toggles and excludedBlockchainsManager before the MainActivity starts using them. runBlocking { - awaitAll( - async { featureTogglesManager.init() }, - async { excludedBlockchainsManager.init() }, - ) initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize()) } @@ -329,7 +334,8 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat ) appStateHolder.mainStore = store - walletConnect2Repository.init( + + wcInitializeUseCase.init( projectId = environmentConfigStorage.getConfigSync().walletConnectProjectId, ) } @@ -342,7 +348,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat daggerGraphState = DaggerGraphState( networkConnectionManager = networkConnectionManager, cardScanningFeatureToggles = cardScanningFeatureToggles, - walletConnectRepository = walletConnect2Repository, scanCardProcessor = scanCardProcessor, appCurrencyRepository = appCurrencyRepository, walletManagersFacade = walletManagersFacade, @@ -357,7 +362,7 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat settingsRepository = settingsRepository, blockchainSDKFactory = blockchainSDKFactory, sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, - getCardInfoUseCase = getCardInfoUseCase, + getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, issuersConfigStorage = issuersConfigStorage, urlOpener = urlOpener, shareManager = shareManager, @@ -401,6 +406,7 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat val factory = AnalyticsFactory() factory.addHandlerBuilder(AmplitudeAnalyticsHandler.Builder()) factory.addHandlerBuilder(FirebaseAnalyticsHandler.Builder()) + factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder()) factory.addFilter(oneTimeEventFilter) diff --git a/app/src/main/java/com/tangem/tap/common/DialogManager.kt b/app/src/main/java/com/tangem/tap/common/DialogManager.kt index 814384c569..c863320c2a 100644 --- a/app/src/main/java/com/tangem/tap/common/DialogManager.kt +++ b/app/src/main/java/com/tangem/tap/common/DialogManager.kt @@ -5,9 +5,10 @@ import android.content.Context import com.tangem.domain.redux.StateDialog import com.tangem.tap.common.redux.AppDialog import com.tangem.tap.common.redux.global.GlobalState -import com.tangem.tap.common.ui.* -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectDialog -import com.tangem.tap.features.details.ui.walletconnect.dialogs.* +import com.tangem.tap.common.ui.ScanFailsDialog +import com.tangem.tap.common.ui.SimpleAlertDialog +import com.tangem.tap.common.ui.SimpleCancelableAlertDialog +import com.tangem.tap.common.ui.SimpleOkDialog import com.tangem.tap.features.onboarding.OnboardingDialog import com.tangem.tap.features.onboarding.products.wallet.redux.BackupDialog import com.tangem.tap.features.onboarding.products.wallet.ui.dialogs.ConfirmDiscardingBackupDialog @@ -59,88 +60,6 @@ class DialogManager : StoreSubscriber { context = context, ) is OnboardingDialog.WalletActivationError -> WalletActivationErrorDialog.create(context, state.dialog) - is WalletConnectDialog.UnsupportedCard -> - SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - messageRes = R.string.wallet_connect_scanner_error_not_valid_card, - context = context, - ) - is WalletConnectDialog.AddNetwork -> { - val message = context.getString( - R.string.wallet_connect_error_missing_blockchains, - ) + state.dialog.networks.joinToString() - SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - message = message, - context = context, - ) - } - is WalletConnectDialog.OpeningSessionRejected -> { - SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - messageRes = R.string.wallet_connect_same_wcuri, - context = context, - ) - } - is WalletConnectDialog.SessionTimeout -> { - SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - messageRes = R.string.wallet_connect_error_timeout, - context = context, - ) - } - is WalletConnectDialog.RequestTransaction -> TransactionDialog.create(state.dialog.data, context) - is WalletConnectDialog.PersonalSign -> PersonalSignDialog.create(state.dialog.data, context) - is WalletConnectDialog.BnbTransactionDialog -> - BnbTransactionDialog.create( - preparedData = state.dialog.data, - context = context, - ) - is WalletConnectDialog.UnsupportedNetwork -> { - val warning = if (state.dialog.networks.isNullOrEmpty()) { - context.getString(R.string.wallet_connect_scanner_error_unsupported_network) - } else { - context.getString(R.string.wallet_connect_error_unsupported_blockchains) + - state.dialog.networks.joinToString() - } - SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - message = warning, - context = context, - ) - } - is WalletConnectDialog.UnsupportedDapp -> SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - messageRes = R.string.wallet_connect_error_unsupported_dapp, - context = context, - ) - is WalletConnectDialog.SessionProposalDialog -> { - SessionProposalDialog.create( - sessionProposal = state.dialog.sessionProposal, - networks = state.dialog.networks, - context = context, - onApprove = state.dialog.onApprove, - onReject = state.dialog.onReject, - ) - } - is WalletConnectDialog.SignTransactionDialog -> SignTransactionDialog.create( - preparedData = state.dialog.data, - context = context, - ) - is WalletConnectDialog.SignTransactionsDialog -> SignTransactionsDialog.create( - preparedData = state.dialog.data, - context = context, - ) - is WalletConnectDialog.PairConnectErrorDialog -> SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - message = state.dialog.error.message, - context = context, - ) - is WalletConnectDialog.UnsupportedWcVersion -> SimpleAlertDialog.create( - titleRes = R.string.common_error, - messageRes = R.string.unsupported_wc_version, - context = context, - ) is BackupDialog.UnfinishedBackupFound -> UnfinishedBackupFoundDialog.create( context = context, scanResponse = state.dialog.scanResponse, diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/WalletConnect.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/WalletConnect.kt deleted file mode 100644 index 24566a94f8..0000000000 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/WalletConnect.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.tangem.tap.common.analytics.events - -import com.tangem.core.analytics.models.AnalyticsEvent -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction.OpenSession.SourceType - -/** -[REDACTED_AUTHOR] - */ -internal sealed class WalletConnect( - event: String, - params: Map = mapOf(), -) : AnalyticsEvent("Wallet Connect", event, params) { - - class ScreenOpened : WalletConnect(event = "WC Screen Opened") - class NewSessionInitiated(source: SourceType) : WalletConnect( - event = "Session Initiated", - params = mapOf( - AnalyticsParam.SOURCE to when (source) { - SourceType.QR -> "QR" - SourceType.DEEPLINK -> "DeepLink" - SourceType.CLIPBOARD -> "Clipboard" - SourceType.ETC -> "etc" - }, - ), - ) - - data object SessionFailed : WalletConnect( - event = "Session Failed", - ) - - class DAppConnectionRequested( - blockchainNames: List, - ) : WalletConnect( - event = "dApp Connection Requested", - params = mapOf( - AnalyticsParam.NETWORKS to blockchainNames.joinToString(","), - ), - ) - - class DAppConnected(dAppName: String, dAppUrl: String, blockchainNames: List) : WalletConnect( - event = "dApp Connected", - params = mapOf( - AnalyticsParam.DAPP_NAME to dAppName, - AnalyticsParam.DAPP_URL to dAppUrl, - AnalyticsParam.BLOCKCHAIN to blockchainNames.joinToString(","), - ), - ) - - class DAppConnectionFailed(dAppName: String, dAppUrl: String, blockchainNames: List) : WalletConnect( - event = "dApp Connection Failed", - params = mapOf( - AnalyticsParam.DAPP_NAME to dAppName, - AnalyticsParam.DAPP_URL to dAppUrl, - AnalyticsParam.BLOCKCHAIN to blockchainNames.joinToString(","), - ), - ) - - class SessionDisconnected(dAppName: String, dAppUrl: String) : WalletConnect( - event = "dApp Disconnected", - params = mapOf( - AnalyticsParam.DAPP_NAME to dAppName, - AnalyticsParam.DAPP_URL to dAppUrl, - ), - ) - - class SignatureRequestHandled( - params: RequestHandledParams, - ) : WalletConnect( - event = "Signature Request Handled", - params = params.toParamsMap(), - ) - - class SignatureRequestReceived( - params: RequestHandledParams, - ) : WalletConnect( - event = "Signature Request Received", - params = params.toParamsMap(), - ) - - class SignatureRequestFailed( - params: RequestHandledParams, - ) : WalletConnect( - event = "Signature Request Failed", - params = params.toParamsMap(), - ) - - data class RequestHandledParams( - val dAppName: String, - val dAppUrl: String, - val methodName: String, - val blockchain: String, - val errorCode: String? = null, - val errorDescription: String? = null, - ) { - fun toParamsMap(): Map { - val validation = if (errorCode == null) Validation.SUCCESS.param else Validation.FAIL.param - val code = errorCode ?: SUCCESS_CODE - return buildMap { - put(AnalyticsParam.DAPP_NAME, dAppName) - put(AnalyticsParam.DAPP_URL, dAppUrl) - put(AnalyticsParam.METHOD_NAME, methodName) - put(AnalyticsParam.BLOCKCHAIN, blockchain) - put(AnalyticsParam.VALIDATION, validation) - put(AnalyticsParam.ERROR_CODE, code) - if (errorDescription != null) { - put(AnalyticsParam.ERROR_DESCRIPTION, errorDescription) - } - } - } - } - - enum class Validation(val param: String) { - SUCCESS("Success"), - FAIL("Fail"), - } - - private companion object { - const val SUCCESS_CODE = "0" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt new file mode 100644 index 0000000000..aa2b3b40db --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt @@ -0,0 +1,26 @@ +package com.tangem.tap.common.analytics.handlers.appsflyer + +import android.content.Context +import com.appsflyer.AppsFlyerLib +import com.tangem.core.analytics.api.EventLogger + +interface AppsFlyerAnalyticsClient : EventLogger + +internal class AppsFlyerClient( + private val context: Context, + key: String, + appId: String, +) : AppsFlyerAnalyticsClient { + + private val appsFlyerLib: AppsFlyerLib = AppsFlyerLib.getInstance() + + init { + appsFlyerLib.init(key, null, context) + appsFlyerLib.setAppId(appId) + appsFlyerLib.start(context) + } + + override fun logEvent(event: String, params: Map) { + appsFlyerLib.logEvent(context, event, params) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsHandler.kt new file mode 100644 index 0000000000..d14d543384 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsHandler.kt @@ -0,0 +1,32 @@ +package com.tangem.tap.common.analytics.handlers.appsflyer + +import com.tangem.core.analytics.api.AnalyticsHandler +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder + +class AppsFlyerAnalyticsHandler( + private val client: AppsFlyerAnalyticsClient, +) : AnalyticsHandler { + + override fun id(): String = ID + + override fun send(eventId: String, params: Map) { + client.logEvent(eventId, params) + } + + override fun send(event: AnalyticsEvent) { + super.send(event) + } + + companion object { + const val ID = "AppsFlyer" + } + + class Builder : AnalyticsHandlerBuilder { + override fun build(data: AnalyticsHandlerBuilder.Data): AnalyticsHandler? = when { + !data.isDebug -> AppsFlyerClient(data.application, data.config.appsFlyerApiKey, data.config.appsAppId) + data.isDebug && data.logConfig.appsflyer -> AppsFlyerLogClient(data.jsonConverter) + else -> null + }?.let { AppsFlyerAnalyticsHandler(it) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerLogClient.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerLogClient.kt new file mode 100644 index 0000000000..36284bd8df --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerLogClient.kt @@ -0,0 +1,15 @@ +package com.tangem.tap.common.analytics.handlers.appsflyer + +import com.tangem.common.json.MoshiJsonConverter +import com.tangem.tap.common.analytics.AnalyticsEventsLogger + +internal class AppsFlyerLogClient( + jsonConverter: MoshiJsonConverter, +) : AppsFlyerAnalyticsClient { + + private val logger = AnalyticsEventsLogger(AppsFlyerAnalyticsHandler.ID, jsonConverter) + + override fun logEvent(event: String, params: Map) { + logger.logEvent(event, params) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/buildconfig/AppConfigurationProviderImpl.kt b/app/src/main/java/com/tangem/tap/common/buildconfig/AppConfigurationProviderImpl.kt new file mode 100644 index 0000000000..7a87d69e7c --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/buildconfig/AppConfigurationProviderImpl.kt @@ -0,0 +1,13 @@ +package com.tangem.tap.common.buildconfig + +import com.tangem.utils.buildConfig.AppConfigurationProvider +import com.tangem.wallet.BuildConfig +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class AppConfigurationProviderImpl @Inject constructor() : AppConfigurationProvider { + + override fun isDebug(): Boolean = BuildConfig.BUILD_TYPE == "debug" + override fun isHuawei(): Boolean = BuildConfig.FLAVOR_NAME == "huawei" +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt b/app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt new file mode 100644 index 0000000000..a04f41db40 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt @@ -0,0 +1,102 @@ +package com.tangem.tap.common.pushes + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import coil.executeBlocking +import coil.request.ImageRequest +import com.tangem.domain.common.LogConfig +import com.tangem.tap.MainActivity +import com.tangem.tap.common.images.createCoilImageLoader +import com.tangem.wallet.R + +class PushNotificationDelegate(private val context: Context) { + + @Suppress("LongParameterList") + fun showNotification( + dataMap: Map, + title: String?, + body: String?, + channelId: String, + priority: Int, + imageUrl: Uri? = null, + vibratePattern: LongArray?, + ) { + val intent = Intent(context, MainActivity::class.java).apply { + dataMap.forEach { (key, value) -> + putExtra(key, value) + } + putExtra(OPENED_FROM_GCM_PUSH, true) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + + val pendingIntent = PendingIntent.getActivity( + /* context = */ context, + /* requestCode = */ PUSH_NOTIFICATION_REQUEST_CODE, + /* intent = */ intent, + /* flags = */ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notificationBuilder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_tangem_24) + .setContentTitle(title) + .setContentText(body) + .setPriority(priority) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setVibrate(vibratePattern) + .apply { + imageUrl?.let { uri -> + val bitmap = getBitmapImageFromUrl(uri) + setStyle( + NotificationCompat + .BigPictureStyle() + .bigPicture(bitmap), + ).setLargeIcon(bitmap) + } + } + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationChannel = NotificationChannel( + channelId, + ContextCompat.getString(context, R.string.tangem_app_name), + NotificationManager.IMPORTANCE_HIGH, + ) + notificationManager.createNotificationChannel(notificationChannel) + } + + // Generating unique notification id + val uniqueId = (System.currentTimeMillis() % Integer.MAX_VALUE).toInt() + + notificationManager.notify( + /* id = */ uniqueId, + /* notification = */ notificationBuilder.build(), + ) + } + + private fun getBitmapImageFromUrl(url: Uri): Bitmap? { + return createCoilImageLoader( + context, + logEnabled = LogConfig.imageLoader, + ).executeBlocking( + ImageRequest.Builder(context) + .data(url) + .build(), + ).drawable?.toBitmap() + } + + private companion object { + const val PUSH_NOTIFICATION_REQUEST_CODE = 123 + private const val OPENED_FROM_GCM_PUSH = "google.sent_time" // every bundle from FCM contains this key + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt b/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt index ebeec6dffc..97026725ea 100644 --- a/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt +++ b/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt @@ -1,30 +1,17 @@ package com.tangem.tap.common.pushes import android.annotation.SuppressLint -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Intent -import android.graphics.Bitmap -import android.net.Uri -import android.os.Build -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.toBitmap -import coil.executeBlocking -import coil.request.ImageRequest import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage -import com.tangem.domain.common.LogConfig -import com.tangem.tap.MainActivity -import com.tangem.tap.common.images.createCoilImageLoader -import com.tangem.tap.features.intentHandler.handlers.OnPushClickedIntentHandler -import com.tangem.wallet.R import timber.log.Timber @SuppressLint("MissingFirebaseInstanceTokenRefresh") internal class TangemPushNotificationService : FirebaseMessagingService() { + private val pushNotificationDelegate: PushNotificationDelegate by lazy { + PushNotificationDelegate(applicationContext) + } + override fun onNewToken(token: String) { super.onNewToken(token) Timber.d("New FCM token received: $token") @@ -36,73 +23,18 @@ internal class TangemPushNotificationService : FirebaseMessagingService() { val notification = message.notification ?: return val channelId = notification.channelId ?: TANGEM_CHANNEL_ID - val intent = Intent(applicationContext, MainActivity::class.java).apply { - message.data.forEach { - putExtra(it.key, it.value) - } - putExtra(OnPushClickedIntentHandler.OPENED_FROM_GCM_PUSH, true) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - val pendingIntent = PendingIntent.getActivity( - /* context = */ this, - /* requestCode = */ PUSH_NOTIFICATION_REQUEST_CODE, - /* intent = */ intent, - /* flags = */ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, + pushNotificationDelegate.showNotification( + dataMap = message.data, + title = notification.title, + body = notification.body, + channelId = channelId, + priority = message.priority, + imageUrl = notification.imageUrl, + vibratePattern = notification.vibrateTimings, ) - - val notificationBuilder = - NotificationCompat.Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.ic_tangem_24) - .setContentTitle(notification.title) - .setContentText(notification.body) - .setPriority(message.priority) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - .setVibrate(notification.vibrateTimings) - .apply { - notification.imageUrl?.let { uri -> - val bitmap = getBitmapImageFromUrl(uri) - setStyle( - NotificationCompat - .BigPictureStyle() - .bigPicture(bitmap), - ).setLargeIcon(bitmap) - } - } - - val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val notificationChannel = NotificationChannel( - channelId, - ContextCompat.getString(applicationContext, R.string.tangem_app_name), - NotificationManager.IMPORTANCE_HIGH, - ) - notificationManager.createNotificationChannel(notificationChannel) - } - - // Generating unique notification id - val uniqueId = (System.currentTimeMillis() % Integer.MAX_VALUE).toInt() - - notificationManager.notify( - /* id = */ uniqueId, - /* notification = */ notificationBuilder.build(), - ) - } - - private fun getBitmapImageFromUrl(url: Uri): Bitmap? { - return createCoilImageLoader( - applicationContext, - logEnabled = LogConfig.imageLoader, - ).executeBlocking( - ImageRequest.Builder(applicationContext) - .data(url) - .build(), - ).drawable?.toBitmap() } private companion object { const val TANGEM_CHANNEL_ID = "Tangem General" // General channel for notifications - const val PUSH_NOTIFICATION_REQUEST_CODE = 123 } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt index 8bd03da756..6099bad60a 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt @@ -2,7 +2,6 @@ package com.tangem.tap.common.redux import com.tangem.tap.common.redux.global.globalReducer import com.tangem.tap.features.details.redux.DetailsReducer -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectReducer import com.tangem.tap.features.welcome.redux.WelcomeReducer import com.tangem.tap.proxy.redux.DaggerGraphReducer import org.rekotlin.Action @@ -14,7 +13,6 @@ fun appReducer(action: Action, state: AppState?): AppState { return AppState( globalState = globalReducer(action, state), detailsState = DetailsReducer.reduce(action, state), - walletConnectState = WalletConnectReducer.reduce(action, state.walletConnectState), welcomeState = WelcomeReducer.reduce(action, state), daggerGraphState = DaggerGraphReducer.reduce(action, state), ) diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt index 427a103a98..7c3065b0f4 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt @@ -5,8 +5,6 @@ import com.tangem.tap.common.redux.global.GlobalState import com.tangem.tap.common.redux.legacy.LegacyMiddleware import com.tangem.tap.features.details.redux.DetailsMiddleware import com.tangem.tap.features.details.redux.DetailsState -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectMiddleware -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectState import com.tangem.tap.features.onboarding.products.wallet.redux.BackupMiddleware import com.tangem.tap.features.wallet.redux.middlewares.TradeCryptoMiddleware import com.tangem.tap.features.welcome.redux.WelcomeMiddleware @@ -19,7 +17,6 @@ import org.rekotlin.StateType data class AppState( val globalState: GlobalState = GlobalState(), val detailsState: DetailsState = DetailsState(), - val walletConnectState: WalletConnectState = WalletConnectState(), val welcomeState: WelcomeState = WelcomeState(), val daggerGraphState: DaggerGraphState = DaggerGraphState(), ) : StateType { @@ -30,7 +27,6 @@ data class AppState( logMiddleware, GlobalMiddleware.handler, DetailsMiddleware().detailsMiddleware, - WalletConnectMiddleware().walletConnectMiddleware, BackupMiddleware().backupMiddleware, WelcomeMiddleware().middleware, LockUserWalletsTimerMiddleware().middleware, diff --git a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt new file mode 100644 index 0000000000..f990fdc0fd --- /dev/null +++ b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt @@ -0,0 +1,77 @@ +package com.tangem.tap.data + +import android.content.Context +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.visa.model.VisaAuthTokens +import com.tangem.sdk.storage.AndroidSecureStorageV2 +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.text.encodeToByteArray + +private const val DEFAULT_KEY = "tangem_pay_default_key" +private const val DEFAULT_CUSTOMER_WALLET_ADDRESS_KEY = "tangem_pay_default_customer_wallet_address_key" + +@Singleton +internal class DefaultTangemPayStorage @Inject constructor( + @ApplicationContext applicationContext: Context, + private val dispatcherProvider: CoroutineDispatcherProvider, +) : TangemPayStorage { + + private val secureStorage by lazy { + AndroidSecureStorageV2( + appContext = applicationContext, + useStrongBox = false, + name = "tangem_pay_storage", + ) + } + private val moshi by lazy { + Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + } + + private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) } + + override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens) = + withContext(dispatcherProvider.io) { + val json = tokensAdapter.toJson(tokens) + + secureStorage.store( + json.encodeToByteArray(throwOnInvalidSequence = true), + createKey(customerWalletAddress), + ) + } + + override suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens? = + withContext(dispatcherProvider.io) { + secureStorage.get(createKey(customerWalletAddress)) + ?.decodeToString(throwOnInvalidSequence = true) + ?.let(tokensAdapter::fromJson) + } + + /** + * Store the only customer wallet address, since for the f&f user can issue only one card tied to one address + */ + override suspend fun storeCustomerWalletAddress(customerWalletAddress: String) = + withContext(dispatcherProvider.io) { + secureStorage.store( + customerWalletAddress.encodeToByteArray(throwOnInvalidSequence = true), + DEFAULT_CUSTOMER_WALLET_ADDRESS_KEY, + ) + } + + override suspend fun getCustomerWalletAddress(): String? = withContext(dispatcherProvider.io) { + secureStorage.get(DEFAULT_CUSTOMER_WALLET_ADDRESS_KEY)?.decodeToString(throwOnInvalidSequence = true) + } + + override suspend fun clear(customerWalletAddress: String) = withContext(dispatcherProvider.io) { + secureStorage.delete(createKey(customerWalletAddress)) + } + + private fun createKey(cardId: String): String = "${DEFAULT_KEY}_$cardId" +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt b/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt index d780f3d15a..d0fd68250c 100644 --- a/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt +++ b/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt @@ -19,6 +19,9 @@ internal class RuntimeUserWalletsStore( override val userWallets: Flow> get() = userWalletsListManager.userWallets + override val userWalletsSync: List + get() = userWalletsListManager.userWalletsSync + override fun getSyncOrNull(key: UserWalletId): UserWallet? { return userWalletsListManager.userWalletsSync.firstOrNull { it.walletId == key } } diff --git a/app/src/main/java/com/tangem/tap/data/UserWalletsStoreRepositoryProxy.kt b/app/src/main/java/com/tangem/tap/data/UserWalletsStoreRepositoryProxy.kt index c5275bf034..d41ea29466 100644 --- a/app/src/main/java/com/tangem/tap/data/UserWalletsStoreRepositoryProxy.kt +++ b/app/src/main/java/com/tangem/tap/data/UserWalletsStoreRepositoryProxy.kt @@ -3,9 +3,9 @@ package com.tangem.tap.data import com.tangem.common.CompletionResult import com.tangem.common.catching import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.core.wallets.UserWalletsListRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -24,6 +24,9 @@ class UserWalletsStoreRepositoryProxy( } } + override val userWalletsSync: List + get() = userWalletsListRepository.userWallets.value.orEmpty() + override fun getSyncOrNull(key: UserWalletId): UserWallet? { return userWalletsListRepository.userWallets.value?.find { it.walletId == key } } diff --git a/app/src/main/java/com/tangem/tap/di/AppConfigurationModule.kt b/app/src/main/java/com/tangem/tap/di/AppConfigurationModule.kt new file mode 100644 index 0000000000..42148c10d5 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/AppConfigurationModule.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.di + +import com.tangem.tap.common.buildconfig.AppConfigurationProviderImpl +import com.tangem.utils.buildConfig.AppConfigurationProvider +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 AppConfigurationModule { + + @Binds + @Singleton + fun bindAppConfigurationProvider(impl: AppConfigurationProviderImpl): AppConfigurationProvider +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/IntentHandlingModule.kt b/app/src/main/java/com/tangem/tap/di/IntentHandlingModule.kt index cd7becb2e0..64f87e9dcb 100644 --- a/app/src/main/java/com/tangem/tap/di/IntentHandlingModule.kt +++ b/app/src/main/java/com/tangem/tap/di/IntentHandlingModule.kt @@ -1,10 +1,6 @@ package com.tangem.tap.di -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.tap.features.intentHandler.IntentProcessor import com.tangem.tap.features.intentHandler.handlers.BackgroundScanIntentHandler -import com.tangem.tap.features.intentHandler.handlers.OnPushClickedIntentHandler -import com.tangem.tap.features.intentHandler.handlers.WalletConnectLinkIntentHandler import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,17 +14,4 @@ internal object IntentHandlingModule { @Provides @Singleton fun provideBackgroundScanIntentHandler(): BackgroundScanIntentHandler = BackgroundScanIntentHandler() - - @Provides - @Singleton - fun provideWalletConnectLinkIntentHandler(): WalletConnectLinkIntentHandler = WalletConnectLinkIntentHandler() - - @Provides - @Singleton - fun provideOnPushClickedIntentHandler(analyticsEventHandler: AnalyticsEventHandler): OnPushClickedIntentHandler = - OnPushClickedIntentHandler(analyticsEventHandler) - - @Provides - @Singleton - fun provideIntentProcessor(): IntentProcessor = IntentProcessor() } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt b/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt index c78909a676..1927963c23 100644 --- a/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt +++ b/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt @@ -1,7 +1,9 @@ package com.tangem.tap.di.data +import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.datasource.local.visa.VisaOTPStorage +import com.tangem.tap.data.DefaultTangemPayStorage import com.tangem.tap.data.DefaultVisaAuthTokenStorage import com.tangem.tap.data.DefaultVisaOTPStorage import dagger.Binds @@ -21,4 +23,8 @@ internal interface VisaStorageModule { @Binds @Singleton fun bindVisaOTPStorage(impl: DefaultVisaOTPStorage): VisaOTPStorage + + @Binds + @Singleton + fun bindTangemPayStorage(impl: DefaultTangemPayStorage): TangemPayStorage } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt index 8cf8edf8d6..d90cfcd00a 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt @@ -1,5 +1,6 @@ package com.tangem.tap.di.domain +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.usecase.* import dagger.Module @@ -42,6 +43,12 @@ internal object AccountDomainModule { return RecoverCryptoPortfolioUseCase(crudRepository = accountsCRUDRepository) } + @Provides + @Singleton + fun provideGetArchivedAccountsUseCase(accountsCRUDRepository: AccountsCRUDRepository): GetArchivedAccountsUseCase { + return GetArchivedAccountsUseCase(crudRepository = accountsCRUDRepository) + } + @Provides @Singleton fun provideGetUnoccupiedAccountIndexUseCase( @@ -49,4 +56,16 @@ internal object AccountDomainModule { ): GetUnoccupiedAccountIndexUseCase { return GetUnoccupiedAccountIndexUseCase(crudRepository = accountsCRUDRepository) } + + @Provides + @Singleton + fun provideIsAccountsModeEnabledUseCase( + accountsCRUDRepository: AccountsCRUDRepository, + accountsFeatureToggles: AccountsFeatureToggles, + ): IsAccountsModeEnabledUseCase { + return IsAccountsModeEnabledUseCase( + crudRepository = accountsCRUDRepository, + accountsFeatureToggles = accountsFeatureToggles, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/FeedbackDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/FeedbackDomainModule.kt index b905aa7edc..48bfb0b921 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/FeedbackDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/FeedbackDomainModule.kt @@ -1,7 +1,7 @@ package com.tangem.tap.di.domain import android.content.Context -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.repository.FeedbackRepository @@ -18,8 +18,8 @@ internal object FeedbackDomainModule { @Provides @Singleton - fun provideGetCardInfoUseCase(feedbackRepository: FeedbackRepository): GetCardInfoUseCase { - return GetCardInfoUseCase(feedbackRepository = feedbackRepository) + fun provideGetCardInfoUseCase(feedbackRepository: FeedbackRepository): GetWalletMetaInfoUseCase { + return GetWalletMetaInfoUseCase(feedbackRepository = feedbackRepository) } @Provides diff --git a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt index 4d82335720..d16fc22bd8 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt @@ -6,7 +6,6 @@ import com.tangem.domain.nft.repository.NFTRepository import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -33,29 +32,21 @@ internal object NFTDomainModule { @Provides @Singleton fun providesFetchNFTCollectionsUseCase( - currenciesRepository: CurrenciesRepository, nftRepository: NFTRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): FetchNFTCollectionsUseCase = FetchNFTCollectionsUseCase( - currenciesRepository = currenciesRepository, nftRepository = nftRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) @Provides @Singleton fun providesRefreshAllNFTUseCase( - currenciesRepository: CurrenciesRepository, nftRepository: NFTRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): RefreshAllNFTUseCase = RefreshAllNFTUseCase( - currenciesRepository = currenciesRepository, nftRepository = nftRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) @Provides @@ -127,16 +118,12 @@ internal object NFTDomainModule { fun provideDisableWalletNFTUseCase( walletsRepository: WalletsRepository, nftRepository: NFTRepository, - currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): DisableWalletNFTUseCase { return DisableWalletNFTUseCase( walletsRepository = walletsRepository, nftRepository = nftRepository, - currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/NotificationsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NotificationsDomainModule.kt index b35ae23d95..5e28ca4628 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NotificationsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NotificationsDomainModule.kt @@ -1,11 +1,8 @@ package com.tangem.tap.di.domain -import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.domain.notifications.* import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.notifications.repository.PushNotificationsRepository -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles -import com.tangem.tap.domain.notifications.DefaultNotificationsFeatureToggles import com.tangem.utils.notifications.PushNotificationsTokenProvider import dagger.Module import dagger.Provides @@ -79,12 +76,6 @@ internal object NotificationsDomainModule { ) } - @Provides - @Singleton - fun provideNotificationsFeatureToggles(featureTogglesManager: FeatureTogglesManager): NotificationsFeatureToggles { - return DefaultNotificationsFeatureToggles(featureTogglesManager = featureTogglesManager) - } - @Provides @Singleton fun provideGetNetworksAvailableForNotifications( diff --git a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt index 335ce4196c..0c20fd0993 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt @@ -1,11 +1,7 @@ package com.tangem.tap.di.domain -import com.tangem.domain.onramp.repositories.LegacyTopUpRepository import com.tangem.domain.onramp.* -import com.tangem.domain.onramp.repositories.HotCryptoRepository -import com.tangem.domain.onramp.repositories.OnrampErrorResolver -import com.tangem.domain.onramp.repositories.OnrampRepository -import com.tangem.domain.onramp.repositories.OnrampTransactionRepository +import com.tangem.domain.onramp.repositories.* import com.tangem.domain.settings.repositories.SettingsRepository import dagger.Module import dagger.Provides @@ -27,6 +23,15 @@ internal object OnrampDomainModule { return GetOnrampCurrenciesUseCase(onrampRepository, onrampErrorResolver) } + @Provides + @Singleton + fun provideGetOnrampCurrencyUseCase( + onrampRepository: OnrampRepository, + onrampErrorResolver: OnrampErrorResolver, + ): OnrampGetDefaultCurrencyUseCase { + return OnrampGetDefaultCurrencyUseCase(onrampRepository, onrampErrorResolver) + } + @Provides @Singleton fun provideOnrampSaveDefaultCurrencyUseCase( @@ -120,6 +125,12 @@ internal object OnrampDomainModule { return OnrampSaveTransactionUseCase(onrampTransactionRepository, onrampErrorResolver) } + @Provides + @Singleton + fun provideOnrampSepaAvailableUseCase(onrampRepository: OnrampRepository): OnrampSepaAvailableUseCase { + return OnrampSepaAvailableUseCase(onrampRepository) + } + @Provides @Singleton fun provideOnrampUpdateTransactionStatusUseCase( @@ -231,4 +242,34 @@ internal object OnrampDomainModule { fun provideGetLegacyTopUpUrlUseCase(legacyTopUpRepository: LegacyTopUpRepository): GetLegacyTopUpUrlUseCase { return GetLegacyTopUpUrlUseCase(legacyTopUpRepository) } + + @Provides + @Singleton + fun provideGetOnrampAllOffersUseCase( + onrampRepository: OnrampRepository, + onrampErrorResolver: OnrampErrorResolver, + settingsRepository: SettingsRepository, + ): GetOnrampAllOffersUseCase { + return GetOnrampAllOffersUseCase( + onrampRepository = onrampRepository, + errorResolver = onrampErrorResolver, + settingsRepository = settingsRepository, + ) + } + + @Provides + @Singleton + fun provideGetOnrampOffersUseCase( + onrampRepository: OnrampRepository, + onrampErrorResolver: OnrampErrorResolver, + onrampTransactionRepository: OnrampTransactionRepository, + settingsRepository: SettingsRepository, + ): GetOnrampOffersUseCase { + return GetOnrampOffersUseCase( + onrampRepository = onrampRepository, + errorResolver = onrampErrorResolver, + onrampTransactionRepository = onrampTransactionRepository, + settingsRepository = settingsRepository, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt index e35089b9e9..76a87ca009 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt @@ -15,9 +15,10 @@ internal object PromoDomainModule { @Provides @Singleton fun provideShouldShowSwapPromoWalletUseCase( - promoSettingsRepository: PromoRepository, + promoRepository: PromoRepository, + settingsRepository: SettingsRepository, ): ShouldShowPromoWalletUseCase { - return ShouldShowPromoWalletUseCase(promoSettingsRepository) + return ShouldShowPromoWalletUseCase(promoRepository, settingsRepository) } @Provides diff --git a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt index df25dd1255..2cc898457b 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt @@ -54,8 +54,8 @@ internal object SettingsDomainModule { @Singleton fun providesShouldShowSaveWalletScreenUseCase( settingsRepository: SettingsRepository, - ): ShouldShowSaveWalletScreenUseCase { - return ShouldShowSaveWalletScreenUseCase(settingsRepository = settingsRepository) + ): ShouldShowAskBiometryUseCase { + return ShouldShowAskBiometryUseCase(settingsRepository = settingsRepository) } @Provides @@ -138,10 +138,8 @@ internal object SettingsDomainModule { @Provides @Singleton - fun provideSetSaveWalletScreenShownUseCase( - settingsRepository: SettingsRepository, - ): SetSaveWalletScreenShownUseCase { - return SetSaveWalletScreenShownUseCase(settingsRepository = settingsRepository) + fun provideSetSaveWalletScreenShownUseCase(settingsRepository: SettingsRepository): SetAskBiometryShownUseCase { + return SetAskBiometryShownUseCase(settingsRepository = settingsRepository) } @Provides diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index bdbe9037cc..726ee06efc 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -47,7 +47,6 @@ internal object TokensDomainModule { multiQuoteStatusFetcher: MultiQuoteStatusFetcher, singleYieldBalanceFetcher: SingleYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, stakingIdFactory: StakingIdFactory, ): AddCryptoCurrenciesUseCase { return AddCryptoCurrenciesUseCase( @@ -56,7 +55,6 @@ internal object TokensDomainModule { multiQuoteStatusFetcher = multiQuoteStatusFetcher, singleYieldBalanceFetcher = singleYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, stakingIdFactory = stakingIdFactory, ) } @@ -111,13 +109,11 @@ internal object TokensDomainModule { currenciesRepository: CurrenciesRepository, walletManagersFacade: WalletManagersFacade, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): RemoveCurrencyUseCase { return RemoveCurrencyUseCase( currenciesRepository = currenciesRepository, walletManagersFacade = walletManagersFacade, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) } @@ -156,7 +152,6 @@ internal object TokensDomainModule { dispatchers: CoroutineDispatcherProvider, baseCurrencyStatusOperations: BaseCurrencyStatusOperations, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): GetCurrencyWarningsUseCase { return GetCurrencyWarningsUseCase( walletManagersFacade = walletManagersFacade, @@ -165,7 +160,6 @@ internal object TokensDomainModule { currencyChecksRepository = currencyChecksRepository, currencyStatusOperations = baseCurrencyStatusOperations, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) } @@ -177,7 +171,6 @@ internal object TokensDomainModule { multiQuoteStatusFetcher: MultiQuoteStatusFetcher, singleYieldBalanceFetcher: SingleYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, stakingIdFactory: StakingIdFactory, ): FetchCurrencyStatusUseCase { return FetchCurrencyStatusUseCase( @@ -186,7 +179,6 @@ internal object TokensDomainModule { multiQuoteStatusFetcher = multiQuoteStatusFetcher, singleYieldBalanceFetcher = singleYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, stakingIdFactory = stakingIdFactory, ) } @@ -214,9 +206,8 @@ internal object TokensDomainModule { fun provideGetCryptoCurrencyUseCase( currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): GetCryptoCurrencyUseCase { - return GetCryptoCurrencyUseCase(currenciesRepository, multiWalletCryptoCurrenciesSupplier, tokensFeatureToggles) + return GetCryptoCurrencyUseCase(currenciesRepository, multiWalletCryptoCurrenciesSupplier) } @Provides @@ -238,13 +229,11 @@ internal object TokensDomainModule { fun provideApplyTokenListSortingUseCase( currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, dispatchers: CoroutineDispatcherProvider, ): ApplyTokenListSortingUseCase { return ApplyTokenListSortingUseCase( currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, dispatchers = dispatchers, ) } @@ -304,14 +293,10 @@ internal object TokensDomainModule { @Provides @Singleton fun provideIsCryptoCurrencyCoinCouldHideUseCase( - currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): IsCryptoCurrencyCoinCouldHideUseCase { return IsCryptoCurrencyCoinCouldHideUseCase( - currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) } @@ -330,13 +315,11 @@ internal object TokensDomainModule { fun provideGetBalanceNotEnoughForFeeWarningUseCase( currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, dispatchers: CoroutineDispatcherProvider, ): GetBalanceNotEnoughForFeeWarningUseCase { return GetBalanceNotEnoughForFeeWarningUseCase( currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, dispatchers = dispatchers, ) } @@ -377,16 +360,12 @@ internal object TokensDomainModule { @Provides @Singleton fun provideRefreshMultiCurrencyWalletQuotesUseCase( - currenciesRepository: CurrenciesRepository, multiQuoteStatusFetcher: MultiQuoteStatusFetcher, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): RefreshMultiCurrencyWalletQuotesUseCase { return RefreshMultiCurrencyWalletQuotesUseCase( - currenciesRepository = currenciesRepository, multiQuoteStatusFetcher = multiQuoteStatusFetcher, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) } @@ -402,18 +381,13 @@ internal object TokensDomainModule { @Provides @Singleton fun provideBaseCurrencyStatusOperations( - tokensFeatureToggles: TokensFeatureToggles, currenciesRepository: CurrenciesRepository, quotesRepository: QuotesRepository, singleNetworkStatusSupplier: SingleNetworkStatusSupplier, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, - multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - singleNetworkStatusFetcher: SingleNetworkStatusFetcher, - multiQuoteStatusFetcher: MultiQuoteStatusFetcher, singleQuoteStatusSupplier: SingleQuoteStatusSupplier, singleYieldBalanceSupplier: SingleYieldBalanceSupplier, multiYieldBalanceSupplier: MultiYieldBalanceSupplier, - multiYieldBalanceFetcher: MultiYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, stakingIdFactory: StakingIdFactory, ): BaseCurrencyStatusOperations { @@ -422,15 +396,10 @@ internal object TokensDomainModule { quotesRepository = quotesRepository, singleNetworkStatusSupplier = singleNetworkStatusSupplier, multiNetworkStatusSupplier = multiNetworkStatusSupplier, - multiNetworkStatusFetcher = multiNetworkStatusFetcher, - singleNetworkStatusFetcher = singleNetworkStatusFetcher, - multiQuoteStatusFetcher = multiQuoteStatusFetcher, singleQuoteStatusSupplier = singleQuoteStatusSupplier, singleYieldBalanceSupplier = singleYieldBalanceSupplier, multiYieldBalanceSupplier = multiYieldBalanceSupplier, - multiYieldBalanceFetcher = multiYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, stakingIdFactory = stakingIdFactory, ) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 0b8802198c..3a63ffb6a8 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -6,8 +6,6 @@ import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.WalletAddressServiceRepository @@ -63,18 +61,14 @@ internal object TransactionDomainModule { fun provideAssociateAssetUseCase( cardSdkConfigRepository: CardSdkConfigRepository, walletManagersFacade: WalletManagersFacade, - currenciesRepository: CurrenciesRepository, singleNetworkStatusSupplier: SingleNetworkStatusSupplier, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): AssociateAssetUseCase { return AssociateAssetUseCase( cardSdkConfigRepository = cardSdkConfigRepository, walletManagersFacade = walletManagersFacade, - currenciesRepository = currenciesRepository, singleNetworkStatusSupplier = singleNetworkStatusSupplier, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) } @@ -196,8 +190,13 @@ internal object TransactionDomainModule { fun providePrepareAndSignUseCase( transactionRepository: TransactionRepository, cardSdkConfigRepository: CardSdkConfigRepository, + tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory, ): PrepareAndSignUseCase { - return PrepareAndSignUseCase(transactionRepository, cardSdkConfigRepository) + return PrepareAndSignUseCase( + transactionRepository = transactionRepository, + cardSdkConfigRepository = cardSdkConfigRepository, + getHotTransactionSigner = { tangemHotWalletSignerFactory.create(it) }, + ) } @Provides @@ -241,4 +240,13 @@ internal object TransactionDomainModule { ): GetReverseResolvedEnsAddressUseCase { return GetReverseResolvedEnsAddressUseCase(walletAddressServiceRepository) } + + @Provides + @Singleton + fun sendLargeSolanaTransactionUseCase( + cardSdkConfigRepository: CardSdkConfigRepository, + walletManagersFacade: WalletManagersFacade, + ): SendLargeSolanaTransactionUseCase { + return SendLargeSolanaTransactionUseCase(cardSdkConfigRepository, walletManagersFacade) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt index 44dd702fc3..6304510867 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt @@ -1,5 +1,6 @@ package com.tangem.tap.di.domain +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.WalletAddressServiceRepository @@ -9,8 +10,9 @@ import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.delegate.DefaultUserWalletsSyncDelegate import com.tangem.domain.wallets.delegate.UserWalletsSyncDelegate +import com.tangem.domain.wallets.derivations.DerivationsRepository +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.domain.wallets.legacy.UserWalletsListManager -import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.wallets.repository.WalletNamesMigrationRepository import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.* @@ -25,7 +27,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") @Module @InstallIn(SingletonComponent::class) internal object WalletsDomainModule { @@ -133,6 +135,20 @@ internal object WalletsDomainModule { ) } + @Provides + @Singleton + fun providesIsWalletAlreadySavedUseCase( + userWalletsListManager: UserWalletsListManager, + userWalletsListRepository: UserWalletsListRepository, + hotWalletFeatureToggles: HotWalletFeatureToggles, + ): IsWalletAlreadySavedUseCase { + return IsWalletAlreadySavedUseCase( + userWalletsListManager = userWalletsListManager, + userWalletsListRepository = userWalletsListRepository, + useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled, + ) + } + @Provides @Singleton fun providesOpenBuyTangemCardUseCase(): GenerateBuyTangemCardLinkUseCase { @@ -352,4 +368,84 @@ internal object WalletsDomainModule { walletsRepository = walletsRepository, ) } + + @Provides + @Singleton + fun providesIsUpgradeWalletNotificationEnabledUseCase( + walletsRepository: WalletsRepository, + ): IsUpgradeWalletNotificationEnabledUseCase { + return IsUpgradeWalletNotificationEnabledUseCase( + walletsRepository = walletsRepository, + ) + } + + @Provides + @Singleton + fun providesDismissUpgradeWalletNotificationUseCase( + walletsRepository: WalletsRepository, + ): DismissUpgradeWalletNotificationUseCase { + return DismissUpgradeWalletNotificationUseCase( + walletsRepository = walletsRepository, + ) + } + + @Provides + @Singleton + fun providesUnlockHotWalletContextualUseCase( + hotWalletAccessor: HotWalletAccessor, + ): UnlockHotWalletContextualUseCase { + return UnlockHotWalletContextualUseCase( + hotWalletAccessor = hotWalletAccessor, + ) + } + + @Provides + @Singleton + fun providesGetHotWalletContextualUnlockUseCase( + hotWalletAccessor: HotWalletAccessor, + ): GetHotWalletContextualUnlockUseCase { + return GetHotWalletContextualUnlockUseCase( + hotWalletAccessor = hotWalletAccessor, + ) + } + + @Provides + @Singleton + fun providesClearHotWalletContextualUnlockUseCase( + hotWalletAccessor: HotWalletAccessor, + ): ClearHotWalletContextualUnlockUseCase { + return ClearHotWalletContextualUnlockUseCase( + hotWalletAccessor = hotWalletAccessor, + ) + } + + @Provides + @Singleton + fun providesClearAllHotWalletContextualUnlockUseCase( + hotWalletAccessor: HotWalletAccessor, + ): ClearAllHotWalletContextualUnlockUseCase { + return ClearAllHotWalletContextualUnlockUseCase( + hotWalletAccessor = hotWalletAccessor, + ) + } + + @Provides + @Singleton + fun providesExportSeedPhraseUseCase(hotWalletAccessor: HotWalletAccessor): ExportSeedPhraseUseCase { + return ExportSeedPhraseUseCase( + hotWalletAccessor = hotWalletAccessor, + ) + } + + @Provides + @Singleton + fun providesColdWalletInteractionNeededUseCase( + derivationsRepository: DerivationsRepository, + getUserWalletUseCase: GetUserWalletUseCase, + ): ColdWalletAndHasMissedDerivationsUseCase { + return ColdWalletAndHasMissedDerivationsUseCase( + derivationsRepository = derivationsRepository, + userWalletUseCase = getUserWalletUseCase, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt new file mode 100644 index 0000000000..1b342eb608 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -0,0 +1,50 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.transaction.error.FeeErrorResolver +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository +import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyStartEarningUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyStopEarningUseCase +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 YieldSupplyDomainModule { + + @Provides + @Singleton + fun provideYieldSupplyStartEarningUseCase( + yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, + ): YieldSupplyStartEarningUseCase { + return YieldSupplyStartEarningUseCase( + yieldSupplyTransactionRepository = yieldSupplyTransactionRepository, + ) + } + + @Provides + @Singleton + fun provideYieldSupplyStopEarningUseCase( + yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, + ): YieldSupplyStopEarningUseCase { + return YieldSupplyStopEarningUseCase( + yieldSupplyTransactionRepository = yieldSupplyTransactionRepository, + ) + } + + @Provides + @Singleton + fun provideYieldSupplyEstimateEnterFeeUseCase( + feeRepository: FeeRepository, + feeErrorResolver: FeeErrorResolver, + ): YieldSupplyEstimateEnterFeeUseCase { + return YieldSupplyEstimateEnterFeeUseCase( + feeRepository = feeRepository, + feeErrorResolver = feeErrorResolver, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/hot/TangemHotSdkModule.kt b/app/src/main/java/com/tangem/tap/di/hot/TangemHotSdkModule.kt index c21b55928b..53110dc9c9 100644 --- a/app/src/main/java/com/tangem/tap/di/hot/TangemHotSdkModule.kt +++ b/app/src/main/java/com/tangem/tap/di/hot/TangemHotSdkModule.kt @@ -1,5 +1,7 @@ package com.tangem.tap.di.hot +import com.tangem.data.wallets.hot.DefaultHotWalletAccessor +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.hot.sdk.TangemHotSdk import com.tangem.tap.features.hot.TangemHotSDKProxy import dagger.Binds @@ -15,4 +17,8 @@ internal interface TangemHotSdkModule { @Binds @Singleton fun bindTangemHotSdk(proxy: TangemHotSDKProxy): TangemHotSdk + + @Binds + @Singleton + fun bindHotWalletAccessor(default: DefaultHotWalletAccessor): HotWalletAccessor } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/notifications/DefaultNotificationsFeatureToggles.kt b/app/src/main/java/com/tangem/tap/domain/notifications/DefaultNotificationsFeatureToggles.kt deleted file mode 100644 index 5a58923bb9..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/notifications/DefaultNotificationsFeatureToggles.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.tap.domain.notifications - -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles - -internal class DefaultNotificationsFeatureToggles( - private val featureTogglesManager: FeatureTogglesManager, -) : NotificationsFeatureToggles { - override val isNotificationsEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled("PUSH_NOTIFICATIONS_ENABLED") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt index 386cddb495..5b44a91292 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt @@ -193,7 +193,7 @@ internal class LegacyScanProcessor @Inject constructor( } } - @Suppress("LongMethod", "MagicNumber") + @Suppress("LongMethod", "LongParameterList", "MagicNumber") private suspend inline fun onScanSuccess( scanResponse: ScanResponse, crossinline onProgressStateChange: suspend (showProgress: Boolean) -> Unit, @@ -265,7 +265,7 @@ internal class LegacyScanProcessor @Inject constructor( onOk = { mainScope.launch { onSuccess() } }, onSupportClick = { val cardInfo = - store.inject(DaggerGraphState::getCardInfoUseCase).invoke(scanResponse).getOrNull() + store.inject(DaggerGraphState::getWalletMetaInfoUseCase).invoke(scanResponse).getOrNull() ?: error("CardInfo must be not null") scope.launch { diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt index 83df27ebb4..3019dbeba0 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt @@ -5,9 +5,9 @@ import com.tangem.common.core.TangemError import com.tangem.common.core.TangemSdkError import com.tangem.domain.models.scan.ProductType import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithSeedPhraseMockContent import com.tangem.tap.domain.sdk.mocks.content.NoteMockContent import com.tangem.tap.domain.sdk.mocks.content.WalletMockContent -import com.tangem.tap.domain.sdk.mocks.content.Wallet2MockContent object MockProvider { @@ -67,7 +67,7 @@ object MockProvider { private fun getMockContent(productType: ProductType): MockContent { return when (productType) { ProductType.Wallet -> WalletMockContent - ProductType.Wallet2 -> Wallet2MockContent + ProductType.Wallet2 -> Wallet2WithSeedPhraseMockContent ProductType.Note -> NoteMockContent else -> TODO() } diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/BackupWalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/BackupWalletMockContent.kt new file mode 100644 index 0000000000..c2c8d9ba23 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/BackupWalletMockContent.kt @@ -0,0 +1,270 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object BackupWalletMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AC05000000086747", + batchId = "AC05", + cardPublicKey = byteArrayOf(2, -120, -3, -32, -122, -127, -120, -104, 59, 72, 76, 114, 94, 75, -37, -55, 55, 99, 66, 123, 85, -87, 80, 106, 105, -116, 87, -83, -12, 70, 108, -68, -39), + linkingKey = byteArrayOf( + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 3, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM AG", + publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf(EllipticCurve.Secp256k1, EllipticCurve.Ed25519, EllipticCurve.Bls12381G2Aug), + firmwareVersion = FirmwareVersion( + major = 4, + minor = 52, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AC05000000086747", + batchId = "AC05", + cardPublicKey = byteArrayOf(2, -120, -3, -32, -122, -127, -120, -104, 59, 72, 76, 114, 94, 75, -37, -55, 55, 99, 66, 123, 85, -87, 80, 106, 105, -116, 87, -83, -12, 70, 108, -68, -39), + firmwareVersion = CardDTO.FirmwareVersion( + major = 4, + minor = 52, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1649635200000), + signature = byteArrayOf(-20, 98, -101, 94, -23, 73, 122, -21, 74, 76, 79, -55, -102, -62, -30, 44, -38, 118, 75, 121, -36, 118, -62, 60, -38, -63, 33, -14, -98, -69, 112, 22, 48, -43, 47, 65, -61, -56, 38, -94, -45, 44, 95, -22, 6, -31, -40, -25, 42, 38, 94, -99, 71, 98, -33, 27, -102, 30, 52, -7, 51, -27, 84, 66), + ), + issuer = CardDTO.Issuer( + name = "TANGEM AG", + publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = false, + isSettingPasscodeAllowed = false, + isResettingUserCodesAllowed = true, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = false, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = false, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Secp256r1, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + chainCode = byteArrayOf(80, 13, -8, -108, -35, 116, -92, 125, -65, -28, 85, 72, -113, 59, 83, 13, 5, -83, -102, 123, 124, -22, 94, 108, -71, 95, 65, -2, 38, 38, -108, 14), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + ), + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + chainCode = byteArrayOf(80, 13, -8, -108, -35, 116, -92, 125, -65, -28, 85, 72, -113, 59, 83, 13, 5, -83, -102, 123, 124, -22, 94, 108, -71, 95, 65, -2, 38, 38, -108, 14), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(1), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(3, 2, 95, 53, 40, -87, -60, 11, -8, -47, 41, 37, 100, 15, -69, 1, -122, 127, -20, -81, -32, -20, -24, 5, -28, 113, 106, -90, -59, -30, -27, -110, -110), + chainCode = byteArrayOf(-95, -87, -95, -25, 27, 96, -57, -92, -69, -106, -45, 10, 85, 4, -92, -68, 49, -24, -28, -50, -49, -77, -20, 118, -50, -27, 104, -93, 115, -50, -46, -34), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AC05000000086747") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = error("Available only for Wallet 2") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/DevWalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/DevWalletMockContent.kt new file mode 100644 index 0000000000..29b0ec5fee --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/DevWalletMockContent.kt @@ -0,0 +1,270 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object DevWalletMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AC05000000086747", + batchId = "AC05", + cardPublicKey = byteArrayOf(2, -120, -3, -32, -122, -127, -120, -104, 59, 72, 76, 114, 94, 75, -37, -55, 55, 99, 66, 123, 85, -87, 80, 106, 105, -116, 87, -83, -12, 70, 108, -68, -39), + linkingKey = byteArrayOf( + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 3, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM AG", + publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf(EllipticCurve.Secp256k1, EllipticCurve.Ed25519, EllipticCurve.Bls12381G2Aug), + firmwareVersion = FirmwareVersion( + major = 4, + minor = 52, + patch = 0, + type = FirmwareVersion.FirmwareType.Sdk, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AC05000000086747", + batchId = "AC05", + cardPublicKey = byteArrayOf(2, -120, -3, -32, -122, -127, -120, -104, 59, 72, 76, 114, 94, 75, -37, -55, 55, 99, 66, 123, 85, -87, 80, 106, 105, -116, 87, -83, -12, 70, 108, -68, -39), + firmwareVersion = CardDTO.FirmwareVersion( + major = 4, + minor = 52, + patch = 0, + type = FirmwareVersion.FirmwareType.Sdk, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1649635200000), + signature = byteArrayOf(-20, 98, -101, 94, -23, 73, 122, -21, 74, 76, 79, -55, -102, -62, -30, 44, -38, 118, 75, 121, -36, 118, -62, 60, -38, -63, 33, -14, -98, -69, 112, 22, 48, -43, 47, 65, -61, -56, 38, -94, -45, 44, 95, -22, 6, -31, -40, -25, 42, 38, 94, -99, 71, 98, -33, 27, -102, 30, 52, -7, 51, -27, 84, 66), + ), + issuer = CardDTO.Issuer( + name = "TANGEM AG", + publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = false, + isSettingPasscodeAllowed = false, + isResettingUserCodesAllowed = true, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = false, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = false, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Secp256r1, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + chainCode = byteArrayOf(80, 13, -8, -108, -35, 116, -92, 125, -65, -28, 85, 72, -113, 59, 83, 13, 5, -83, -102, 123, 124, -22, 94, 108, -71, 95, 65, -2, 38, 38, -108, 14), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = false, + derivedKeys = mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + ), + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + chainCode = byteArrayOf(80, 13, -8, -108, -35, 116, -92, 125, -65, -28, 85, 72, -113, 59, 83, 13, 5, -83, -102, 123, 124, -22, 94, 108, -71, 95, 65, -2, 38, 38, -108, 14), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = false, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.NoBackup, + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(3, 2, 95, 53, 40, -87, -60, 11, -8, -47, 41, 37, 100, 15, -69, 1, -122, 127, -20, -81, -32, -20, -24, 5, -28, 113, 106, -90, -59, -30, -27, -110, -110), + chainCode = byteArrayOf(-95, -87, -95, -25, 27, 96, -57, -92, -69, -106, -45, 10, 85, 4, -92, -68, 49, -24, -28, -50, -49, -77, -20, 118, -50, -27, 104, -93, 115, -50, -46, -34), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AC05000000086747") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = error("Available only for Wallet 2") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithSeedPhraseMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithSeedPhraseMockContent.kt new file mode 100644 index 0000000000..06d7e7db26 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithSeedPhraseMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object Wallet2WithSeedPhraseMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AF05888888880018", + batchId = "AF05", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AF05888888880018", + batchId = "AF05", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = true, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = true, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = true, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = true, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = true, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(1), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AF05888888880018") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index f562753a55..1db7cd1bb9 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -198,6 +198,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/144'/0'/0/0") to ExtendedPublicKey( // xrp + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt index 74ed0d0827..9dd041b2ea 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt @@ -22,7 +22,7 @@ import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.visa.error.VisaActivationError import com.tangem.domain.visa.model.* import com.tangem.domain.visa.repository.VisaActivationRepository -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.operations.GenerateOTPCommand import com.tangem.operations.attestation.AttestCardKeyCommand import com.tangem.operations.pins.SetUserCodeCommand @@ -46,7 +46,7 @@ class VisaCardActivationTask @AssistedInject constructor( @Assisted private val coroutineScope: CoroutineScope, private val otpStorage: VisaOTPStorage, private val visaAuthTokenStorage: VisaAuthTokenStorage, - private val visaAuthRepository: VisaAuthRepository, + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, private val visaActivationRepositoryFactory: VisaActivationRepository.Factory, ) : CardSessionRunnable { @@ -168,7 +168,7 @@ class VisaCardActivationTask @AssistedInject constructor( signedChallenge: VisaAuthSignedChallenge, cardWalletAddress: String, ): Either = either { - val tokens = visaAuthRepository.getAccessTokens(signedChallenge) + val tokens = visaAuthRemoteDataSource.getAccessTokens(signedChallenge) .getOrElse { raise(it.tangemError) } visaAuthTokenStorage.store(cardId, tokens) diff --git a/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt b/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt index 49ac95c749..444b1f4032 100644 --- a/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt +++ b/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt @@ -4,9 +4,5 @@ import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.domain.tokens.TokensFeatureToggles internal class DefaultTokensFeatureToggles( - private val featureTogglesManager: FeatureTogglesManager, -) : TokensFeatureToggles { - - override val isWalletBalanceFetcherEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(name = "WALLET_BALANCE_FETCHER_ENABLED") -} \ No newline at end of file + @Suppress("UnusedPrivateMember") private val featureTogglesManager: FeatureTogglesManager, +) : TokensFeatureToggles \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index 93810c48da..c4a0b716f7 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -4,6 +4,7 @@ import arrow.core.Either import arrow.core.left import arrow.core.raise.either import arrow.core.right +import com.tangem.common.CompletionResult import com.tangem.common.doOnFailure import com.tangem.common.doOnSuccess import com.tangem.common.flatMap @@ -11,21 +12,16 @@ import com.tangem.common.map import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getSyncOrDefault +import com.tangem.domain.core.wallets.UserWalletsListRepository +import com.tangem.domain.core.wallets.UserWalletsListRepository.LockMethod +import com.tangem.domain.core.wallets.error.* import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.wallets.R import com.tangem.domain.wallets.builder.UserWalletIdBuilder -import com.tangem.domain.wallets.hot.HotWalletPasswordRequester -import com.tangem.domain.core.wallets.error.DeleteWalletError -import com.tangem.domain.core.wallets.error.LockWalletsError -import com.tangem.domain.core.wallets.error.SaveWalletError -import com.tangem.domain.core.wallets.error.SelectWalletError -import com.tangem.domain.core.wallets.error.SetLockError -import com.tangem.domain.core.wallets.error.UnlockWalletError -import com.tangem.domain.core.wallets.UserWalletsListRepository -import com.tangem.domain.core.wallets.UserWalletsListRepository.LockMethod import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository +import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.hot.sdk.model.HotWalletId import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey @@ -38,6 +34,8 @@ import com.tangem.utils.ProviderSuspend import com.tangem.utils.extensions.indexOfFirstOrNull import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock @Suppress("LongParameterList", "LargeClass") internal class DefaultUserWalletsListRepository( @@ -54,32 +52,40 @@ internal class DefaultUserWalletsListRepository( override val userWallets = MutableStateFlow?>(null) override val selectedUserWallet = MutableStateFlow(null) + private val mutex = Mutex() override suspend fun load() { - if (userWallets.value != null) return + mutex.withLock { + if (userWallets.value != null) return - if (savePersistentInformation().not()) { - // If we don't save persistent information, we don't need to load user wallets - // and we should clear any existing data - clearPersistentData() - userWallets.value = emptyList() - return - } - - val unsecuredEncryptionKeys = userWalletEncryptionKeysRepository.getAllUnsecured() - - publicInformationRepository.getAll() - .map { it.toUserWallets() } - .flatMap { wallets -> - sensitiveInformationRepository.getAll(unsecuredEncryptionKeys) - .map { wallets.updateWith(it) } - }.doOnSuccess { - userWallets.value = it + if (savePersistentInformation().not()) { + // If we don't save persistent information, we don't need to load user wallets + // and we should clear any existing data + clearPersistentData() + updateWallets { emptyList() } + return } - val selectedUserWalletId = selectedUserWalletRepository.get() - selectedUserWallet.value = userWallets.value?.firstOrNull { it.walletId == selectedUserWalletId } - ?: userWallets.value?.firstOrNull() + val unsecuredEncryptionKeys = userWalletEncryptionKeysRepository.getAllUnsecured() + + publicInformationRepository.getAll() + .map { it.toUserWallets() } + .flatMap { wallets -> + sensitiveInformationRepository.getAll(unsecuredEncryptionKeys) + .map { wallets.updateWith(it) } + } + .doOnSuccess { loadedWallets -> + userWallets.update { toUpdate -> + val selectedUserWalletId = selectedUserWalletRepository.get() + selectedUserWallet.value = loadedWallets.firstOrNull { it.walletId == selectedUserWalletId } + ?: loadedWallets.firstOrNull()?.also { + selectedUserWalletRepository.set(it.walletId) + } + + loadedWallets + } + } + } } override suspend fun userWalletsSync(): List { @@ -118,7 +124,7 @@ internal class DefaultUserWalletsListRepository( } // update the userWallets state and add if it doesn't exist - userWallets.update { currentWallets -> + updateWallets { currentWallets -> val wallets = currentWallets ?: emptyList() if (wallets.any { it.walletId == userWallet.walletId }) { wallets.map { if (it.walletId == userWallet.walletId) userWallet else it } @@ -191,21 +197,21 @@ internal class DefaultUserWalletsListRepository( userWalletEncryptionKeysRepository.delete(userWalletIds) - val userWalletsBeforeDelete = userWallets.value ?: return@either - userWallets.update { currentWallets -> - currentWallets?.filterNot { it.walletId in userWalletIds } - } - - selectedUserWallet.update { currentSelected -> - if (currentSelected == null) return@update null - - userWallets.value?.findAvailableUserWallet( - userWalletsBeforeDelete.indexOfFirstOrNull { it.walletId == currentSelected.walletId } ?: 0, - ) + val updatedWallets = currentWallets?.filter { userWalletIds.contains(it.walletId).not() } + selectedUserWallet.update { currentSelected -> + if (currentSelected == null) return@update null + val newSelected = updatedWallets?.findAvailableUserWallet( + currentWallets.indexOfFirstOrNull { it.walletId == currentSelected.walletId } ?: 0, + ) + selectedUserWalletRepository.set(newSelected?.walletId) + newSelected + } + updatedWallets } } + @Suppress("CyclomaticComplexMethod") override suspend fun unlock( userWalletId: UserWalletId, unlockMethod: UserWalletsListRepository.UnlockMethod, @@ -248,37 +254,45 @@ internal class DefaultUserWalletsListRepository( removePasswordAttempts(userWallet) sensitiveInformationRepository.getAll(listOf(encryptionKey)) - .doOnSuccess { sensitiveInfo -> userWallets.update { it?.updateWith(sensitiveInfo) } } + .doOnSuccess { sensitiveInfo -> updateWallets { it?.updateWith(sensitiveInfo) } } .doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock) } } - UserWalletsListRepository.UnlockMethod.Scan -> { + is UserWalletsListRepository.UnlockMethod.Scan -> { if (userWallet !is UserWallet.Cold) { raise(UnlockWalletError.UnableToUnlock) } - tangemSdkManagerProvider().scanProduct() - .doOnSuccess { scanResponse -> - val expectedId = UserWalletIdBuilder.scanResponse(scanResponse).build() - - if (expectedId != userWallet.walletId) { - raise(UnlockWalletError.ScannedCardWalletNotMatched) - } - - saveWithoutLock(userWallet.copy(scanResponse = scanResponse), canOverride = true) - .mapLeft { UnlockWalletError.UnableToUnlock } - .bind() - } - .doOnFailure { - raise(UnlockWalletError.UserCancelled) + val scanResponse = unlockMethod.scanResponse ?: run { + val res = tangemSdkManagerProvider().scanProduct() + when (res) { + is CompletionResult.Failure -> raise(UnlockWalletError.UserCancelled) + is CompletionResult.Success -> res.data } + } + + val expectedId = UserWalletIdBuilder.scanResponse(scanResponse).build() + + if (expectedId != userWallet.walletId) { + raise(UnlockWalletError.ScannedCardWalletNotMatched) + } + + val encryptionKey = UserWalletEncryptionKey( + walletId = userWallet.walletId, + encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock), + ) + + sensitiveInformationRepository.getAll(listOf(encryptionKey)) + .doOnSuccess { sensitiveInfo -> updateWallets { it?.updateWith(sensitiveInfo) } } + .doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock) } } } } override suspend fun unlockAllWallets(): Either = either { - val userWalletIds = userWalletsSync().map { it.walletId }.toSet() + val userWallets = userWalletsSync() + val userWalletIds = userWallets.map { it.walletId }.toSet() val biometricKeys = runCatching { userWalletEncryptionKeysRepository.getAllBiometric() }.getOrElse { @@ -291,7 +305,7 @@ internal class DefaultUserWalletsListRepository( val unlockedWalletsIds = allKeys.map { it.walletId } val unlockedWallets = unlockedWalletsIds.mapNotNull { id -> - userWalletsSync().firstOrNull { it.walletId == id } + userWallets.firstOrNull { it.walletId == id } } // Remove all password attempts for unlocked hot wallets @@ -306,7 +320,7 @@ internal class DefaultUserWalletsListRepository( sensitiveInformationRepository.getAll(allKeys) .doOnSuccess { sensitiveInfo -> - userWallets.update { it?.updateWith(sensitiveInfo) } + updateWallets { userWallets.updateWith(sensitiveInfo) } } .doOnFailure { raise(UnlockWalletError.UnableToUnlock) } } @@ -318,7 +332,7 @@ internal class DefaultUserWalletsListRepository( raise(LockWalletsError.NothingToLock) } - userWallets.update { + updateWallets { it?.map { if (it.walletId !in unsecuredWalletIds) { it.lock() @@ -389,6 +403,17 @@ internal class DefaultUserWalletsListRepository( return tangemSdkManagerProvider.invoke().canUseBiometry && useBiometricAuthentication } + private fun updateWallets(block: (List?) -> List?) { + userWallets.update { + val updated = block(it) + selectedUserWallet.update { currentSelected -> + if (currentSelected == null) return@update null + updated?.find { it.walletId == currentSelected.walletId } + } + updated + } + } + /** * Find the nearest available wallet that can be selected * diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/UserWalletEncyptionKeyCalculator.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/UserWalletEncyptionKeyCalculator.kt index 8b05c741e7..65b8e925a7 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/UserWalletEncyptionKeyCalculator.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/UserWalletEncyptionKeyCalculator.kt @@ -4,6 +4,7 @@ import com.tangem.common.extensions.calculateSha256 import com.tangem.domain.common.extensions.calculateHmacSha256 import com.tangem.domain.models.MobileWallet import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet internal val UserWallet.encryptionKey: ByteArray? @@ -19,6 +20,9 @@ private fun calculateEncryptionKey(publicKey: ByteArray): ByteArray { return message.calculateHmacSha256(keyHash) } +val ScanResponse.encryptionKey: ByteArray? + get() = findPublicKey(this.card.wallets)?.let { calculateEncryptionKey(it) } + private fun findPublicKey(wallets: List): ByteArray? { return wallets.firstOrNull()?.publicKey } diff --git a/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt b/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt index c439ee3d5b..d074006981 100644 --- a/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt +++ b/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt @@ -15,7 +15,7 @@ import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.error.VisaCardScanError import com.tangem.domain.visa.model.* import com.tangem.domain.visa.repository.VisaActivationRepository -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.operations.attestation.AttestCardKeyCommand import com.tangem.operations.attestation.AttestCardKeyResponse import com.tangem.operations.attestation.AttestWalletKeyResponse @@ -26,7 +26,7 @@ import javax.inject.Inject import kotlin.coroutines.resume internal class VisaCardScanHandler @Inject constructor( - private val visaAuthRepository: VisaAuthRepository, + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, private val visaActivationRepositoryFactory: VisaActivationRepository.Factory, private val visaAuthTokenStorage: VisaAuthTokenStorage, ) { @@ -83,7 +83,7 @@ internal class VisaCardScanHandler @Inject constructor( Timber.i("Requesting challenge for wallet authorization") - val challengeResponse = visaAuthRepository.getCardWalletAuthChallenge( + val challengeResponse = visaAuthRemoteDataSource.getCardWalletAuthChallenge( cardId = card.cardId, // This is the wallet public key, not the address and it's alright, as the API expects it in this format cardWalletAddress = wallet.publicKey.toHexString(), @@ -122,7 +122,7 @@ internal class VisaCardScanHandler @Inject constructor( cardWalletAddress: String, signedChallenge: VisaAuthSignedChallenge, ): CompletionResult { - val authorizationTokensResponse = visaAuthRepository.getAccessTokens(signedChallenge = signedChallenge) + val authorizationTokensResponse = visaAuthRemoteDataSource.getAccessTokens(signedChallenge = signedChallenge) .getOrElse { Timber.i("Failed to get Access token for Wallet public key authorization.") return if ( @@ -149,7 +149,7 @@ internal class VisaCardScanHandler @Inject constructor( Timber.i("Requesting authorization challenge to sign") - val challengeResponse = visaAuthRepository.getCardAuthChallenge( + val challengeResponse = visaAuthRemoteDataSource.getCardAuthChallenge( cardId = card.cardId, cardPublicKey = card.cardPublicKey.toHexString(), ).getOrElse { @@ -174,7 +174,7 @@ internal class VisaCardScanHandler @Inject constructor( } } - val authorizationTokensResponse = visaAuthRepository.getAccessTokens( + val authorizationTokensResponse = visaAuthRemoteDataSource.getAccessTokens( signedChallenge = challengeResponse.toSignedChallenge( signedChallenge = attestCardKeyResponse.cardSignature.toHexString(), salt = attestCardKeyResponse.salt.toHexString(), diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect/BnbHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect/BnbHelper.kt deleted file mode 100644 index 0e6ccce36b..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect/BnbHelper.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.tangem.tap.domain.walletconnect - -import com.github.salomonbrys.kotson.registerTypeAdapter -import com.google.gson.GsonBuilder -import com.tangem.blockchain.common.Blockchain -import com.tangem.common.extensions.calculateSha256 -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTradeOrder -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTransferOrder -import com.tangem.tap.domain.walletconnect2.domain.models.binance.tradeOrderSerializer -import com.tangem.tap.features.details.redux.walletconnect.BinanceMessageData -import com.tangem.tap.features.details.redux.walletconnect.TradeData -import com.tangem.utils.extensions.stripZeroPlainString -import timber.log.Timber - -internal object BnbHelper { - - fun createMessageData(order: WcBinanceTransferOrder): BinanceMessageData.Transfer { - val input = order.msgs.first().inputs.first() - val output = order.msgs.first().inputs.first() - - val currency = - input.coins.firstOrNull()?.denom ?: Blockchain.Binance - val amount = input.coins - .mapNotNull { if (it.denom == currency) it.amount else null } - .sum() - .toBigDecimal() - .movePointLeft(Blockchain.Binance.decimals()) - .stripZeroPlainString() - - val gson = GsonBuilder() - .registerTypeAdapter(tradeOrderSerializer) - .serializeNulls() - .create() - - return BinanceMessageData.Transfer( - outputAddress = output.address, - amount = amount, - address = input.address, - data = gson.toJson(order).toByteArray().calculateSha256(), - ) - } - - fun createMessageData(order: WcBinanceTradeOrder): BinanceMessageData.Trade { - val address = order.msgs.first().sender - - val tradeData = order.msgs.map { - val price = it.price.toBigDecimal() - .movePointLeft(Blockchain.Binance.decimals()) - .stripTrailingZeros() - val quantity = it.quantity.toBigDecimal() - .movePointLeft(Blockchain.Binance.decimals()) - .stripTrailingZeros() - - val amount = price * quantity - val symbol = it.symbol.substringBefore("-") - - TradeData( - price = "$price ${Blockchain.Binance.currency}", - quantity = "$quantity $symbol", - amount = "$amount ${Blockchain.Binance.currency}", - symbol = symbol, - ) - } - val gson = GsonBuilder() - .registerTypeAdapter(tradeOrderSerializer) - .serializeNulls() - .create() - val serialized = gson.toJson(order) - Timber.d(serialized) - - return BinanceMessageData.Trade( - tradeData = tradeData, - address = address, - data = serialized.toByteArray().calculateSha256(), - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect/EthSignHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect/EthSignHelper.kt deleted file mode 100644 index 80d7b046e9..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect/EthSignHelper.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.tangem.tap.domain.walletconnect - -import com.github.salomonbrys.kotson.fromJson -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.JsonParser - -object EthSignHelper { - private val gson: Gson by lazy { - GsonBuilder() - .setPrettyPrinting() - .serializeNulls() - .create() - } - - fun tryToParseEthTypedMessageString(message: String): String? { - return try { - val messageString = message - .replace("\\", "") - .removePrefix("\"") - .removeSuffix("\"") - val messageJson = JsonParser().parse(messageString) - val filteredMap = gson.fromJson>(messageJson) - .filterKeys { it == "domain" || it == "message" } - - gson.toJson(filteredMap) - } catch (exception: Exception) { - null - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt deleted file mode 100644 index 9df63ef08a..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt +++ /dev/null @@ -1,513 +0,0 @@ -package com.tangem.tap.domain.walletconnect - -import com.tangem.Message -import com.tangem.blockchain.blockchains.ethereum.EthereumGasLoader -import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras -import com.tangem.blockchain.blockchains.ethereum.EthereumUtils -import com.tangem.blockchain.common.* -import com.tangem.blockchain.common.smartcontract.CompiledSmartContractCallData -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.extensions.* -import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.common.CompletionResult -import com.tangem.common.extensions.hexToBytes -import com.tangem.common.extensions.toDecompressedPublicKey -import com.tangem.common.extensions.toHexString -import com.tangem.data.walletconnect.network.ethereum.LegacySdkHelper -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.operations.sign.SignHashCommand -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.extensions.safeUpdate -import com.tangem.tap.common.extensions.toFormattedString -import com.tangem.tap.domain.walletconnect2.domain.TransactionType -import com.tangem.tap.domain.walletconnect2.domain.WcEthereumTransaction -import com.tangem.tap.domain.walletconnect2.domain.WcSignMessage -import com.tangem.tap.domain.walletconnect2.domain.models.EthTransactionData -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTradeOrder -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTransferOrder -import com.tangem.tap.features.demo.isDemoCard -import com.tangem.tap.features.details.redux.walletconnect.BinanceMessageData -import com.tangem.tap.features.details.redux.walletconnect.WcEthTransactionType -import com.tangem.tap.features.details.redux.walletconnect.WcPersonalSignData -import com.tangem.tap.features.details.redux.walletconnect.WcTransactionData -import com.tangem.tap.features.details.ui.walletconnect.dialogs.PersonalSignDialogData -import com.tangem.tap.features.details.ui.walletconnect.dialogs.TransactionRequestDialogData -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store -import com.tangem.tap.tangemSdkManager -import org.json.JSONArray -import org.json.JSONObject -import timber.log.Timber -import java.math.BigDecimal - -@Suppress("LargeClass") -class WalletConnectSdkHelper { - - private val userWalletsListManager by lazy { - store.inject(DaggerGraphState::generalUserWalletsListManager) - } - - @Suppress("MagicNumber") - suspend fun prepareTransactionData(data: EthTransactionData): WcTransactionData { - val transaction = data.transaction - val blockchain = requireNotNull(Blockchain.fromNetworkId(data.networkId)) { "Blockchain not found" } - val walletManager = requireNotNull(getWalletManager(blockchain, data.rawDerivationPath)) { - "WalletManager not found" - } - - walletManager.safeUpdate(isDemoCard()) - val wallet = walletManager.wallet - val balance = requireNotNull(wallet.amounts[AmountType.Coin]?.value) { - "Coin balance not found" - } - - val decimals = wallet.blockchain.decimals() - - val value = (transaction.value ?: "0") - .hexToBigDecimal() - .movePointLeft(decimals) - - requireNotNull(value) { - "Transaction amount is null" - } - - // TODO move fee calculation to SDK getFee() [REDACTED_JIRA] - val gasLimit = getGasLimitFromTx(value, walletManager, transaction, blockchain) - val gasPrice = getGasPrice(walletManager, transaction) - - val feeDecimal = (gasLimit * gasPrice).movePointLeft(decimals) - val total = value + feeDecimal - val feeAmount = Amount(feeDecimal, wallet.blockchain) - - val destinationAddress = requireNotNull(transaction.to) { "Destination address is null" } - - val fee = if (blockchain.isEvm()) { - // TODO [REDACTED_JIRA] - // workaround for Mantle, remove after [REDACTED_JIRA] - val patchedAmount = if (blockchain == Blockchain.Mantle) { - feeAmount.copy(value = feeAmount.value?.multiply(MANTLE_FEE_ESTIMATE_MULTIPLIER)) - } else { - feeAmount - } - Fee.Ethereum.Legacy(patchedAmount, gasLimit.toBigInteger(), gasPrice.toBigInteger()) - } else { - Fee.Common(feeAmount) - } - val transactionData = TransactionData.Uncompiled( - amount = Amount(value, wallet.blockchain), - fee = fee, - sourceAddress = transaction.from, - destinationAddress = destinationAddress, - extras = EthereumTransactionExtras( - callData = CompiledSmartContractCallData(transaction.data.removePrefix(HEX_PREFIX).hexToBytes()), - gasLimit = gasLimit.toBigInteger(), - nonce = transaction.nonce?.hexToBigDecimal()?.toBigInteger(), - ), - ) - - val dialogData = TransactionRequestDialogData( - dAppName = data.metaName, - dAppUrl = data.metaUrl, - amount = value.toFormattedString(decimals), - feeAmount = feeDecimal.toFormattedString(decimals), - totalAmount = total.toFormattedString(decimals), - balance = balance.toFormattedString(decimals), - isEnoughFundsToSend = balance - total >= BigDecimal.ZERO, - topic = data.topic, - id = data.id, - type = data.type, - ) - - return WcTransactionData( - type = data.type, - transaction = transactionData, - topic = data.topic, - id = data.id, - walletManager = walletManager, - dialogData = dialogData, - ) - } - - fun isDemoCard(): Boolean { - val userWallet = userWalletsListManager.selectedUserWalletSync ?: return false - return userWallet is UserWallet.Cold && userWallet.scanResponse.isDemoCard() - } - - private suspend fun getWalletManager(blockchain: Blockchain, derivationPath: String?): WalletManager? { - val userWallet = userWalletsListManager.selectedUserWalletSync ?: return null - val walletManagerFacade = store.inject(DaggerGraphState::walletManagersFacade) - return walletManagerFacade.getOrCreateWalletManager( - userWalletId = userWallet.walletId, - blockchain = blockchain, - derivationPath = derivationPath, - ) - } - - suspend fun completeTransaction(data: WcTransactionData, cardId: String?): String? { - return when (data.type) { - WcEthTransactionType.EthSendTransaction -> sendTransaction(data, cardId) - WcEthTransactionType.EthSignTransaction -> signTransaction(data, cardId) - } - } - - private suspend fun getGasPrice(walletManager: WalletManager, transaction: WcEthereumTransaction): BigDecimal { - val txGasPrice = transaction.gasPrice?.hexToBigDecimal() - if (txGasPrice != null) { - return txGasPrice - } - return when (val result = (walletManager as? EthereumGasLoader)?.getGasPrice()) { - is Result.Success -> result.data.toBigDecimal() - is Result.Failure -> { - (result.error as? Throwable)?.let { Timber.e(it, "getGasPrice failed") } - - error("Unable to get gas price: ${result.error}") - } - null -> error("Gas price is null") - } - } - - private suspend fun getGasLimitFromTx( - value: BigDecimal, - walletManager: WalletManager, - transaction: WcEthereumTransaction, - blockchain: Blockchain, - ): BigDecimal { - return transaction.gas?.hexToBigDecimal() - ?: transaction.gasLimit?.hexToBigDecimal() - ?: getGasLimitFromBlockchain( - value = value, - walletManager = walletManager, - transaction = transaction, - ).increaseForMantleIfNeeded(blockchain) - } - - private suspend fun getGasLimitFromBlockchain( - value: BigDecimal, - walletManager: WalletManager, - transaction: WcEthereumTransaction, - ): BigDecimal { - val gasLimitResult = (walletManager as? EthereumGasLoader)?.getGasLimit( - amount = Amount(value, walletManager.wallet.blockchain), - destination = transaction.to ?: "", - callData = CompiledSmartContractCallData(transaction.data.hexToBytes()), - ) - return when (gasLimitResult) { - is Result.Success -> gasLimitResult.data.toBigDecimal().multiply(BigDecimal("1.2")) - is Result.Failure -> { - (gasLimitResult.error as? Throwable)?.let { Timber.e(it, "getGasLimit failed") } - DEFAULT_MAX_GASLIMIT.toBigDecimal() // Set high gasLimit if not provided - } - else -> DEFAULT_MAX_GASLIMIT.toBigDecimal() // Set high gasLimit if not provided - } - } - - // TODO Workaround for Mantle. Remove after [REDACTED_JIRA] - private fun BigDecimal.increaseForMantleIfNeeded(blockchain: Blockchain): BigDecimal { - return if (blockchain == Blockchain.Mantle) { - this.multiply(MANTLE_FEE_ESTIMATE_MULTIPLIER) - } else { - this - } - } - - private suspend fun sendTransaction(data: WcTransactionData, cardId: String?): String? { - val sdk = store.inject(DaggerGraphState::cardSdkConfigRepository).sdk - - val result = (data.walletManager as TransactionSender).send( - transactionData = data.transaction, - signer = store.inject(DaggerGraphState::transactionSignerFactory).createTransactionSigner( - cardId = cardId, - sdk = sdk, - twinKey = null, // use null here because twin doesn't support WC - ), - ) - return when (result) { - is Result.Success -> { - val hash = result.data.hash - if (hash.startsWith(HEX_PREFIX)) { - hash - } else { - HEX_PREFIX + hash - } - } - is Result.Failure -> { - Timber.e(result.error as BlockchainSdkError) - null - } - } - } - - private suspend fun signTransaction(data: WcTransactionData, cardId: String?): String? { - val dataToSign = EthereumUtils.buildTransactionToSign( - transactionData = data.transaction, - blockchain = data.walletManager.wallet.blockchain, - ) - - val command = SignHashCommand( - hash = dataToSign.hash, - walletPublicKey = data.walletManager.wallet.publicKey.seedKey, - derivationPath = data.walletManager.wallet.publicKey.derivationPath, - ) - return when ( - val result = tangemSdkManager.runTaskAsync( - runnable = command, - initialMessage = Message(), - cardId = cardId, - preflightReadFilter = null, - ) - ) { - is CompletionResult.Success -> { - val hash = EthereumUtils.prepareTransactionToSend( - signature = result.data.signature, - transactionToSign = dataToSign, - walletPublicKey = data.walletManager.wallet.publicKey, - blockchain = data.walletManager.wallet.blockchain, - ).toHexString() - if (hash.startsWith(HEX_PREFIX)) { - hash - } else { - HEX_PREFIX + hash - } - } - is CompletionResult.Failure -> { - Timber.e(result.error.customMessage) - null - } - } - } - - fun prepareBnbTradeOrder(data: WcBinanceTradeOrder): BinanceMessageData.Trade { - return BnbHelper.createMessageData(data) - } - - fun prepareBnbTransferOrder(data: WcBinanceTransferOrder): BinanceMessageData.Transfer { - return BnbHelper.createMessageData(data) - } - - suspend fun signBnbTransaction( - data: ByteArray, - networkId: String, - derivationPath: String?, - cardId: String?, - ): String? { - val blockchain = Blockchain.fromNetworkId(networkId) ?: return null - val wallet = getWalletManager(blockchain, derivationPath)?.wallet ?: return null - - val command = SignHashCommand( - hash = data, - walletPublicKey = wallet.publicKey.seedKey, - derivationPath = wallet.publicKey.derivationPath, - ) - return when ( - val result = tangemSdkManager.runTaskAsync( - runnable = command, - initialMessage = Message(), - cardId = cardId, - preflightReadFilter = null, - ) - ) { - is CompletionResult.Success -> { - val key = wallet.publicKey.blockchainKey.toDecompressedPublicKey() - getBnbResultString( - key.toHexString(), - result.data.signature.toHexString(), - ) - } - is CompletionResult.Failure -> { - Timber.e(result.error.customMessage) - null - } - } - } - - fun prepareDataForPersonalSign( - message: WcSignMessage, - topic: String, - metaName: String, - id: Long, - ): WcPersonalSignData { - val messageData = when (message.type) { - WcSignMessage.WCSignType.MESSAGE, - WcSignMessage.WCSignType.PERSONAL_MESSAGE, - -> createMessageData(message) - WcSignMessage.WCSignType.TYPED_MESSAGE -> EthereumUtils.makeTypedDataHash(message.data) - WcSignMessage.WCSignType.SOLANA_MESSAGE -> message.data.decodeBase58() - } - - val messageString = message.data.hexToAscii() - ?: EthSignHelper.tryToParseEthTypedMessageString(message.data) - ?: message.data - - val dialogData = PersonalSignDialogData( - dAppName = metaName, - message = messageString, - topic = topic, - id = id, - ) - return WcPersonalSignData( - hash = requireNotNull(messageData) { "Message data must not be null" }, - topic = topic, - id = id, - dialogData = dialogData, - type = message.type, - ) - } - - private fun createMessageData(message: WcSignMessage): ByteArray = LegacySdkHelper.createMessageData(message.data) - - private fun String.hexToAscii(): String? = LegacySdkHelper.hexToAscii(hex = this) - - suspend fun signPersonalMessage( - hashToSign: ByteArray, - networkId: String, - type: WcSignMessage.WCSignType, - derivationPath: String?, - cardId: String?, - ): String? { - val blockchain = Blockchain.fromNetworkId(networkId) ?: return null - val wallet = getWalletManager(blockchain, derivationPath)?.wallet ?: return null - - val command = SignHashCommand( - hash = hashToSign, - walletPublicKey = wallet.publicKey.seedKey, - derivationPath = wallet.publicKey.derivationPath, - ) - return when ( - val result = tangemSdkManager.runTaskAsync( - runnable = command, - cardId = cardId, - preflightReadFilter = null, - ) - ) { - is CompletionResult.Success -> { - val signedHash = result.data.signature - - return when (type) { - WcSignMessage.WCSignType.SOLANA_MESSAGE -> getSolanaResultString(signedHash) - else -> UnmarshalHelper.unmarshalSignatureExtended( - signature = signedHash, - hash = hashToSign, - publicKey = wallet.publicKey.blockchainKey.toDecompressedPublicKey(), - ).asRSVLegacyEVM().toHexString().formatHex() - .lowercase() // use lowercase because some dapps cant handle UPPERCASE - } - } - is CompletionResult.Failure -> { - Timber.e(result.error.customMessage) - null - } - } - } - - /** - * Returns result of signing prepared to send in WC request - */ - suspend fun signTransaction( - hashToSign: ByteArray, - networkId: String, - type: TransactionType, - derivationPath: String?, - cardId: String?, - ): String? { - val blockchain = Blockchain.fromNetworkId(networkId) ?: return null - val wallet = getWalletManager(blockchain, derivationPath)?.wallet ?: return null - - val command = SignHashCommand( - hash = hashToSign, - walletPublicKey = wallet.publicKey.seedKey, - derivationPath = wallet.publicKey.derivationPath, - ) - return when ( - val result = tangemSdkManager.runTaskAsync( - runnable = command, - cardId = cardId, - preflightReadFilter = null, - ) - ) { - is CompletionResult.Success -> { - val signedHash = result.data.signature - - when (type) { - TransactionType.SOLANA_TX -> { - getSolanaResultString(signedHash) - } - } - } - is CompletionResult.Failure -> { - Timber.e(result.error.customMessage) - null - } - } - } - - /** - * Returns result of signing prepared to send in WC request - */ - suspend fun signTransactions( - hashesToSign: List, - networkId: String, - type: TransactionType, - derivationPath: String?, - cardId: String?, - ): String? { - val blockchain = Blockchain.fromNetworkId(networkId) ?: return null - val wallet = getWalletManager(blockchain, derivationPath)?.wallet ?: return null - val sdk = store.inject(DaggerGraphState::cardSdkConfigRepository).sdk - - val signer = store.inject(DaggerGraphState::transactionSignerFactory).createTransactionSigner( - cardId = cardId, - sdk = sdk, - twinKey = null, - ) - - return when (val signingResult = signer.sign(hashesToSign, wallet.publicKey)) { - is CompletionResult.Failure -> { - Timber.e(signingResult.error.customMessage) - null - } - is CompletionResult.Success -> { - when (type) { - TransactionType.SOLANA_TX -> { - val result = signingResult.data.mapIndexed { index, bytes -> - byteArrayOf(1) + bytes + hashesToSign[index] - } - getSolanaResultTxHashesString(result) - } - } - } - else -> { - null - } - } - } - - private fun getSolanaResultString(signedHash: ByteArray) = "{ signature: \"${signedHash.encodeBase58()}\" }" - - /** - * Build json object - * { - * "transactions": [ - * "signed_tx_hash" - * ] - * } - */ - private fun getSolanaResultTxHashesString(signedHashes: List): String { - val result = JSONObject() - val transactions = JSONArray() - signedHashes.forEach { - transactions.put(it.encodeBase64()) - } - result.put("transactions", transactions) - return result.toString() - } - - private fun getBnbResultString(publicKey: String, signature: String) = - "{\"signature\":\"$signature\",\"publicKey\":\"$publicKey\"}" - - private companion object { - const val HEX_PREFIX = "0x" - const val DEFAULT_MAX_GASLIMIT = 350000 - // TODO remove after [REDACTED_JIRA] - private val MANTLE_FEE_ESTIMATE_MULTIPLIER = BigDecimal("1.8") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/TangemWcBlockchainHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/TangemWcBlockchainHelper.kt deleted file mode 100644 index b0e2c44579..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/TangemWcBlockchainHelper.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.app - -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.blockchainsdk.utils.toNetworkId -import com.tangem.tap.domain.walletconnect2.domain.WcBlockchainHelper -import com.tangem.domain.walletconnect.model.legacy.Account - -internal class TangemWcBlockchainHelper : WcBlockchainHelper { - - private val supportedNonEvmBlockchains = setOf(Blockchain.Solana, Blockchain.SolanaTestnet) - - override fun chainIdToNetworkIdOrNull(chainId: String): String? { - val parsedId = chainId.parseId() ?: return null - val blockchain = parsedId.chainIdToBlockchain() - - return blockchain?.toNetworkId() - } - - override fun chainIdsToBlockchains(chainIds: List): List { - return chainIds.mapNotNull { - it.parseId()?.chainIdToBlockchain() - }.distinct() - } - - override fun chainIdToMissingNetworkNameOrNull(chainId: String): String? { - val parsedId = chainId.parseId() ?: return null - val blockchain = parsedId.chainIdToBlockchain() - - return blockchain?.fullName - ?: if (parsedId.first == EVM_NAMESPACE) { - chainId - } else { - parsedId.first.replaceFirstChar(Char::titlecase) - } - } - - override fun networkIdToChainIdOrNull(networkId: String): List { - val blockchain = Blockchain.fromNetworkId(networkId) - val namespace = blockchain?.getCaip2Namespace() ?: return emptyList() - return blockchain.getCaip2ChainIds().map { - "$namespace$CHAIN_SEPARATOR$it" - } - } - - override fun getNamespaceFromFullChainIdOrNull(chainId: String): String? { - val parsed = chainId.split(CHAIN_SEPARATOR) - return parsed.firstOrNull() - } - - override fun chainIdToFullNameOrNull(chainId: String): String? { - val networkId = chainIdToNetworkIdOrNull(chainId) ?: return null - return Blockchain.fromNetworkId(networkId)?.fullName - } - - override fun chainIdsToAccounts( - walletAddress: String, - chainIds: List, - derivationPath: String?, - ): List { - return chainIds.map { chainId -> - Account(chainId, walletAddress, derivationPath) - } - } - - private fun Blockchain.getCaip2ChainIds(): List { - if (this.isEvm()) return listOfNotNull(this.getChainId()?.toString()) - - return when (this) { - /* - * The WC sample application and documentation use a commented out chain ID. However, in real dApps - * uncommented is used. - * Docs: https://docs.walletconnect.com/advanced/multichain/chain-list - * - * */ - Blockchain.Solana -> listOf("5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ") - Blockchain.SolanaTestnet -> listOf("z4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z") - Blockchain.Polkadot -> listOf("91b171bb158e2d3848fa23a9f1c25182") - Blockchain.Tron -> listOf("0x2b6653dc") - else -> emptyList() - } - } - - private fun Blockchain.getCaip2Namespace(): String? { - return when { - this.isEvm() -> EVM_NAMESPACE - supportedNonEvmBlockchains.contains(this) -> this.toNetworkId().substringBefore(TESTNET_SEPARATOR) - else -> null - } - } - - private fun String.parseId(): Pair? { - val parsed = this.split(CHAIN_SEPARATOR) - if (parsed.size != 2) return null - - return parsed[0] to parsed[1] - } - - private fun Pair.chainIdToBlockchain(): Blockchain? { - return when (first) { - EVM_NAMESPACE -> { - second.toIntOrNull() - ?.let(Blockchain::fromChainId) - } - SOLANA_NAMESPACE -> Blockchain.Solana - else -> { - Blockchain.fromNetworkId(networkId = first) - .takeIf(supportedNonEvmBlockchains::contains) - } - } - } - - private companion object { - const val EVM_NAMESPACE = "eip155" - const val SOLANA_NAMESPACE = "solana" - const val CHAIN_SEPARATOR = ":" - const val TESTNET_SEPARATOR = "/" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/WalletConnectEventsHandlerImpl.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/WalletConnectEventsHandlerImpl.kt deleted file mode 100644 index d84263034d..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/WalletConnectEventsHandlerImpl.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.app - -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.WalletConnectEventsHandler -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectError -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectEvents -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectDialog -import com.tangem.tap.features.details.ui.walletconnect.WcSessionForScreen -import com.tangem.tap.store -import timber.log.Timber - -internal class WalletConnectEventsHandlerImpl : WalletConnectEventsHandler { - override fun onProposalReceived(proposal: WalletConnectEvents.SessionProposal, networksFormatted: String) { - store.dispatchOnMain( - GlobalAction.ShowDialog( - WalletConnectDialog.SessionProposalDialog( - sessionProposal = proposal, - networks = networksFormatted, - onApprove = { store.dispatchOnMain(WalletConnectAction.ApproveProposal(proposal)) }, - onReject = { store.dispatchOnMain(WalletConnectAction.RejectProposal) }, - ), - ), - ) - } - - override fun onSessionEstablished() { - store.dispatchOnMain(WalletConnectAction.SessionEstablished) - } - - override fun onSessionRejected(error: WalletConnectError) { - store.dispatchOnMain(WalletConnectAction.SessionRejected(error)) - } - - override fun onListOfSessionsUpdated(sessions: List) { - Timber.d("WC2: List of sessions updated. Sessions: $sessions") - store.dispatchOnMain(WalletConnectAction.SessionListUpdated(sessions)) - } - - override fun onSessionRequest(request: WcPreparedRequest) { - store.dispatchOnMain(WalletConnectAction.ShowSessionRequest(request)) - } - - override fun onUnsupportedRequest() { - store.dispatchOnMain(WalletConnectAction.RejectUnsupportedRequest) - } - - override fun onPairConnectError(error: Throwable) { - store.dispatchOnMain(WalletConnectAction.PairConnectErrorAction(error)) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt deleted file mode 100644 index 7d1b854d10..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt +++ /dev/null @@ -1,686 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.data - -import android.app.Application -import arrow.core.flatten -import com.reown.android.Core -import com.reown.android.CoreClient -import com.reown.android.relay.ConnectionType -import com.reown.walletkit.client.Wallet -import com.reown.walletkit.client.WalletKit -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.data.walletconnect.pair.UnsupportedDApps -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.walletconnect.WcPairService -import com.tangem.domain.walletconnect.model.WcPairRequest -import com.tangem.domain.walletconnect.model.legacy.Account -import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles -import com.tangem.tap.common.analytics.events.WalletConnect -import com.tangem.tap.domain.walletconnect2.app.TangemWcBlockchainHelper -import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository -import com.tangem.tap.domain.walletconnect2.domain.WcJrpcMethods -import com.tangem.tap.domain.walletconnect2.domain.WcJrpcRequestsDeserializer -import com.tangem.tap.domain.walletconnect2.domain.WcRequest -import com.tangem.tap.domain.walletconnect2.domain.models.* -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction.OpenSession.SourceType -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.emptyFlow -import timber.log.Timber - -internal class DefaultLegacyWalletConnectRepositoryFacade constructor( - private val stub: LegacyWalletConnectRepositoryStub, - private val legacy: DefaultLegacyWalletConnectRepository, - private val walletConnectFeatureToggles: WalletConnectFeatureToggles, - private val wcInitializeUseCase: WcInitializeUseCase, - private val wcPairService: WcPairService, -) : LegacyWalletConnectRepository { - private val isNewWc by lazy { - walletConnectFeatureToggles.isRedesignedWalletConnectEnabled - } - override val events: Flow by lazy { - if (isNewWc) stub.events else legacy.events - } - override val activeSessions: Flow> by lazy { - if (isNewWc) stub.activeSessions else legacy.activeSessions - } - override val currentSessions: List by lazy { - if (isNewWc) stub.currentSessions else legacy.currentSessions - } - - override fun init(projectId: String) { - if (isNewWc) { - wcInitializeUseCase.init(projectId) - } else { - legacy.init(projectId) - } - } - - override fun setUserNamespaces(userNamespaces: Map>) { - if (isNewWc) stub.setUserNamespaces(userNamespaces) else legacy.setUserNamespaces(userNamespaces) - } - - override fun updateSessions() { - if (isNewWc) stub.updateSessions() else legacy.updateSessions() - } - - override fun pair(userWalletId: UserWalletId, uri: String, source: SourceType) { - val src = when (source) { - SourceType.QR -> WcPairRequest.Source.QR - SourceType.DEEPLINK -> WcPairRequest.Source.DEEPLINK - SourceType.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD - SourceType.ETC -> WcPairRequest.Source.ETC - } - if (isNewWc) { - wcPairService.pair(WcPairRequest(uri = uri, source = src, userWalletId = userWalletId)) - } else { - legacy.pair(userWalletId = userWalletId, uri = uri, source = source) - } - } - - override fun disconnect(topic: String) { - if (isNewWc) stub.disconnect(topic) else legacy.disconnect(topic) - } - - override fun approve(userNamespaces: Map>, blockchainNames: List) { - if (isNewWc) stub.approve(userNamespaces, blockchainNames) else legacy.approve(userNamespaces, blockchainNames) - } - - override fun reject() { - if (isNewWc) stub.reject() else legacy.reject() - } - - override fun sendRequest(requestData: RequestData, result: String) { - if (isNewWc) stub.sendRequest(requestData, result) else legacy.sendRequest(requestData, result) - } - - override fun rejectRequest(requestData: RequestData, error: WalletConnectError) { - if (isNewWc) stub.rejectRequest(requestData, error) else legacy.rejectRequest(requestData, error) - } - - override fun cancelRequest(topic: String, id: Long, message: String) { - if (isNewWc) stub.cancelRequest(topic, id, message) else legacy.cancelRequest(topic, id, message) - } -} - -internal class LegacyWalletConnectRepositoryStub : LegacyWalletConnectRepository { - override val events: Flow = emptyFlow() - override val activeSessions: Flow> = emptyFlow() - override val currentSessions: List = listOf() - - override fun init(projectId: String) = Unit - - override fun setUserNamespaces(userNamespaces: Map>) = Unit - - override fun updateSessions() = Unit - - override fun pair(userWalletId: UserWalletId, uri: String, source: SourceType) = Unit - - override fun disconnect(topic: String) = Unit - - override fun approve(userNamespaces: Map>, blockchainNames: List) = Unit - - override fun reject() = Unit - - override fun sendRequest(requestData: RequestData, result: String) = Unit - - override fun rejectRequest(requestData: RequestData, error: WalletConnectError) = Unit - - override fun cancelRequest(topic: String, id: Long, message: String) = Unit -} - -@Suppress("LargeClass") -internal class DefaultLegacyWalletConnectRepository( - private val application: Application, - private val wcRequestDeserializer: WcJrpcRequestsDeserializer, - private val analyticsHandler: AnalyticsEventHandler, -) : LegacyWalletConnectRepository { - - private var sessionProposal: Wallet.Model.SessionProposal? = null - private var userNamespaces: Map>? = null - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - private val _events: MutableSharedFlow = MutableSharedFlow() - override val events: Flow = _events - - private val _activeSessions: MutableSharedFlow> = MutableSharedFlow() - override val activeSessions: Flow> = _activeSessions - private val blockchainHelper by lazy { TangemWcBlockchainHelper() } - - override var currentSessions: List = emptyList() - private set - - /** - * @param projectId Project ID at https://cloud.walletconnect.com/ - */ - override fun init(projectId: String) { - val relayUrl = "relay.walletconnect.com" - val serverUrl = "wss://$relayUrl?projectId=$projectId" - val connectionType = ConnectionType.AUTOMATIC - - val appMetaData = Core.Model.AppMetaData( - name = "Tangem", - description = "Tangem Wallet", - url = "tangem.com", - icons = listOf( - "https://user-images.githubusercontent.com/24321494/124071202-72a00900-da58-11eb-935a-dcdab21de52b.png", - ), - redirect = "kotlin-wallet-wc:/request", // Custom Redirect URI - ) - - CoreClient.initialize( - relayServerUrl = serverUrl, - connectionType = connectionType, - application = application, - metaData = appMetaData, - ) { error -> - Timber.e("Error while initializing client: $error") - scope.launch { - _events.emit( - WalletConnectEvents.SessionApprovalError( - WalletConnectError.ExternalApprovalError(error.throwable.message), - ), - ) - } - } - - WalletKit.initialize( - Wallet.Params.Init(core = CoreClient), - onSuccess = { - val walletDelegate = defineWalletDelegate() - WalletKit.setWalletDelegate(walletDelegate) - }, - onError = { error -> - Timber.e("Error while initializing Web3Wallet: $error") - scope.launch { - _events.emit( - WalletConnectEvents.SessionApprovalError( - WalletConnectError.ExternalApprovalError(error.throwable.message), - ), - ) - } - }, - ) - } - - private fun defineWalletDelegate(): WalletKit.WalletDelegate { - return object : WalletKit.WalletDelegate { - - @Suppress("LongMethod") - override fun onSessionProposal( - sessionProposal: Wallet.Model.SessionProposal, - verifyContext: Wallet.Model.VerifyContext, - ) { - // Triggered when wallet receives the session proposal sent by a Dapp - Timber.i("sessionProposal: $sessionProposal") - this@DefaultLegacyWalletConnectRepository.sessionProposal = sessionProposal - - if (sessionProposal.name in UnsupportedDApps.list) { - Timber.i("Unsupported DApp") - scope.launch { - _events.emit( - WalletConnectEvents.SessionApprovalError( - WalletConnectError.UnsupportedDApp, - ), - ) - } - return - } - - val missingNetworks = findMissingNetworks( - namespaces = sessionProposal.requiredNamespaces, - userNamespaces = this@DefaultLegacyWalletConnectRepository.userNamespaces ?: emptyMap(), - ) - - if (missingNetworks.isNotEmpty()) { - Timber.i("Not added blockchains: $missingNetworks") - scope.launch { - _events.emit( - WalletConnectEvents.SessionApprovalError( - WalletConnectError.ApprovalErrorMissingNetworks(missingNetworks.toList()), - ), - ) - } - return - } - - val optionalMissingNetwork = findMissingNetworks( - namespaces = sessionProposal.optionalNamespaces, - userNamespaces = this@DefaultLegacyWalletConnectRepository.userNamespaces ?: emptyMap(), - ) - - val optionalWithoutMissingNetworks = removeMissingNetworks( - namespaces = sessionProposal.optionalNamespaces, - userNamespaces = this@DefaultLegacyWalletConnectRepository.userNamespaces ?: emptyMap(), - ) - - // for cases when optionalNamespaces is not empty but we doesn't support none of them - if (optionalMissingNetwork.isNotEmpty() && - optionalWithoutMissingNetworks.isEmpty() && - sessionProposal.requiredNamespaces.isEmpty() // if requiredNamespaces is not empty we can connect - ) { - Timber.i("Not added optional blockchains: $optionalMissingNetwork") - - val unsupportedNetworks = sessionProposal.optionalNamespaces.values - .flatMap { it.chains ?: emptyList() } - .filter { blockchainHelper.chainIdToNetworkIdOrNull(it) == null } - - scope.launch { - val error = if (unsupportedNetworks.isNotEmpty()) { - WalletConnectEvents.SessionApprovalError( - WalletConnectError.ApprovalErrorUnsupportedNetwork(unsupportedNetworks), - ) - } else { - WalletConnectEvents.SessionApprovalError( - WalletConnectError.ApprovalErrorMissingNetworks(optionalMissingNetwork.toList()), - ) - } - _events.emit(error) - } - return - } - - val requiredChainIds = sessionProposal.requiredNamespaces.values.flatMap { it.chains ?: emptyList() } - val optionalChainIds = optionalWithoutMissingNetworks.toList() - val networks = (requiredChainIds + optionalChainIds) - .mapNotNull { blockchainHelper.chainIdToFullNameOrNull(it) } - .distinct() - analyticsHandler.send(WalletConnect.DAppConnectionRequested(networks)) - - scope.launch { - _events.emit( - WalletConnectEvents.SessionProposal( - sessionProposal.name, - sessionProposal.description, - sessionProposal.url, - sessionProposal.icons, - requiredChainIds, - optionalChainIds, - ), - ) - } - } - - override fun onSessionRequest( - sessionRequest: Wallet.Model.SessionRequest, - verifyContext: Wallet.Model.VerifyContext, - ) { - // Triggered when a Dapp sends SessionRequest to sign a transaction or a message - Timber.i("sessionRequest: $sessionRequest") - val request = wcRequestDeserializer.deserialize( - method = sessionRequest.request.method, - params = sessionRequest.request.params, - ) - Timber.i("sessionRequestParsed: $request") - - when (request) { - is WcRequest.AddChain -> { - // we can send approval automatically, because in WC 2.0 the list of chains is approved when - // initial connection is established - sendRequest( - RequestData( - topic = sessionRequest.topic, - requestId = sessionRequest.request.id, - blockchain = sessionRequest.chainId.toString(), - method = WcJrpcMethods.WALLET_ADD_ETHEREUM_CHAIN.code, - ), - result = "", - ) - } - else -> { - val event = WalletConnect.SignatureRequestReceived( - WalletConnect.RequestHandledParams( - dAppName = sessionRequest.peerMetaData?.name ?: "", - dAppUrl = sessionRequest.peerMetaData?.url ?: "", - methodName = sessionRequest.request.method, - blockchain = sessionRequest.chainId - ?.let { blockchainHelper.chainIdToNetworkIdOrNull(it) } ?: "", - ), - ) - analyticsHandler.send(event) - scope.launch { - _events.emit( - WalletConnectEvents.SessionRequest( - request = request, - chainId = sessionRequest.chainId, - topic = sessionRequest.topic, - id = sessionRequest.request.id, - metaUrl = sessionRequest.peerMetaData?.url ?: "", - metaName = sessionRequest.peerMetaData?.name ?: "", - method = sessionRequest.request.method, - ), - ) - } - } - } - } - - override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) { - // Triggered when the session is deleted by the peer - if (sessionDelete is Wallet.Model.SessionDelete.Success) { - scope.launch { - _events.emit(WalletConnectEvents.SessionDeleted(sessionDelete.topic)) - updateSessionsInternal().join() - } - } - Timber.i("onSessionDelete: $sessionDelete") - } - - override fun onSessionExtend(session: Wallet.Model.Session) { - Timber.i("onSessionExtend: $session") - } - - override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { - // Triggered when wallet receives the session settlement response from Dapp - Timber.i("onSessionSettleResponse: $settleSessionResponse") - if (settleSessionResponse is Wallet.Model.SettledSessionResponse.Result) { - scope.launch { - _events.emit( - WalletConnectEvents.SessionApprovalSuccess( - topic = settleSessionResponse.session.topic, - accounts = userNamespaces?.flatMap { it.value } ?: emptyList(), - ), - ) - updateSessionsInternal().join() - } - } - } - - override fun onSessionUpdateResponse(sessionUpdateResponse: Wallet.Model.SessionUpdateResponse) { - // Triggered when wallet receives the session update response from Dapp - Timber.i("onSessionUpdateResponse: $sessionUpdateResponse") - updateSessionsInternal() - } - - override fun onConnectionStateChange(state: Wallet.Model.ConnectionState) { - // Triggered whenever the connection state is changed - Timber.i("onConnectionStateChange: $state") - if (state.isAvailable) updateSessionsInternal() - } - - override fun onError(error: Wallet.Model.Error) { - // Triggered whenever there is an issue inside the SDK - Timber.i("onError: $error") - } - } - } - - override fun setUserNamespaces(userNamespaces: Map>) { - this.userNamespaces = userNamespaces - } - - override fun pair(userWalletId: UserWalletId, uri: String, source: SourceType) { - analyticsHandler.send(WalletConnect.NewSessionInitiated(source = source)) - WalletKit.pair( - params = Wallet.Params.Pair(uri), - onSuccess = { - Timber.i("Paired successfully: $it") - }, - onError = { - Timber.e("Error while pairing: $it") - analyticsHandler.send(WalletConnect.SessionFailed) - scope.launch { - _events.emit( - WalletConnectEvents.PairConnectError(it.throwable), - ) - } - }, - ) - } - - override fun approve(userNamespaces: Map>, blockchainNames: List) { - val sessionProposal: Wallet.Model.SessionProposal = requireNotNull(this.sessionProposal) - - val userChains = userNamespaces.flatMap { namespace -> - namespace.value.map { it.chainId to "${it.chainId}:${it.walletAddress}" } - }.groupBy { pair -> pair.first } - .mapValues { entry -> entry.value.map { pair -> pair.second }.toSet() } - - val preparedRequiredNamespaces = sessionProposal.requiredNamespaces - .map { requiredNamespace -> - val accountsRequired = requiredNamespace.value.chains - ?.mapNotNull { chain -> userChains[chain] } - ?.flatten() ?: emptyList() - val optionalNamespace = sessionProposal.optionalNamespaces[requiredNamespace.key] - val accountsOptional = optionalNamespace?.chains - ?.mapNotNull { chain -> userChains[chain] } - ?.flatten() ?: emptyList() - - val methods = (requiredNamespace.value.methods + (optionalNamespace?.methods ?: emptyList())) - .distinct() - requiredNamespace.key to Wallet.Model.Namespace.Session( - accounts = (accountsRequired + accountsOptional).distinct(), - methods = methods, - events = requiredNamespace.value.events, - ) - }.toMap() - - val sessionApproval = Wallet.Params.SessionApprove( - proposerPublicKey = sessionProposal.proposerPublicKey, - namespaces = preparedRequiredNamespaces.ifEmpty { - sessionProposal.createPreparedOptionalNamespaces(userChains) - }.filterValues { it.accounts.isNotEmpty() && it.chains?.isNotEmpty() == true }, - ) - - Timber.i("Session approval is prepared for sending: $sessionApproval") - - WalletKit.approveSession( - params = sessionApproval, - onSuccess = { - Timber.i("Approved successfully: $it") - analyticsHandler.send( - WalletConnect.DAppConnected( - dAppName = sessionProposal.name, - dAppUrl = sessionProposal.url, - blockchainNames = blockchainNames, - ), - ) - }, - onError = { - Timber.e("Error while approving: $it") - analyticsHandler.send( - WalletConnect.DAppConnectionFailed( - dAppName = sessionProposal.name, - dAppUrl = sessionProposal.url, - blockchainNames = blockchainNames, - ), - ) - scope.launch { - _events.emit( - WalletConnectEvents.SessionApprovalError( - WalletConnectError.ExternalApprovalError(it.throwable.message), - ), - ) - } - }, - ) - } - - private fun Wallet.Model.SessionProposal.createPreparedOptionalNamespaces( - userChains: Map>, - ): Map { - return optionalNamespaces - .map { optionalNamespace -> - val accountsOptional = optionalNamespace.value.chains - ?.mapNotNull { chain -> userChains[chain] } - ?.flatten() ?: emptyList() - - val methods = optionalNamespace.value.methods - optionalNamespace.key to Wallet.Model.Namespace.Session( - accounts = accountsOptional.distinct(), - methods = methods, - chains = userChains.keys - .filter { it.startsWith(optionalNamespace.key) } - .toList(), - events = optionalNamespace.value.events, - ) - } - .toMap() - } - - override fun sendRequest(requestData: RequestData, result: String) { - val session = currentSessions.find { it.topic == requestData.topic } - // Add Ethereum Chain method is processed without user input, skip logging it - if (requestData.method != WcJrpcMethods.WALLET_ADD_ETHEREUM_CHAIN.code) { - analyticsHandler.send( - WalletConnect.SignatureRequestHandled( - WalletConnect.RequestHandledParams( - dAppName = session?.name ?: "", - dAppUrl = session?.url ?: "", - methodName = requestData.method, - blockchain = requestData.blockchain, - ), - ), - ) - } - - val params = Wallet.Params.SessionRequestResponse( - sessionTopic = requestData.topic, - jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcResult( - id = requestData.requestId, - result = result, - ), - ) - Timber.i("Session request response: $params") - - WalletKit.respondSessionRequest( - params = params, - onSuccess = { response -> - Timber.i("Session request responded successfully: $response") - }, - onError = { error -> - Timber.e(error.throwable, "Error while responging session request") - - val handledParams = WalletConnect.RequestHandledParams( - dAppName = session?.name ?: "", - dAppUrl = session?.url ?: "", - methodName = requestData.method, - blockchain = requestData.blockchain, - errorCode = WalletConnectError.ValidationError.error, - errorDescription = error.throwable.message, - ) - analyticsHandler.send(WalletConnect.SignatureRequestFailed(handledParams)) - }, - ) - } - - override fun rejectRequest(requestData: RequestData, error: WalletConnectError) { - val session = currentSessions.find { it.topic == requestData.topic } - - analyticsHandler.send( - WalletConnect.SignatureRequestFailed( - WalletConnect.RequestHandledParams( - dAppName = session?.name ?: "", - dAppUrl = session?.url ?: "", - methodName = requestData.method, - blockchain = requestData.blockchain, - errorCode = error.toString(), - ), - ), - ) - - cancelRequest(requestData.topic, requestData.requestId, error.error) - } - - override fun cancelRequest(topic: String, id: Long, message: String) { - WalletKit.respondSessionRequest( - params = Wallet.Params.SessionRequestResponse( - sessionTopic = topic, - jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError( - id = id, - code = 0, - message = message, - ), - ), - onSuccess = {}, - onError = {}, - ) - } - - override fun reject() { - WalletKit.rejectSession( - params = Wallet.Params.SessionReject( - proposerPublicKey = sessionProposal?.proposerPublicKey ?: "", - reason = "", - ), - onSuccess = { - Timber.i("Rejected successfully: $it") - }, - onError = { - Timber.e("Error while rejecting: $it") - }, - ) - } - - override fun disconnect(topic: String) { - val session = currentSessions.find { it.topic == topic } - WalletKit.disconnectSession( - params = Wallet.Params.SessionDisconnect(topic), - onSuccess = { - analyticsHandler.send( - WalletConnect.SessionDisconnected( - dAppName = session?.name ?: "", - dAppUrl = session?.url ?: "", - ), - ) - updateSessionsInternal() - Timber.i("Disconnected successfully: $it") - }, - onError = { - Timber.e("Error while disconnecting: $it") - }, - ) - } - - fun send(topic: String, id: Long, data: String) { - WalletKit.respondSessionRequest( - params = Wallet.Params.SessionRequestResponse( - sessionTopic = topic, - jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcResult( - id = id, - result = data, - ), - ), - onError = {}, - onSuccess = {}, - ) - } - - override fun updateSessions() { - updateSessionsInternal() - } - - private fun updateSessionsInternal(): Job = scope.launch { - val availableSessions = WalletKit.getListOfActiveSessions() - .map { - WalletConnectSession( - topic = it.topic, - icon = it.metaData?.icons?.firstOrNull(), - name = it.metaData?.name, - url = it.metaData?.url, - ) - } - Timber.i("Available sessions: $availableSessions") - currentSessions = availableSessions - _activeSessions.emit(availableSessions) - } - - private fun findMissingNetworks( - namespaces: Map, - userNamespaces: Map>, - ): Collection { - val requiredChains = namespaces.values.flatMap { it.chains ?: emptyList() } - val userChains = userNamespaces.flatMap { it.value.map { account -> account.chainId } } - return requiredChains.subtract(userChains.toSet()) - } - - private fun removeMissingNetworks( - namespaces: Map, - userNamespaces: Map>, - ): Collection { - val wcProvidedChains = namespaces.values.flatMap { it.chains ?: emptyList() } - val userChains = userNamespaces.flatMap { it.value.map { account -> account.chainId } } - return wcProvidedChains.intersect(userChains.toSet()) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultWalletConnectSessionsRepository.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultWalletConnectSessionsRepository.kt deleted file mode 100644 index 108492f2de..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultWalletConnectSessionsRepository.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.data - -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.Moshi -import com.squareup.moshi.Types -import com.tangem.datasource.files.FileReader -import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository -import com.tangem.domain.walletconnect.model.legacy.Session -import timber.log.Timber - -internal class DefaultWalletConnectSessionsRepository( - private val moshi: Moshi, - private val fileReader: FileReader, -) : - WalletConnectSessionsRepository { - - private val sessionsAdapter: JsonAdapter> = moshi.adapter( - Types.newParameterizedType(List::class.java, Session::class.java), - ) - - override suspend fun loadSessions(userWallet: String): List { - return try { - val fileContent = fileReader.readFile(getFileNameForUserWallet(userWallet)) - sessionsAdapter.fromJson(fileContent) ?: emptyList() - } catch (exception: Exception) { - Timber.d(exception) - emptyList() - } - } - - override suspend fun saveSession(userWallet: String, session: Session) { - val updatedList = loadSessions(userWallet).plus(session) - writeSessionToFile(sessions = updatedList, userWallet = userWallet) - } - - override suspend fun removeSession(userWallet: String, topic: String) { - val updatedList = loadSessions(userWallet).filterNot { it.topic == topic } - writeSessionToFile(sessions = updatedList, userWallet = userWallet) - } - - private fun writeSessionToFile(sessions: List, userWallet: String) { - val serialized = sessionsAdapter.toJson(sessions) - try { - fileReader.rewriteFile(serialized, getFileNameForUserWallet(userWallet)) - } catch (exception: Exception) { - Timber.e(exception) - } - } - - companion object { - private const val FILE_NAME_PREFIX = "wc_2" - - private fun getFileNameForUserWallet(userWallet: String): String { - return "$FILE_NAME_PREFIX-${userWallet.uppercase()}" - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/di/WalletConnectInteractorModule.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/di/WalletConnectInteractorModule.kt deleted file mode 100644 index 6c9e9f189d..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/di/WalletConnectInteractorModule.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.di - -import android.app.Application -import com.squareup.moshi.Moshi -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.datasource.di.SdkMoshi -import com.tangem.datasource.files.FileReader -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.walletconnect.WcPairService -import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository -import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles -import com.tangem.tap.domain.walletconnect.WalletConnectSdkHelper -import com.tangem.tap.domain.walletconnect2.app.TangemWcBlockchainHelper -import com.tangem.tap.domain.walletconnect2.app.WalletConnectEventsHandlerImpl -import com.tangem.tap.domain.walletconnect2.data.DefaultLegacyWalletConnectRepository -import com.tangem.tap.domain.walletconnect2.data.DefaultLegacyWalletConnectRepositoryFacade -import com.tangem.tap.domain.walletconnect2.data.DefaultWalletConnectSessionsRepository -import com.tangem.tap.domain.walletconnect2.data.LegacyWalletConnectRepositoryStub -import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository -import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor -import com.tangem.tap.domain.walletconnect2.domain.WcJrpcRequestsDeserializer -import com.tangem.tap.domain.walletconnect2.toggles.DefaultWalletConnectFeatureToggles -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object WalletConnectInteractorModule { - - @Provides - @Singleton - fun provideWalletConnectInteractor( - wcRepository: LegacyWalletConnectRepository, - wcSessionsRepository: WalletConnectSessionsRepository, - currenciesRepository: CurrenciesRepository, - walletManagersFacade: WalletManagersFacade, - walletConnectFeatureToggles: WalletConnectFeatureToggles, - coroutineDispatcherProvider: CoroutineDispatcherProvider, - getSelectedWalletUseCase: GetSelectedWalletUseCase, - ): WalletConnectInteractor { - return WalletConnectInteractor( - handler = WalletConnectEventsHandlerImpl(), - walletConnectRepository = wcRepository, - sessionsRepository = wcSessionsRepository, - sdkHelper = WalletConnectSdkHelper(), - blockchainHelper = TangemWcBlockchainHelper(), - currenciesRepository = currenciesRepository, - walletManagersFacade = walletManagersFacade, - getSelectedWalletUseCase = getSelectedWalletUseCase, - dispatchers = coroutineDispatcherProvider, - walletConnectFeatureToggles = walletConnectFeatureToggles, - ) - } -} - -@Module -@InstallIn(SingletonComponent::class) -internal object WalletConnectModule { - - @Provides - @Singleton - fun provideWalletConnectFeatureToggles(featureTogglesManager: FeatureTogglesManager): WalletConnectFeatureToggles { - return DefaultWalletConnectFeatureToggles(featureTogglesManager) - } - - @Provides - @Singleton - fun provideWalletConnectRepository( - application: Application, - wcRequestDeserializer: WcJrpcRequestsDeserializer, - analyticsHandler: AnalyticsEventHandler, - walletConnectFeatureToggles: WalletConnectFeatureToggles, - wcInitializeUseCase: WcInitializeUseCase, - wcPairService: WcPairService, - ): LegacyWalletConnectRepository { - val legacy = DefaultLegacyWalletConnectRepository( - application = application, - wcRequestDeserializer = wcRequestDeserializer, - analyticsHandler = analyticsHandler, - ) - val stub = LegacyWalletConnectRepositoryStub() - return DefaultLegacyWalletConnectRepositoryFacade( - stub, - legacy, - walletConnectFeatureToggles, - wcInitializeUseCase, - wcPairService, - ) - } - - @Provides - @Singleton - fun provideWalletConnectSessionsRepository( - @SdkMoshi moshi: Moshi, - fileReader: FileReader, - ): WalletConnectSessionsRepository { - return DefaultWalletConnectSessionsRepository( - moshi = moshi, - fileReader = fileReader, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/LegacyWalletConnectRepository.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/LegacyWalletConnectRepository.kt deleted file mode 100644 index 55b4899aa9..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/LegacyWalletConnectRepository.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.walletconnect.model.legacy.Account -import com.tangem.tap.domain.walletconnect2.domain.models.* -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction.OpenSession.SourceType -import kotlinx.coroutines.flow.Flow - -interface LegacyWalletConnectRepository { - - val events: Flow - - val activeSessions: Flow> - - val currentSessions: List - - fun init(projectId: String) - - fun setUserNamespaces(userNamespaces: Map>) - - fun updateSessions() - - fun pair(userWalletId: UserWalletId, uri: String, source: SourceType) - - fun disconnect(topic: String) - - fun approve(userNamespaces: Map>, blockchainNames: List) - - fun reject() - - fun sendRequest(requestData: RequestData, result: String) - - fun rejectRequest(requestData: RequestData, error: WalletConnectError) - - fun cancelRequest(topic: String, id: Long, message: String = "") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectEventsHandler.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectEventsHandler.kt deleted file mode 100644 index b9414d247e..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectEventsHandler.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectError -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectEvents -import com.tangem.tap.features.details.ui.walletconnect.WcSessionForScreen - -interface WalletConnectEventsHandler { - fun onProposalReceived(proposal: WalletConnectEvents.SessionProposal, networksFormatted: String) - - fun onSessionEstablished() - - fun onSessionRejected(error: WalletConnectError) - - fun onListOfSessionsUpdated(sessions: List) - - fun onSessionRequest(request: WcPreparedRequest) - - fun onUnsupportedRequest() - - fun onPairConnectError(error: Throwable) -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt deleted file mode 100644 index 395ad23885..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt +++ /dev/null @@ -1,457 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import arrow.core.flatten -import com.tangem.blockchainsdk.utils.toBlockchain -import com.tangem.blockchainsdk.utils.toNetworkId -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.models.wallet.isMultiCurrency -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.walletconnect.model.legacy.Account -import com.tangem.domain.walletconnect.model.legacy.Session -import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.extensions.filterNotNull -import com.tangem.tap.domain.walletconnect.WalletConnectSdkHelper -import com.tangem.tap.domain.walletconnect2.domain.models.* -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.features.details.ui.walletconnect.WcSessionForScreen -import com.tangem.tap.store -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* -import timber.log.Timber -import java.util.Stack - -@Suppress("LargeClass", "LongParameterList") -class WalletConnectInteractor( - private val handler: WalletConnectEventsHandler, - private val walletConnectRepository: LegacyWalletConnectRepository, - private val sessionsRepository: WalletConnectSessionsRepository, - private val sdkHelper: WalletConnectSdkHelper, - private val dispatchers: CoroutineDispatcherProvider, - private val walletManagersFacade: WalletManagersFacade, - private val currenciesRepository: CurrenciesRepository, - private val walletConnectFeatureToggles: WalletConnectFeatureToggles, - private val getSelectedWalletUseCase: GetSelectedWalletUseCase, - val blockchainHelper: WcBlockchainHelper, -) { - private val isNewWc by lazy { walletConnectFeatureToggles.isRedesignedWalletConnectEnabled } - - private var isWalletConnectReadyForDeepLinks = false - - private val wcScope = CoroutineScope( - SupervisorJob() + dispatchers.io + CoroutineExceptionHandler { _, throwable -> - Timber.e("CoroutineException: from: LISTENER SCOPE, exception: $throwable") - }, - ) - - private val listenerScope = CoroutineScope( - SupervisorJob() + dispatchers.io + CoroutineExceptionHandler { _, throwable -> - Timber.e("CoroutineException: from: LISTENER SCOPE, exception: $throwable") - }, - ) - - /** Stack of deeplinks to handle if user wallet is not selected or cryptocurrency statuses are not available */ - private val deeplinkStack: Stack = Stack() - - private val events = walletConnectRepository.events - private val sessions = walletConnectRepository.activeSessions - - private var userWalletId: String = "" - private var cardId: String? = null - - private var currentRequest: WalletConnectEvents.SessionRequest? = null - private val sessionRequestConverter = WcSessionRequestConverter( - blockchainHelper = blockchainHelper, - sessionsRepository = sessionsRepository, - sdkHelper = sdkHelper, - ) - - init { - getSelectedWalletUseCase().onRight { userWalletFlow -> - userWalletFlow - .conflate() - .distinctUntilChanged() - .onEach(::initWithWallet) - .flowOn(dispatchers.io) - .launchIn(wcScope) - } - } - - private fun initWithWallet(userWallet: UserWallet) { - if (isNewWc) return - if (userWallet.isMultiCurrency) { - Timber.i("WalletConnect: initialize and setup networks for ${userWallet.walletId}") - startListeningWc(userWallet.walletId.stringValue, getCardId(userWallet)) - subscribeOnCurrenciesUpdates(userWallet) - } - } - - private fun subscribeOnCurrenciesUpdates(userWallet: UserWallet) { - currenciesRepository.getMultiCurrencyWalletCurrenciesUpdates(userWallet.walletId) - .conflate() - .distinctUntilChanged() - .onEach { currencies -> - setupUserChains(userWallet, currencies) - } - .flowOn(dispatchers.io) - .launchIn(wcScope) - } - - private suspend fun setupUserChains(userWallet: UserWallet, currencies: List) { - val accounts = getAccountsForWc( - userWallet = userWallet, - networks = currencies.map { it.network }.distinct(), - ) - setUserChains(accounts) - handleDeeplinkStack(accounts) - } - - private fun startListeningWc(userWalletId: String, cardId: String?) { - this.userWalletId = userWalletId - this.cardId = cardId - listenerScope.coroutineContext.cancelChildren() - listenerScope.launch { - launch { subscribeToEvents() } - launch { subscribeToSessions() } - walletConnectRepository.updateSessions() - } - } - - private fun setUserChains(accounts: List) { - val userNamespaces: Map> = accounts - .groupBy { account -> - blockchainHelper.getNamespaceFromFullChainIdOrNull(account.chainId) - ?.let { NetworkNamespace(it) } - }.filterNotNull() - walletConnectRepository.setUserNamespaces(userNamespaces) - } - - private fun handleDeeplinkStack(accounts: List) { - runCatching { - if (accounts.isEmpty()) return - isWalletConnectReadyForDeepLinks = true - if (deeplinkStack.empty()) return - val lastDeeplink = deeplinkStack.pop() - val action = WalletConnectAction - .OpenSession( - wcUri = lastDeeplink, - source = WalletConnectAction.OpenSession.SourceType.DEEPLINK, - userWalletId = UserWalletId(userWalletId), - ) - store.dispatchOnMain(action) - }.onFailure { - Timber.e("WC deeplink handling failed. $it") - } - } - - private suspend fun subscribeToEvents() { - events - .onEach { wcEvent -> - Timber.i("WalletConnect: event: $wcEvent") - when (wcEvent) { - is WalletConnectEvents.SessionProposal -> { - val unsupportedNetworks = wcEvent.requiredChainIds - .filter { blockchainHelper.chainIdToNetworkIdOrNull(it) == null } - if (unsupportedNetworks.isNotEmpty()) { - val error = WalletConnectError.ApprovalErrorUnsupportedNetwork(unsupportedNetworks) - handler.onSessionRejected(error) - return@onEach - } - val networksFormatted = (wcEvent.requiredChainIds + wcEvent.optionalChainIds) - .mapNotNull { blockchainHelper.chainIdToFullNameOrNull(it) } - .distinct() - .toString() - handler.onProposalReceived(proposal = wcEvent, networksFormatted = networksFormatted) - } - is WalletConnectEvents.SessionApprovalError -> { - val error = when (wcEvent.error) { - is WalletConnectError.ApprovalErrorMissingNetworks -> { - val missingNetworks = wcEvent.error.missingChains.mapNotNull { - blockchainHelper.chainIdToMissingNetworkNameOrNull(it) - } - - WalletConnectError.ApprovalErrorAddNetwork(missingNetworks) - } - else -> wcEvent.error - } - handler.onSessionRejected(error) - } - is WalletConnectEvents.SessionApprovalSuccess -> { - sessionsRepository.saveSession( - userWallet = userWalletId, - session = Session( - accounts = wcEvent.accounts, - topic = wcEvent.topic, - ), - ) - walletConnectRepository.updateSessions() - handler.onSessionEstablished() - } - is WalletConnectEvents.SessionDeleted -> { - sessionsRepository.removeSession(userWalletId, wcEvent.topic) - } - is WalletConnectEvents.SessionRequest -> { - handleRequest(wcEvent) - } - is WalletConnectEvents.PairConnectError -> { - handler.onPairConnectError(wcEvent.error) - } - } - } - .flowOn(dispatchers.io) - .collect() - } - - private suspend fun subscribeToSessions() { - sessions - .onEach { listOfSessions -> - val relevantTopics = getTopicsForUserWallet(userWalletId, sessionsRepository) - val filteredSessions = filterSessionsForUserWallet(listOfSessions, relevantTopics) - handler.onListOfSessionsUpdated(filteredSessions) - } - .flowOn(dispatchers.io) - .collect() - } - - private fun filterSessionsForUserWallet( - availableSessions: List, - relevantTopics: List, - ): List { - return availableSessions.filter { relevantTopics.contains(it.topic) } - .map { - WcSessionForScreen( - description = it.name ?: "", - sessionId = it.topic, - ) - } - } - - private suspend fun getTopicsForUserWallet( - userWalletId: String, - repository: WalletConnectSessionsRepository, - ): List { - return repository.loadSessions(userWalletId).map { it.topic } - } - - fun approveSessionProposal(accounts: List) { - if (isNewWc) return - Timber.i("Approve session proposal: $accounts") - val userNamespaces: Map> = accounts - .groupBy { account -> - blockchainHelper.getNamespaceFromFullChainIdOrNull(account.chainId) - ?.let { NetworkNamespace(it) } - }.filterNotNull() - - val blockchainNames = blockchainHelper.chainIdsToBlockchains(userNamespaces.values.flatten().map { it.chainId }) - .map { it.fullName } - walletConnectRepository.approve( - userNamespaces = userNamespaces, - blockchainNames = blockchainNames, - ) - } - - fun rejectSessionProposal() { - if (isNewWc) return - Timber.i("Reject session proposal") - walletConnectRepository.reject() - } - - fun disconnectSession(topic: String) { - if (isNewWc) return - Timber.i("Disconnect session: $topic") - walletConnectRepository.disconnect(topic) - } - - fun cancelRequest(topic: String, id: Long) { - if (isNewWc) return - Timber.i("Cancel request: $topic, $id") - walletConnectRepository.cancelRequest(topic, id) - } - - private suspend fun handleRequest(sessionRequest: WalletConnectEvents.SessionRequest) { - val error: WalletConnectError? = when { - sessionsRepository.loadSessions(userWalletId).none { it.topic == sessionRequest.topic } -> { - WalletConnectError.WrongUserWallet - } - sessionRequest.request is WcRequest.CustomRequest -> { - WalletConnectError.UnsupportedMethod - } - else -> { - null - } - } - val networkId = sessionRequest.chainId?.let { blockchainHelper.chainIdToNetworkIdOrNull(it) } ?: "" - val requestData = RequestData( - topic = sessionRequest.topic, - requestId = sessionRequest.id, - blockchain = networkId, - method = sessionRequest.method, - ) - - if (error != null) { - walletConnectRepository.rejectRequest(requestData, error) - return - } - - when (sessionRequest.request) { - is WcRequest.BnbCancel -> Unit - is WcRequest.BnbTxConfirm -> walletConnectRepository.sendRequest( - requestData = requestData, - result = "", - ) - else -> { - currentRequest = sessionRequest - - val data = prepareRequestData(sessionRequest).getOrElse { e -> - val wrappedError = e as? WalletConnectError ?: WalletConnectError.UnknownError( - message = e.localizedMessage ?: "Unknown error", - ) - - walletConnectRepository.rejectRequest(requestData, wrappedError) - handler.onSessionRejected(wrappedError) - return - } - - handler.onSessionRequest(data) - } - } - } - - suspend fun continueWithRequest(request: WcPreparedRequest) { - if (isNewWc) return - val currentRequest = this.currentRequest - if (currentRequest == null || request.topic != currentRequest.topic) return - - val networkId = blockchainHelper.chainIdToNetworkIdOrNull(currentRequest.chainId.orEmpty()) ?: return - val signingResultData = when (request) { - is WcPreparedRequest.BnbTransaction -> sdkHelper.signBnbTransaction( - data = request.preparedRequestData.data.data, - networkId = networkId, - derivationPath = request.derivationPath, - cardId = cardId, - ) - is WcPreparedRequest.EthTransaction -> sdkHelper.completeTransaction( - data = request.preparedRequestData, - cardId = cardId, - ) - is WcPreparedRequest.EthSign -> sdkHelper.signPersonalMessage( - hashToSign = request.preparedRequestData.hash, - networkId = networkId, - type = request.preparedRequestData.type, - derivationPath = request.derivationPath, - cardId = cardId, - ) - is WcPreparedRequest.SolanaSignTransaction -> sdkHelper.signTransaction( - hashToSign = request.preparedRequestData.hashToSign, - networkId = networkId, - type = request.preparedRequestData.type, - derivationPath = request.derivationPath, - cardId = cardId, - ) - is WcPreparedRequest.SolanaSignMultipleTransactions -> sdkHelper.signTransactions( - hashesToSign = request.preparedRequestData.hashesToSign, - networkId = networkId, - type = request.preparedRequestData.type, - derivationPath = request.derivationPath, - cardId = cardId, - ) - } - - val requestData = RequestData( - topic = request.topic, - requestId = request.requestId, - blockchain = networkId, - method = currentRequest.method, - ) - - if (signingResultData == null) { - walletConnectRepository.rejectRequest(requestData, WalletConnectError.SigningError) - } else { - walletConnectRepository.sendRequest( - requestData = requestData, - result = signingResultData, - ) - } - } - - /** - * Handles Wallet Connect deep links. - * If wallet connect is able to handle the deeplink, session is started with deeplink. - * Otherwise, deeplink is stored until wallet connect is ready to handle it. - * - * @param deeplink deeplink to handle - */ - fun addDeeplink(deeplink: String) { - if (isNewWc) return - val deeplinkRegex = Regex(WC_PARAM_REGEX) - val matched = deeplinkRegex.findAll(deeplink) - val sessionTopic = matched.firstOrNull { it.value.contains(WC_TOPIC_QUERY_NAME) }?.groupValues?.lastOrNull() - - val isAlreadyActiveSessionTopic = walletConnectRepository.currentSessions.any { session -> - session.topic == sessionTopic - } - - if (isAlreadyActiveSessionTopic && sessionTopic != null) { - Timber.i("WC already has an active session topic: $deeplink") - return - } - - if (isWalletConnectReadyForDeepLinks) { - val action = WalletConnectAction.OpenSession( - wcUri = deeplink, - source = WalletConnectAction.OpenSession.SourceType.DEEPLINK, - userWalletId = UserWalletId(userWalletId), - ) - store.dispatchOnMain(action) - } else { - deeplinkStack.push(deeplink) - } - } - - private fun getCardId(userWallet: UserWallet): String? { - return if (userWallet is UserWallet.Cold && userWallet.scanResponse.card.backupStatus?.isActive != true) { - userWallet.cardId - } else { // if wallet has backup, any card from wallet can be used to sign - null - } - } - - private suspend fun getAccountsForWc(userWallet: UserWallet, networks: List): List { - val walletManagers = networks.mapNotNull { - val blockchain = it.toBlockchain() - - walletManagersFacade.getOrCreateWalletManager( - userWalletId = userWallet.walletId, - blockchain = blockchain, - derivationPath = it.derivationPath.value, - ) - } - return walletManagers.flatMap { - val wallet = it.wallet - val chainIds = blockchainHelper.networkIdToChainIdOrNull(wallet.blockchain.toNetworkId()) - blockchainHelper.chainIdsToAccounts( - walletAddress = wallet.address, - chainIds = chainIds, - derivationPath = wallet.publicKey.derivationPath?.rawPath, - ) - } - } - - private suspend fun prepareRequestData( - sessionRequest: WalletConnectEvents.SessionRequest, - ): Result { - return sessionRequestConverter.prepareRequest(sessionRequest, userWalletId) - } - - private companion object { - const val WC_TOPIC_QUERY_NAME = "sessionTopic" - const val WC_PARAM_REGEX = "([a-zA-Z\\d-]+)=([a-zA-Z\\d]+)" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcBlockchainHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcBlockchainHelper.kt deleted file mode 100644 index 74241e4cf6..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcBlockchainHelper.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import com.tangem.blockchain.common.Blockchain -import com.tangem.domain.walletconnect.model.legacy.Account - -interface WcBlockchainHelper { - fun chainIdToNetworkIdOrNull(chainId: String): String? - - fun chainIdToMissingNetworkNameOrNull(chainId: String): String? - - fun networkIdToChainIdOrNull(networkId: String): List - - fun getNamespaceFromFullChainIdOrNull(chainId: String): String? - - fun chainIdToFullNameOrNull(chainId: String): String? - - fun chainIdsToAccounts(walletAddress: String, chainIds: List, derivationPath: String?): List - - fun chainIdsToBlockchains(chainIds: List): List -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcJrpcMethods.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcJrpcMethods.kt deleted file mode 100644 index 3a79b205ed..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcJrpcMethods.kt +++ /dev/null @@ -1,297 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import com.squareup.moshi.* -import com.tangem.datasource.di.SdkMoshi -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceCancelOrder -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTradeOrder -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTransferOrder -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTxConfirmParam -import com.tangem.tap.domain.walletconnect2.domain.models.solana.SolanaSignMessage -import com.tangem.tap.domain.walletconnect2.domain.models.solana.SolanaTransactionRequest -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -internal enum class WcJrpcMethods(val code: String) { - - ETH_SIGN("eth_sign"), - ETH_PERSONAL_SIGN("personal_sign"), - ETH_SIGN_TYPE_DATA("eth_signTypedData"), - ETH_SIGN_TYPE_DATA_V4("eth_signTypedData_v4"), - ETH_SIGN_TRANSACTION("eth_signTransaction"), - ETH_SEND_TRANSACTION("eth_sendTransaction"), - - BNB_SIGN("bnb_sign"), - BNB_TRANSACTION_CONFIRM("bnb_tx_confirmation"), - SIGN_TRANSACTION("trust_signTransaction"), - WALLET_ADD_ETHEREUM_CHAIN("wallet_addEthereumChain"), - - SOLANA_SIGN_TX("solana_signTransaction"), - SOLANA_SIGN_MESSAGE("solana_signMessage"), - SOLANA_SIGN_ALL_TX("solana_signAllTransactions"), - ; - - companion object { - fun fromCode(code: String): WcJrpcMethods? = values().firstOrNull { it.code == code } - } -} - -@JsonClass(generateAdapter = true) -data class WcSignTransaction( - @Json(name = "network") - val network: Int, - - @Json(name = "transaction") - val transaction: String, -) : WcRequestData - -@JsonClass(generateAdapter = true) -data class WcSignMessage( - @Json(name = "raw") - val raw: List, - - @Json(name = "type") - val type: WCSignType, -) : WcRequestData { - - @JsonClass(generateAdapter = false) - enum class WCSignType { - MESSAGE, PERSONAL_MESSAGE, TYPED_MESSAGE, SOLANA_MESSAGE, - } - - /** - * Raw parameters will always be the message and the address. Depending on the WCSignType, - * those parameters can be swapped as description below: - * - * - MESSAGE: `[address, data ]` - * - TYPED_MESSAGE: `[address, data]` - * - PERSONAL_MESSAGE: `[data, address]` - * - SOLANA_MESSAGE: `[publicKey (address), message]` - * - * reference: https://docs.walletconnect.org/json-rpc/ethereum#eth_signtypeddata - */ - val data - get() = when (type) { - WCSignType.PERSONAL_MESSAGE -> raw[0] - else -> raw[1] - } - - val address - get() = when (type) { - WCSignType.PERSONAL_MESSAGE -> raw[1] - else -> raw[0] - } -} - -@JsonClass(generateAdapter = true) -data class WcSignMessageData( - @Json(name = "address") - val address: String, - - @Json(name = "message") - val message: String, -) : WcRequestData - -@JsonClass(generateAdapter = true) -data class WcAddChain( - @Json(name = "chainId") - val chainId: String, -) : WcRequestData - -@JsonClass(generateAdapter = true) -data class WcEthereumTransaction( - @Json(name = "from") - val from: String, - - @Json(name = "to") - val to: String?, - - @Json(name = "nonce") - val nonce: String?, - - @Json(name = "gasPrice") - val gasPrice: String?, - - @Json(name = "maxFeePerGas") - val maxFeePerGas: String?, - - @Json(name = "maxPriorityFeePerGas") - val maxPriorityFeePerGas: String?, - - @Json(name = "gas") - val gas: String?, - - @Json(name = "gasLimit") - val gasLimit: String?, - - @Json(name = "value") - val value: String?, - - @Json(name = "data") - val data: String, -) : WcRequestData - -@JsonClass(generateAdapter = true) -data class SolanaTransactionsRequest( - @Json(name = "transactions") - val transactions: List, -) : WcRequestData - -interface WcRequestData - -data class WcCustomRequestData(val data: String) : WcRequestData - -sealed class WcRequest(open val data: WcRequestData) { - data class EthSign(override val data: WcSignMessage) : WcRequest(data) - data class EthSignTransaction(override val data: WcEthereumTransaction) : WcRequest(data) - data class EthSendTransaction(override val data: WcEthereumTransaction) : WcRequest(data) - data class BnbTrade(override val data: WcBinanceTradeOrder) : WcRequest(data) - data class BnbCancel(override val data: WcBinanceCancelOrder) : WcRequest(data) - data class BnbTransfer(override val data: WcBinanceTransferOrder) : WcRequest(data) - data class BnbTxConfirm(override val data: WcBinanceTxConfirmParam) : WcRequest(data) - data class SignTransaction(override val data: WcSignTransaction) : WcRequest(data) - data class AddChain(override val data: WcAddChain) : WcRequest(data) - data class CustomRequest(override val data: WcCustomRequestData) : WcRequest(data) - data class SolanaSignRequest(override val data: SolanaTransactionRequest) : WcRequest(data) - data class SolanaSignTransactions(override val data: SolanaTransactionsRequest) : WcRequest(data) -} - -@Singleton -internal class WcJrpcRequestsDeserializer @Inject constructor(@SdkMoshi private val moshi: Moshi) { - - @Suppress("ComplexMethod", "LongMethod") - fun deserialize(method: String, params: String): WcRequest { - val customRequest = WcRequest.CustomRequest(WcCustomRequestData(params)) - val wcMethod: WcJrpcMethods = WcJrpcMethods.fromCode(method) ?: return customRequest - - return when (wcMethod) { - WcJrpcMethods.ETH_SIGN_TRANSACTION -> { - val deserializedParams = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcEthereumTransaction::class.java), - ).fromJsonFirstOrNull(params) ?: return customRequest - WcRequest.EthSignTransaction(data = deserializedParams) - } - WcJrpcMethods.ETH_SEND_TRANSACTION -> { - val deserializedParams = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcEthereumTransaction::class.java), - ).fromJsonFirstOrNull(params) ?: return customRequest - WcRequest.EthSendTransaction(data = deserializedParams) - } - WcJrpcMethods.ETH_SIGN -> { - val deserializedParams = moshi.adapter>( - Types.newParameterizedType(List::class.java, String::class.java), - ).fromJsonOrNull(params) ?: return customRequest - val data = WcSignMessage( - raw = deserializedParams, - type = WcSignMessage.WCSignType.MESSAGE, - ) - WcRequest.EthSign(data = data) - } - WcJrpcMethods.ETH_PERSONAL_SIGN -> { - val deserializedParams = moshi.adapter>( - Types.newParameterizedType(List::class.java, String::class.java), - ).fromJsonOrNull(params) ?: return customRequest - val data = WcSignMessage( - raw = deserializedParams, - type = WcSignMessage.WCSignType.PERSONAL_MESSAGE, - ) - WcRequest.EthSign(data = data) - } - WcJrpcMethods.ETH_SIGN_TYPE_DATA, WcJrpcMethods.ETH_SIGN_TYPE_DATA_V4 -> { - val deserializedParams = listOf( - params.substring(params.indexOf("\"") + 1, params.indexOf("\"", startIndex = 2)), - params.substring(params.indexOfFirst { it == '{' }, params.indexOfLast { it == '}' } + 1), - ) - val data = WcSignMessage( - deserializedParams, - WcSignMessage.WCSignType.TYPED_MESSAGE, - ) - Timber.d("TypedData params: $deserializedParams") - WcRequest.EthSign(data) - } - WcJrpcMethods.BNB_SIGN -> { - return deserializeBnb(moshi, params) ?: customRequest - } - WcJrpcMethods.BNB_TRANSACTION_CONFIRM -> { - val deserializedParams = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcBinanceTxConfirmParam::class.java), - ).fromJsonFirstOrNull(params) ?: return customRequest - WcRequest.BnbTxConfirm(data = deserializedParams) - } - WcJrpcMethods.SIGN_TRANSACTION -> { - val deserializedParams = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcSignTransaction::class.java), - ).fromJsonFirstOrNull(params) ?: return customRequest - - WcRequest.SignTransaction(data = deserializedParams) - } - WcJrpcMethods.WALLET_ADD_ETHEREUM_CHAIN -> { - val deserializedParams: WcAddChain = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcAddChain::class.java), - ).fromJsonFirstOrNull(params) ?: return customRequest - - WcRequest.AddChain(data = deserializedParams) - } - WcJrpcMethods.SOLANA_SIGN_TX -> { - val tx = moshi.adapter(SolanaTransactionRequest::class.java) - .fromJsonOrNull(params) - ?: return customRequest - - WcRequest.SolanaSignRequest(data = tx) - } - WcJrpcMethods.SOLANA_SIGN_MESSAGE -> { - val signMessage = moshi.adapter(SolanaSignMessage::class.java) - .fromJsonOrNull(params) - ?: return customRequest - val data = WcSignMessage( - raw = listOf(signMessage.publicKey, signMessage.message), - type = WcSignMessage.WCSignType.SOLANA_MESSAGE, - ) - - WcRequest.EthSign(data = data) - } - WcJrpcMethods.SOLANA_SIGN_ALL_TX -> { - val transactionsToSign = moshi.adapter(SolanaTransactionsRequest::class.java) - .fromJsonOrNull(params) ?: return customRequest - WcRequest.SolanaSignTransactions(transactionsToSign) - } - } - } - - private fun deserializeBnb(moshi: Moshi, params: String): WcRequest? { - val cancelOrder = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcBinanceCancelOrder::class.java), - ).fromJsonFirstOrNull(params) - if (cancelOrder != null) return WcRequest.BnbCancel(cancelOrder) - - val tradeOrder = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcBinanceTradeOrder::class.java), - ).fromJsonFirstOrNull(params) - if (tradeOrder != null) return WcRequest.BnbTrade(tradeOrder) - - val transferOrder = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcBinanceTransferOrder::class.java), - ).fromJsonFirstOrNull(params) - if (transferOrder != null) return WcRequest.BnbTransfer(transferOrder) - - return null - } - - private fun JsonAdapter.fromJsonOrNull(data: String): T? { - return try { - this.fromJson(data) - } catch (e: Exception) { - Timber.e(e.message) - null - } - } - - private fun JsonAdapter>.fromJsonFirstOrNull(data: String): T? { - return try { - this.fromJson(data)?.firstOrNull() - } catch (e: Exception) { - Timber.e(e.message) - null - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcPreparedRequest.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcPreparedRequest.kt deleted file mode 100644 index ba9f21b2d6..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcPreparedRequest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import com.tangem.tap.domain.walletconnect2.domain.models.BnbData -import com.tangem.tap.features.details.redux.walletconnect.WcPersonalSignData -import com.tangem.tap.features.details.redux.walletconnect.WcTransactionData - -sealed class WcPreparedRequest( - open val preparedRequestData: Any, - val topic: String, - val requestId: Long, - val derivationPath: String?, -) { - class EthSign( - override val preparedRequestData: WcPersonalSignData, - topic: String, - requestId: Long, - derivationPath: String?, - ) : WcPreparedRequest(preparedRequestData, topic, requestId, derivationPath) - - class EthTransaction( - override val preparedRequestData: WcTransactionData, - topic: String, - requestId: Long, - derivationPath: String?, - ) : WcPreparedRequest(preparedRequestData, topic, requestId, derivationPath) - - class BnbTransaction( - override val preparedRequestData: BnbData, - topic: String, - requestId: Long, - derivationPath: String?, - ) : WcPreparedRequest(preparedRequestData, topic, requestId, derivationPath) - - class SolanaSignTransaction( - override val preparedRequestData: GenericTransactionData.SingleHash, - topic: String, - requestId: Long, - derivationPath: String?, - ) : WcPreparedRequest(preparedRequestData, topic, requestId, derivationPath) - - class SolanaSignMultipleTransactions( - override val preparedRequestData: GenericTransactionData.MultipleHashes, - topic: String, - requestId: Long, - derivationPath: String?, - ) : WcPreparedRequest(preparedRequestData, topic, requestId, derivationPath) -} - -sealed interface GenericTransactionData { - val dAppName: String - val type: TransactionType - - data class SingleHash( - val hashToSign: ByteArray, - override val dAppName: String, - override val type: TransactionType, - ) : GenericTransactionData - - data class MultipleHashes( - val hashesToSign: List, - override val dAppName: String, - override val type: TransactionType, - ) : GenericTransactionData -} - -enum class TransactionType { - SOLANA_TX, -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcSessionRequestConverter.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcSessionRequestConverter.kt deleted file mode 100644 index 90de1f8b12..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcSessionRequestConverter.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper -import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository -import com.tangem.tap.domain.walletconnect.WalletConnectSdkHelper -import com.tangem.tap.domain.walletconnect2.domain.models.BnbData -import com.tangem.tap.domain.walletconnect2.domain.models.EthTransactionData -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectError -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectEvents -import com.tangem.tap.features.details.redux.walletconnect.WcEthTransactionType -import okio.ByteString.Companion.decodeBase64 - -internal class WcSessionRequestConverter( - private val blockchainHelper: WcBlockchainHelper, - private val sessionsRepository: WalletConnectSessionsRepository, - private val sdkHelper: WalletConnectSdkHelper, -) { - - @Suppress("LongMethod") - suspend fun prepareRequest( - sessionRequest: WalletConnectEvents.SessionRequest, - userWalletId: String, - ): Result = runCatching { - val networkId = requireNotNull(blockchainHelper.chainIdToNetworkIdOrNull(sessionRequest.chainId.orEmpty())) { - "Failed to get network ID for chain ID: ${sessionRequest.chainId}" - } - val derivationPath = getDerivationPath( - sessionsRepository = sessionsRepository, - sessionRequest = sessionRequest, - userWalletId = userWalletId, - walletAddress = getWalletAddress(sessionRequest.request), - ) - - when (val request = sessionRequest.request) { - is WcRequest.EthSendTransaction -> { - val data = sdkHelper.prepareTransactionData( - EthTransactionData( - transaction = request.data, - networkId = networkId, - rawDerivationPath = derivationPath, - id = sessionRequest.id, - topic = sessionRequest.topic, - type = WcEthTransactionType.EthSendTransaction, - metaName = sessionRequest.metaName, - metaUrl = sessionRequest.metaUrl, - ), - ) - - WcPreparedRequest.EthTransaction( - preparedRequestData = data, - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - is WcRequest.EthSignTransaction -> { - val data = sdkHelper.prepareTransactionData( - EthTransactionData( - transaction = request.data, - networkId = networkId, - rawDerivationPath = derivationPath, - id = sessionRequest.id, - topic = sessionRequest.topic, - type = WcEthTransactionType.EthSignTransaction, - metaName = sessionRequest.metaName, - metaUrl = sessionRequest.metaUrl, - ), - ) - - WcPreparedRequest.EthTransaction( - preparedRequestData = data, - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - is WcRequest.EthSign -> { - val data = sdkHelper.prepareDataForPersonalSign( - request.data, - sessionRequest.topic, - sessionRequest.metaName, - sessionRequest.id, - ) - WcPreparedRequest.EthSign( - preparedRequestData = data, - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - is WcRequest.BnbTrade -> { - val data = sdkHelper.prepareBnbTradeOrder(request.data) - WcPreparedRequest.BnbTransaction( - BnbData( - data = data, - topic = sessionRequest.topic, - requestId = sessionRequest.id, - dAppName = sessionRequest.metaName, - ), - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - is WcRequest.BnbTransfer -> { - val data = sdkHelper.prepareBnbTransferOrder(request.data) - WcPreparedRequest.BnbTransaction( - BnbData( - data = data, - topic = sessionRequest.topic, - requestId = sessionRequest.id, - dAppName = sessionRequest.metaName, - ), - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - is WcRequest.SolanaSignRequest -> { - val transaction = request.data.transaction - - WcPreparedRequest.SolanaSignTransaction( - preparedRequestData = GenericTransactionData.SingleHash( - hashToSign = transaction.prepareSolanaTransaction(), - dAppName = sessionRequest.metaName, - type = TransactionType.SOLANA_TX, - ), - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - is WcRequest.SolanaSignTransactions -> { - val transactions = request.data.transactions - WcPreparedRequest.SolanaSignMultipleTransactions( - preparedRequestData = GenericTransactionData.MultipleHashes( - hashesToSign = transactions.map { it.prepareSolanaTransaction() }, // - dAppName = sessionRequest.metaName, - type = TransactionType.SOLANA_TX, - ), - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - else -> throw WalletConnectError.UnsupportedMethod - } - } - - /** - * Input transaction in Base64 string - */ - private fun String.prepareSolanaTransaction(): ByteArray { - val transaction = this.decodeBase64()?.toByteArray() ?: ByteArray(0) - return SolanaTransactionHelper.removeSignaturesPlaceholders(transaction) - } - - private fun getWalletAddress(request: WcRequest): String? { - return when (request) { - is WcRequest.BnbTrade -> request.data.accountNumber - is WcRequest.BnbTransfer -> request.data.accountNumber - is WcRequest.EthSendTransaction -> request.data.from - is WcRequest.EthSignTransaction -> request.data.from - is WcRequest.EthSign -> request.data.address - is WcRequest.SolanaSignRequest -> request.data.feePayer - else -> null - } - } - - private suspend fun getDerivationPath( - sessionsRepository: WalletConnectSessionsRepository, - sessionRequest: WalletConnectEvents.SessionRequest, - userWalletId: String, - walletAddress: String?, - ): String? { - return sessionsRepository.loadSessions(userWalletId) - .firstOrNull { it.topic == sessionRequest.topic } - ?.accounts?.firstOrNull { - // if walletAddress == null take first account - it.chainId == sessionRequest.chainId && - (walletAddress == null || it.walletAddress.lowercase() == walletAddress.lowercase()) - }?.derivationPath - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/BnbData.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/BnbData.kt deleted file mode 100644 index 194ca5d01d..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/BnbData.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -import com.tangem.tap.features.details.redux.walletconnect.BinanceMessageData - -data class BnbData( - val data: BinanceMessageData, - val topic: String, - val requestId: Long, - val dAppName: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/EthTransactionData.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/EthTransactionData.kt deleted file mode 100644 index 2138d6bd63..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/EthTransactionData.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -import com.tangem.tap.domain.walletconnect2.domain.WcEthereumTransaction -import com.tangem.tap.features.details.redux.walletconnect.WcEthTransactionType - -data class EthTransactionData( - val transaction: WcEthereumTransaction, - val networkId: String, - val rawDerivationPath: String?, - val id: Long, - val topic: String, - val type: WcEthTransactionType, - val metaName: String, - val metaUrl: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/NetworkNamespace.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/NetworkNamespace.kt deleted file mode 100644 index a431210e42..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/NetworkNamespace.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -@JvmInline -value class NetworkNamespace(val value: String) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/RequestData.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/RequestData.kt deleted file mode 100644 index 38cd19ca97..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/RequestData.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -data class RequestData( - val topic: String, - val requestId: Long, - val method: String, - val blockchain: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectError.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectError.kt deleted file mode 100644 index 40d82cdb51..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectError.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -sealed class WalletConnectError(val error: String) : Exception() { - - data object UnsupportedDApp : WalletConnectError("UnsupportedDApp") - - data class ApprovalErrorMissingNetworks( - val missingChains: List, - ) : WalletConnectError("ApprovalErrorMissingNetworks") - - data class ApprovalErrorAddNetwork( - val networks: List, - ) : WalletConnectError("ApprovalErrorAddNetwork") - - data class ApprovalErrorUnsupportedNetwork( - val unsupportedNetworks: List, - ) : WalletConnectError("ApprovalErrorUnsupportedNetwork") - - data class ExternalApprovalError( - override val message: String?, - ) : WalletConnectError("ExternalApprovalError") - - data class UnknownError( - override val message: String, - ) : WalletConnectError(message) - - data object WrongUserWallet : WalletConnectError("WrongUserWallet") - data object UnsupportedMethod : WalletConnectError("UnsupportedMethod") - data object SigningError : WalletConnectError("SigningError") - data object ValidationError : WalletConnectError("ValidationError") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectEvents.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectEvents.kt deleted file mode 100644 index 1298ede44d..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectEvents.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -import com.tangem.domain.walletconnect.model.legacy.Account -import com.tangem.tap.domain.walletconnect2.domain.WcRequest -import java.net.URI - -sealed interface WalletConnectEvents { - data class SessionProposal( - val name: String, - val description: String, - val url: String, - val icons: List, - val requiredChainIds: List, - val optionalChainIds: List, - ) : WalletConnectEvents - - data class SessionApprovalError(val error: WalletConnectError) : WalletConnectEvents - data class SessionApprovalSuccess(val topic: String, val accounts: List) : WalletConnectEvents - data class SessionDeleted(val topic: String) : WalletConnectEvents - - data class SessionRequest( - val request: WcRequest, - val chainId: String?, - val topic: String, - val id: Long, - val metaName: String, - val metaUrl: String, - val method: String, - ) : WalletConnectEvents - - data class PairConnectError(val error: Throwable) : WalletConnectEvents -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectSession.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectSession.kt deleted file mode 100644 index fb535bcc66..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectSession.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -data class WalletConnectSession( - val topic: String, - val icon: String?, - val name: String?, - val url: String?, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceCancelOrder.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceCancelOrder.kt deleted file mode 100644 index 112c92e7a6..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceCancelOrder.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models.binance - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import com.tangem.tap.domain.walletconnect2.domain.WcRequestData - -@Suppress("LongParameterList") -@JsonClass(generateAdapter = true) -data class WcBinanceCancelOrder( - @Json(name = "account_number") - val accountNumber: String, - @Json(name = "chain_id") - val chainId: String, - @Json(name = "data") - val data: String?, - @Json(name = "memo") - val memo: String?, - @Json(name = "sequence") - val sequence: String, - @Json(name = "source") - val source: String, - @Json(name = "msgs") - val msgs: List, -) : WcRequestData { - - @JsonClass(generateAdapter = true) - data class Message( - @Json(name = "refid") - val refid: String, - @Json(name = "sender") - val sender: String, - @Json(name = "symbol") - val symbol: String, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTradeOrder.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTradeOrder.kt deleted file mode 100644 index bdeaba5b27..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTradeOrder.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models.binance - -import com.github.salomonbrys.kotson.jsonSerializer -import com.google.gson.JsonObject -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import com.tangem.tap.domain.walletconnect2.domain.WcRequestData - -@Suppress("LongParameterList") -@JsonClass(generateAdapter = true) -data class WcBinanceTradeOrder( - @Json(name = "account_number") - val accountNumber: String, - @Json(name = "chain_id") - val chainId: String, - @Json(name = "data") - val data: String?, - @Json(name = "memo") - val memo: String?, - @Json(name = "sequence") - val sequence: String, - @Json(name = "source") - val source: String, - @Json(name = "msgs") - val msgs: List, -) : WcRequestData { - - @JsonClass(generateAdapter = false) - enum class MessageKey(val key: String) { - ID("id"), - ORDER_TYPE("ordertype"), - PRICE("price"), - QUANTITY("quantity"), - SENDER("sender"), - SIDE("side"), - SYMBOL("symbol"), - TIME_INFORCE("timeinforce"), - } - - @JsonClass(generateAdapter = true) - data class Message( - @Json(name = "id") - val id: String, - @Json(name = "orderType") - val orderType: Int, - @Json(name = "price") - val price: Long, - @Json(name = "quantity") - val quantity: Long, - @Json(name = "sender") - val sender: String, - @Json(name = "side") - val side: Int, - @Json(name = "symbol") - val symbol: String, - @Json(name = "timeInforce") - val timeInforce: Int, - ) -} - -val tradeOrderSerializer = jsonSerializer { - val jsonObject = JsonObject() - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.ID.key, it.src.id) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.ORDER_TYPE.key, it.src.orderType) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.PRICE.key, it.src.price) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.QUANTITY.key, it.src.quantity) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.SENDER.key, it.src.sender) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.SIDE.key, it.src.side) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.SYMBOL.key, it.src.symbol) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.TIME_INFORCE.key, it.src.timeInforce) - - jsonObject -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTransferOrder.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTransferOrder.kt deleted file mode 100644 index 99199c17dc..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTransferOrder.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models.binance - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import com.tangem.tap.domain.walletconnect2.domain.WcRequestData - -@Suppress("LongParameterList") -@JsonClass(generateAdapter = true) -data class WcBinanceTransferOrder( - @Json(name = "account_number") - val accountNumber: String, - @Json(name = "chain_id") - val chainId: String, - @Json(name = "data") - val data: String?, - @Json(name = "memo") - val memo: String?, - @Json(name = "sequence") - val sequence: String, - @Json(name = "source") - val source: String, - @Json(name = "msgs") - val msgs: List, -) : WcRequestData { - - @JsonClass(generateAdapter = true) - data class Message( - @Json(name = "inputs") - val inputs: List, - @Json(name = "outputs") - val outputs: List, - ) { - - @JsonClass(generateAdapter = true) - data class Item( - @Json(name = "address") - val address: String, - @Json(name = "coins") - val coins: List, - ) { - - @JsonClass(generateAdapter = true) - data class Coin( - @Json(name = "amount") - val amount: Long, - @Json(name = "denom") - val denom: String, - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTxConfirmParam.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTxConfirmParam.kt deleted file mode 100644 index 24ada4ca98..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTxConfirmParam.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models.binance - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import com.tangem.tap.domain.walletconnect2.domain.WcRequestData - -@JsonClass(generateAdapter = true) -data class WcBinanceTxConfirmParam( - @Json(name = "ok") - val ok: Boolean, - @Json(name = "errorMsg") - val errorMsg: String?, -) : WcRequestData \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaSignMessage.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaSignMessage.kt deleted file mode 100644 index a521aa55e9..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaSignMessage.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models.solana - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -@JsonClass(generateAdapter = true) -internal data class SolanaSignMessage( - @Json(name = "pubkey") - val publicKey: String, - - @Json(name = "message") - val message: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaTransactionRequest.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaTransactionRequest.kt deleted file mode 100644 index bed9fd3897..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaTransactionRequest.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models.solana - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import com.tangem.tap.domain.walletconnect2.domain.WcRequestData - -@JsonClass(generateAdapter = true) -data class SolanaTransactionRequest( - @Json(name = "feePayer") - val feePayer: String?, - - @Json(name = "transaction") - val transaction: String, -) : WcRequestData \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/toggles/DefaultWalletConnectFeatureToggles.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/toggles/DefaultWalletConnectFeatureToggles.kt deleted file mode 100644 index 03cb9b878d..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/toggles/DefaultWalletConnectFeatureToggles.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.toggles - -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles - -internal class DefaultWalletConnectFeatureToggles( - private val featureTogglesManager: FeatureTogglesManager, -) : WalletConnectFeatureToggles { - - override val isRedesignedWalletConnectEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled("WALLET_CONNECT_REDESIGN_ENABLED") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt b/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt index 0ba699bde1..e4070d4b60 100644 --- a/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt +++ b/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt @@ -1,44 +1,22 @@ package com.tangem.tap.features.demo -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.redux.AppState -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store -import com.tangem.wallet.R -import org.rekotlin.Action object DemoHelper { val config = DemoConfig() - private val disabledActionFeatures = listOf( - WalletConnectAction.StartWalletConnect::class.java, - ) - fun isDemoCard(scanResponse: ScanResponse): Boolean = isDemoCardId(scanResponse.card.cardId) fun isTestDemoCard(scanResponse: ScanResponse): Boolean = config.isTestDemoCardId(scanResponse.card.cardId) fun isDemoCardId(cardId: String): Boolean = config.isDemoCardId(cardId) - fun tryHandle(appState: () -> AppState?, action: Action): Boolean { + fun tryHandle(appState: () -> AppState?): Boolean { val scanResponse = getScanResponse(appState) ?: return false if (!scanResponse.isDemoCard()) return false - disabledActionFeatures.firstOrNull { it == action::class.java }?.let { - val uiMessageSender = store.inject(DaggerGraphState::uiMessageSender) - uiMessageSender.send( - DialogMessage( - message = resourceReference(R.string.alert_demo_feature_disabled), - ), - ) - return true - } - return false } diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt index 9281cbc507..2ebe0494ab 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt @@ -39,7 +39,7 @@ class DetailsMiddleware { val detailsMiddleware: Middleware = { _, stateProvider -> { next -> { action -> - if (!DemoHelper.tryHandle(stateProvider, action)) { + if (!DemoHelper.tryHandle(stateProvider)) { val detailsState = stateProvider()?.detailsState if (detailsState != null) { handleAction(detailsState, action) diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectAction.kt b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectAction.kt deleted file mode 100644 index bc90c602d0..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectAction.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.tangem.tap.features.details.redux.walletconnect - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectError -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectEvents -import com.tangem.tap.features.details.ui.walletconnect.WcSessionForScreen -import org.rekotlin.Action - -sealed class WalletConnectAction : Action { - data class HandleDeepLink(val wcUri: String?) : WalletConnectAction() - - data object StartWalletConnect : WalletConnectAction() - - data class OpenSession( - val wcUri: String, - val source: SourceType, - val userWalletId: UserWalletId, - ) : WalletConnectAction() { - enum class SourceType { QR, DEEPLINK, CLIPBOARD, ETC } - } - - data class DisconnectSession(val topic: String) : WalletConnectAction() - - data class RejectRequest(val topic: String, val id: Long) : WalletConnectAction() - - //region WalletConnect 2.0 - data class ApproveProposal(val proposal: WalletConnectEvents.SessionProposal) : WalletConnectAction() - data object RejectProposal : WalletConnectAction() - - data object SessionEstablished : WalletConnectAction() - data class SessionRejected(val error: WalletConnectError) : WalletConnectAction() - data class SessionListUpdated(val sessions: List) : WalletConnectAction() - - data class ShowSessionRequest(val sessionRequest: WcPreparedRequest) : WalletConnectAction() - - data object RejectUnsupportedRequest : WalletConnectAction() - - data class PerformRequestedAction(val sessionRequest: WcPreparedRequest) : WalletConnectAction() - - data class PairConnectErrorAction(val throwable: Throwable) : WalletConnectAction() - - data object UnsupportedDappRequest : WalletConnectAction() - //endregion WalletConnect 2.0 -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt deleted file mode 100644 index 7439241a50..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt +++ /dev/null @@ -1,191 +0,0 @@ -package com.tangem.tap.features.details.redux.walletconnect - -import com.tangem.blockchain.common.WalletManager -import com.tangem.blockchainsdk.utils.toNetworkId -import com.tangem.common.routing.AppRoute -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.AppDialog -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository -import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectError -import com.tangem.tap.features.demo.DemoHelper -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.scope -import com.tangem.tap.store -import com.tangem.wallet.R -import kotlinx.coroutines.launch -import org.rekotlin.Action -import org.rekotlin.Middleware -import timber.log.Timber - -@Suppress("LargeClass") -class WalletConnectMiddleware { - private val walletConnectInteractor: WalletConnectInteractor - get() = store.inject(DaggerGraphState::walletConnectInteractor) - private val walletConnectRepository: LegacyWalletConnectRepository - get() = store.inject(DaggerGraphState::walletConnectRepository) - - val walletConnectMiddleware: Middleware = { dispatch, state -> - { next -> - { action -> - handle(state, action) - next(action) - } - } - } - - @Suppress("ComplexMethod", "LongMethod") - private fun handle(state: () -> AppState?, action: Action) { - if (DemoHelper.tryHandle(state, action)) return - - when (action) { - is WalletConnectAction.HandleDeepLink -> { - val wsUrl = action.wcUri - Timber.i("WC deeplink: $wsUrl") - if (!wsUrl.isNullOrBlank()) { - Timber.i("WC deeplink added to stack: $wsUrl") - walletConnectInteractor.addDeeplink(wsUrl) - } - } - is WalletConnectAction.DisconnectSession -> { - walletConnectInteractor.disconnectSession(action.topic) - } - is WalletConnectAction.StartWalletConnect -> { - store.dispatchNavigationAction { - push(AppRoute.QrScanning(AppRoute.QrScanning.Source.WalletConnect)) - } - } - is WalletConnectAction.OpenSession -> { - val index = action.wcUri.indexOf("@") - when (action.wcUri[index + 1]) { - '2' -> walletConnectRepository.pair( - uri = action.wcUri, - source = action.source, - userWalletId = action.userWalletId, - ) - '1' -> { - store.dispatchOnMain(WalletConnectAction.UnsupportedDappRequest) - store.dispatchOnMain( - GlobalAction.ShowDialog(WalletConnectDialog.UnsupportedWcVersion), - ) - } - } - } - is WalletConnectAction.RejectRequest -> { - walletConnectInteractor.cancelRequest(action.topic, action.id) - } - is WalletConnectAction.ApproveProposal -> { - scope.launch { - val proposalChainIds = action.proposal.requiredChainIds + action.proposal.optionalChainIds - val proposalBlockchains = - walletConnectInteractor.blockchainHelper.chainIdsToBlockchains(proposalChainIds) - val accounts = getWalletManagers() - .filter { walletManager -> proposalBlockchains.contains(walletManager.wallet.blockchain) } - .flatMap { - val wallet = it.wallet - val chainIds = walletConnectInteractor.blockchainHelper.networkIdToChainIdOrNull( - wallet.blockchain.toNetworkId(), - ) - walletConnectInteractor.blockchainHelper.chainIdsToAccounts( - walletAddress = wallet.address, - chainIds = chainIds, - derivationPath = wallet.publicKey.derivationPath?.rawPath, - ) - } - walletConnectInteractor.approveSessionProposal(accounts) - } - } - is WalletConnectAction.RejectProposal -> { - walletConnectInteractor.rejectSessionProposal() - } - is WalletConnectAction.SessionEstablished -> { - } - is WalletConnectAction.SessionRejected -> { - when (action.error) { - is WalletConnectError.ApprovalErrorAddNetwork -> { - store.dispatchOnMain( - GlobalAction.ShowDialog( - WalletConnectDialog.AddNetwork(action.error.networks), - ), - ) - } - is WalletConnectError.ApprovalErrorUnsupportedNetwork -> { - store.dispatchOnMain( - GlobalAction.ShowDialog( - WalletConnectDialog.UnsupportedNetwork(action.error.unsupportedNetworks), - ), - ) - } - is WalletConnectError.UnsupportedDApp -> { - store.dispatchOnMain( - GlobalAction.ShowDialog( - WalletConnectDialog.UnsupportedDapp, - ), - ) - } - is WalletConnectError.UnknownError -> { - store.dispatchOnMain( - GlobalAction.ShowDialog( - AppDialog.SimpleOkDialogRes( - headerId = R.string.wallet_connect_title, - messageId = R.string.wallet_connect_error_with_framework_message, - args = listOf(action.error.message), - ), - ), - ) - } - is WalletConnectError.ExternalApprovalError -> { - Timber.e(action.error, "ExternalApprovalError ${action.error.message}") - // do not show dialog on this event - // val message = action.error.message - // if (!message.isNullOrEmpty()) { - // store.dispatchOnMain( - // GlobalAction.ShowDialog( - // AppDialog.SimpleOkWarningDialog( - // message = message, - // ), - // ), - // ) - // } - } - else -> Unit - } - } - - is WalletConnectAction.ShowSessionRequest -> { - val dialog: WalletConnectDialog = when (val request = action.sessionRequest) { - is WcPreparedRequest.BnbTransaction -> WalletConnectDialog.BnbTransactionDialog(request) - is WcPreparedRequest.EthTransaction -> WalletConnectDialog.RequestTransaction(request) - is WcPreparedRequest.EthSign -> WalletConnectDialog.PersonalSign(request) - is WcPreparedRequest.SolanaSignTransaction -> - WalletConnectDialog.SignTransactionDialog(request) - is WcPreparedRequest.SolanaSignMultipleTransactions -> - WalletConnectDialog.SignTransactionsDialog(request) - } - store.dispatch(GlobalAction.ShowDialog(dialog)) - } - is WalletConnectAction.PerformRequestedAction -> { - scope.launch { walletConnectInteractor.continueWithRequest(action.sessionRequest) } - } - is WalletConnectAction.RejectUnsupportedRequest -> { - store.dispatchOnMain(GlobalAction.ShowDialog(WalletConnectDialog.UnsupportedNetwork())) - } - is WalletConnectAction.PairConnectErrorAction -> { - store.dispatch(GlobalAction.ShowDialog(WalletConnectDialog.PairConnectErrorDialog(action.throwable))) - } - } - } - - private suspend fun getWalletManagers(): List { - val walletManagerFacade = store.inject(DaggerGraphState::walletManagersFacade) - val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager) - val userWallet = userWalletsListManager.selectedUserWalletSync ?: return emptyList() - - return walletManagerFacade.getStoredWalletManagers(userWallet.walletId) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectReducer.kt b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectReducer.kt deleted file mode 100644 index 1cf6c4da24..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectReducer.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.tangem.tap.features.details.redux.walletconnect - -import org.rekotlin.Action - -object WalletConnectReducer { - fun reduce(action: Action, state: WalletConnectState): WalletConnectState { - if (action !is WalletConnectAction) return state - - return when (action) { - is WalletConnectAction.OpenSession -> { - state.copy(loading = true) - } - is WalletConnectAction.ApproveProposal -> state.copy(loading = true) - is WalletConnectAction.RejectProposal, - is WalletConnectAction.SessionEstablished, - is WalletConnectAction.SessionRejected, - is WalletConnectAction.PairConnectErrorAction, - is WalletConnectAction.UnsupportedDappRequest, - -> state.copy(loading = false) - is WalletConnectAction.SessionListUpdated -> state.copy( - wc2Sessions = action.sessions, - ) - else -> state - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectState.kt b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectState.kt deleted file mode 100644 index ac28ca0b95..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectState.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.tangem.tap.features.details.redux.walletconnect - -import com.squareup.moshi.JsonClass -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.WalletManager -import com.tangem.blockchain.common.derivation.DerivationStyle -import com.tangem.crypto.hdWallet.DerivationPath -import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.redux.StateDialog -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.domain.walletconnect2.domain.WcSignMessage -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectEvents -import com.tangem.tap.features.details.ui.walletconnect.WcSessionForScreen -import com.tangem.tap.features.details.ui.walletconnect.dialogs.PersonalSignDialogData -import com.tangem.tap.features.details.ui.walletconnect.dialogs.TransactionRequestDialogData - -data class WalletConnectState( - val loading: Boolean = false, - val wc2Sessions: List = listOf(), - val newSessionData: NewWcSessionData? = null, -) - -data class NewWcSessionData( - val session: WalletConnectSession, - val scanResponse: ScanResponse, - val blockchain: Blockchain?, -) - -data class WalletConnectSession( - val peerId: String, - val remotePeerId: String?, - val wallet: WalletForSession, -) { - fun getAddress(): String? { - val key = wallet.derivedPublicKey ?: wallet.walletPublicKey ?: return null - return wallet.blockchain?.makeAddresses(key)?.first()?.value - } -} - -@JsonClass(generateAdapter = true) -data class WalletForSession( - val walletPublicKey: ByteArray? = null, - val derivedPublicKey: ByteArray? = null, - val derivationPath: DerivationPath? = null, - val derivationStyle: DerivationStyle? = null, - val isTestNet: Boolean = false, - val blockchain: Blockchain? = if (isTestNet) Blockchain.EthereumTestnet else Blockchain.Ethereum, -) { - - fun getBlockchainForSession(): Blockchain { - return blockchain ?: if (isTestNet) Blockchain.EthereumTestnet else Blockchain.Ethereum - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as WalletForSession - - if (walletPublicKey != null) { - if (other.walletPublicKey == null) return false - if (!walletPublicKey.contentEquals(other.walletPublicKey)) return false - } else if (other.walletPublicKey != null) return false - if (derivedPublicKey != null) { - if (other.derivedPublicKey == null) return false - if (!derivedPublicKey.contentEquals(other.derivedPublicKey)) return false - } else if (other.derivedPublicKey != null) return false - if (derivationPath != other.derivationPath) return false - if (isTestNet != other.isTestNet) return false - return blockchain == other.blockchain - } - - override fun hashCode(): Int { - var result = walletPublicKey?.contentHashCode() ?: 0 - result = 31 * result + (derivedPublicKey?.contentHashCode() ?: 0) - result = 31 * result + (derivationPath?.hashCode() ?: 0) - result = 31 * result + isTestNet.hashCode() - result = 31 * result + (blockchain?.hashCode() ?: 0) - return result - } -} - -sealed class WalletConnectDialog : StateDialog { - data object UnsupportedWcVersion : WalletConnectDialog() - data object UnsupportedCard : WalletConnectDialog() - data class UnsupportedNetwork(val networks: List? = null) : WalletConnectDialog() - data object UnsupportedDapp : WalletConnectDialog() - data class AddNetwork(val networks: List) : WalletConnectDialog() - data object OpeningSessionRejected : WalletConnectDialog() - data object SessionTimeout : WalletConnectDialog() - data class ApproveWcSession( - val session: WalletConnectSession, - val networks: List, - ) : WalletConnectDialog() - - data class SessionProposalDialog( - val sessionProposal: WalletConnectEvents.SessionProposal, - val networks: String, - val onApprove: () -> Unit, - val onReject: () -> Unit, - ) : WalletConnectDialog() - - data class ChooseNetwork( - val session: WalletConnectSession, - val networks: List, - ) : WalletConnectDialog() - - data class RequestTransaction(val data: WcPreparedRequest.EthTransaction) : - WalletConnectDialog() - - data class PersonalSign(val data: WcPreparedRequest.EthSign) : WalletConnectDialog() - data class BnbTransactionDialog( - val data: WcPreparedRequest.BnbTransaction, - ) : WalletConnectDialog() - - data class SignTransactionDialog( - val data: WcPreparedRequest.SolanaSignTransaction, - ) : WalletConnectDialog() - - data class SignTransactionsDialog( - val data: WcPreparedRequest.SolanaSignMultipleTransactions, - ) : WalletConnectDialog() - - data class PairConnectErrorDialog(val error: Throwable) : WalletConnectDialog() -} - -data class WcTransactionData( - val type: WcEthTransactionType, - val transaction: TransactionData, - val topic: String, - val id: Long, - val walletManager: WalletManager, - val dialogData: TransactionRequestDialogData, -) - -enum class WcEthTransactionType { - EthSignTransaction, - EthSendTransaction, -} - -data class WcPersonalSignData( - val hash: ByteArray, - val topic: String, - val id: Long, - val dialogData: PersonalSignDialogData, - val type: WcSignMessage.WCSignType, -) - -sealed class BinanceMessageData( - val address: String, - val data: ByteArray, -) { - class Trade( - val tradeData: List, - address: String, - data: ByteArray, - ) : BinanceMessageData(address, data) - - class Transfer( - val outputAddress: String, - val amount: String, - address: String, - data: ByteArray, - ) : BinanceMessageData(address, data) -} - -data class TradeData( - val price: String, - val quantity: String, - val amount: String, - val symbol: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsScreen.kt index 44fdad55f0..eb76dc0b7a 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsScreen.kt @@ -13,11 +13,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate 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 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.DeviceSettingsScreenTestTags import com.tangem.tap.features.details.ui.common.DetailsMainButton import com.tangem.tap.features.details.ui.common.SettingsScreensScaffold import com.tangem.wallet.R @@ -49,7 +51,8 @@ private fun CardSettingsReadCard(onScanCardClick: () -> Unit) { Box( modifier = Modifier .fillMaxWidth() - .padding(bottom = TangemTheme.dimens.spacing40), + .padding(bottom = TangemTheme.dimens.spacing40) + .testTag(DeviceSettingsScreenTestTags.IMAGE_BLOCK), ) { Image( modifier = Modifier @@ -116,7 +119,9 @@ private fun CardSettings(state: CardSettingsScreenState) { if (state.cardDetails == null) return LazyColumn( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag(DeviceSettingsScreenTestTags.LAZY_LIST), ) { items(state.cardDetails) { val paddingBottom = when (it) { @@ -164,12 +169,14 @@ private fun CardSettings(state: CardSettingsScreenState) { text = it.titleRes.resolveReference(), color = titleColor, style = TangemTheme.typography.subtitle1, + modifier = Modifier.testTag(DeviceSettingsScreenTestTags.ITEM_TITLE), ) Spacer(modifier = Modifier.size(TangemTheme.dimens.size4)) Text( text = it.subtitle.resolveReference(), color = subtitleColor, style = TangemTheme.typography.body2, + modifier = Modifier.testTag(DeviceSettingsScreenTestTags.ITEM_SUBTITLE), ) } } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt index dd3093deb9..e81602a4d2 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt @@ -9,12 +9,14 @@ import androidx.compose.material3.IconToggleButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.components.* 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.ResetCardScreenTestTags import com.tangem.tap.features.details.ui.cardsettings.TextReference import com.tangem.tap.features.details.ui.cardsettings.resolveReference import com.tangem.tap.features.details.ui.common.DetailsMainButton @@ -74,6 +76,7 @@ private fun Title() { text = stringResourceSafe(id = R.string.card_settings_reset_card_to_factory), style = TangemTheme.typography.h1, color = TangemTheme.colors.text.primary1, + modifier = Modifier.testTag(ResetCardScreenTestTags.TITLE), ) } @@ -82,7 +85,9 @@ private fun AlertImage() { Image( painter = painterResource(id = R.drawable.img_alert_80), contentDescription = null, - modifier = Modifier.size(TangemTheme.dimens.size80), + modifier = Modifier + .size(TangemTheme.dimens.size80) + .testTag(ResetCardScreenTestTags.ATTENTION_IMAGE), ) } @@ -92,6 +97,7 @@ private fun Subtitle() { text = stringResourceSafe(id = R.string.common_attention), style = TangemTheme.typography.h3, color = TangemTheme.colors.text.primary1, + modifier = Modifier.testTag(ResetCardScreenTestTags.SUBTITLE), ) } @@ -101,6 +107,7 @@ private fun Description(text: TextReference) { text = text.resolveReference(), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, + modifier = Modifier.testTag(ResetCardScreenTestTags.DESCRIPTION), ) } @@ -135,7 +142,11 @@ private fun ConditionCheckBox(checkedState: Boolean, onCheckedChange: (Boolean) .padding(vertical = TangemTheme.dimens.size16), horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing16), ) { - IconToggleButton(checked = checkedState, onCheckedChange = onCheckedChange) { + IconToggleButton( + checked = checkedState, + onCheckedChange = onCheckedChange, + modifier = Modifier.testTag(ResetCardScreenTestTags.CHECKBOX), + ) { AnimatedContent(targetState = checkedState, label = "Update checked state") { checked -> Icon( painter = painterResource( @@ -159,6 +170,7 @@ private fun ConditionCheckBox(checkedState: Boolean, onCheckedChange: (Boolean) text = description.resolveReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.secondary, + modifier = Modifier.testTag(ResetCardScreenTestTags.CHECKBOX_TEXT), ) } } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt index 2d0af3ca0b..6cab06fac6 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt @@ -18,6 +18,7 @@ import com.tangem.domain.wallets.legacy.asLockable import com.tangem.domain.wallets.usecase.DeleteWalletUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.tap.common.analytics.events.Settings import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.common.extensions.onUserWalletSelected @@ -50,6 +51,7 @@ internal class ResetCardModel @Inject constructor( private val userWalletsListManager: UserWalletsListManager, private val analyticsEventHandler: AnalyticsEventHandler, private val cardSettingsInteractor: CardSettingsInteractor, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -259,16 +261,20 @@ internal class ResetCardModel @Inject constructor( private fun finishFullReset() { cardSettingsInteractor.clear() - val newSelectedWallet = userWalletsListManager.selectedUserWalletSync + val newSelectedWallet = getSelectedWalletSyncUseCase.invoke().getOrNull() if (newSelectedWallet != null) { store.dispatchNavigationAction { popTo() } } else { - val isLocked = runCatching { userWalletsListManager.asLockable()?.isLockedSync }.isSuccess - if (isLocked && userWalletsListManager.hasUserWallets) { - store.dispatchNavigationAction { popTo() } - } else { + if (hotWalletFeatureToggles.isHotWalletEnabled) { store.dispatchNavigationAction { replaceAll(AppRoute.Home()) } + } else { + val isLocked = runCatching { userWalletsListManager.asLockable()?.isLockedSync }.isSuccess + if (isLocked && userWalletsListManager.hasUserWallets) { + store.dispatchNavigationAction { popTo() } + } else { + store.dispatchNavigationAction { replaceAll(AppRoute.Home()) } + } } } } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/DefaultWalletConnectComponent.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/DefaultWalletConnectComponent.kt deleted file mode 100644 index 0974e0db1b..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/DefaultWalletConnectComponent.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier -import com.arkivanov.essenty.lifecycle.subscribe -import com.tangem.common.routing.AppRouter -import com.tangem.core.analytics.Analytics -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.tap.common.analytics.events.WalletConnect -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectState -import com.tangem.tap.features.details.ui.walletconnect.api.WalletConnectComponent -import com.tangem.tap.store -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import org.rekotlin.StoreSubscriber - -@Suppress("UnusedPrivateMember") -internal class DefaultWalletConnectComponent @AssistedInject constructor( - @Assisted appComponentContext: AppComponentContext, - @Assisted params: WalletConnectComponent.Params, -) : WalletConnectComponent, AppComponentContext by appComponentContext, StoreSubscriber { - - private val model: WalletConnectModel = getOrCreateModel(params) - - private var screenState: MutableState = - mutableStateOf(model.updateState(store.state.walletConnectState)) - - init { - lifecycle.subscribe( - onCreate = { - Analytics.send(WalletConnect.ScreenOpened()) - }, - onStart = { - store.subscribe(this) { state -> - state.skipRepeats { oldState, newState -> - oldState.walletConnectState == newState.walletConnectState - }.select { it.walletConnectState } - } - }, - onStop = { - store.unsubscribe(this) - }, - ) - } - - override fun newState(state: WalletConnectState) { - screenState.value = model.updateState(state) - } - - @Composable - override fun Content(modifier: Modifier) { - WalletConnectScreen( - modifier = modifier, - state = screenState.value, - onBackClick = { - store.dispatchNavigationAction(AppRouter::pop) - }, - ) - } - - @AssistedFactory - interface Factory : WalletConnectComponent.Factory { - override fun create( - context: AppComponentContext, - params: WalletConnectComponent.Params, - ): DefaultWalletConnectComponent - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectModel.kt deleted file mode 100644 index a231426ebb..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectModel.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect - -import androidx.compose.runtime.Stable -import arrow.core.getOrElse -import com.tangem.core.decompose.di.ModelScoped -import com.tangem.core.decompose.model.Model -import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.domain.qrscanning.models.QrResultSource -import com.tangem.domain.qrscanning.models.SourceType -import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectState -import com.tangem.tap.features.details.ui.walletconnect.api.WalletConnectComponent -import com.tangem.tap.store -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import timber.log.Timber -import javax.inject.Inject - -@Stable -@ModelScoped -internal class WalletConnectModel @Inject constructor( - override val dispatchers: CoroutineDispatcherProvider, - private val listenToQrScanningUseCase: ListenToQrScanningUseCase, - paramsContainer: ParamsContainer, -) : Model() { - - private val params = paramsContainer.require() - - init { - modelScope.launch { - listenToQrScanningUseCase.listen(SourceType.WALLET_CONNECT) - .getOrElse { emptyFlow() } - .map { result -> - val source = when (result.resultSource) { - QrResultSource.CLIPBOARD -> WalletConnectAction.OpenSession.SourceType.CLIPBOARD - QrResultSource.CAMERA, - QrResultSource.GALLERY, - -> WalletConnectAction.OpenSession.SourceType.QR - } - WalletConnectAction.OpenSession( - wcUri = result.qrCode, - source = source, - userWalletId = params.userWalletId, - ) - } - .collect { store.dispatch(it) } - } - } - - fun updateState(state: WalletConnectState): WalletConnectScreenState { - Timber.d("WC2 Sessions: ${state.wc2Sessions}") - val sessions = state.wc2Sessions - return WalletConnectScreenState( - sessions.toImmutableList(), - isLoading = state.loading, - onRemoveSession = { sessionUri -> onRemoveSession(sessionUri, sessions) }, - onAddSession = { - store.dispatch(WalletConnectAction.StartWalletConnect) - }, - ) - } - - private fun onRemoveSession(sessionUri: String, wc2sessions: List) { - wc2sessions.firstOrNull { it.sessionId == sessionUri }?.let { - store.dispatch(WalletConnectAction.DisconnectSession(sessionUri)) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreen.kt deleted file mode 100644 index 252fc23b51..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreen.kt +++ /dev/null @@ -1,169 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -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.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.tangem.core.analytics.Analytics -import com.tangem.core.ui.components.progressbar.TangemLinearProgressIndicator -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.tap.common.analytics.events.Settings -import com.tangem.tap.features.details.ui.common.SettingsScreensScaffold -import com.tangem.wallet.R -import kotlinx.collections.immutable.persistentListOf - -@Composable -internal fun WalletConnectScreen( - state: WalletConnectScreenState, - onBackClick: () -> Unit, - modifier: Modifier = Modifier, -) { - SettingsScreensScaffold( - modifier = modifier, - content = { - if (state.sessions.isEmpty()) { - EmptyScreen(state) - } else { - WalletConnectSessions(state) - } - }, - fab = { - if (!state.isLoading) { - AddSessionFab( - onAddSession = { - Analytics.send(Settings.ButtonStartWalletConnectSession()) - state.onAddSession() - }, - ) - } - }, - titleRes = R.string.wallet_connect_title, - onBackClick = onBackClick, - ) -} - -@Composable -private fun AddSessionFab(onAddSession: () -> Unit, modifier: Modifier = Modifier) { - FloatingActionButton( - onClick = onAddSession, - containerColor = TangemTheme.colors.button.primary, - contentColor = TangemTheme.colors.icon.primary2, - shape = RoundedCornerShape(16.dp), - modifier = modifier, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_plus_24), - contentDescription = "", - ) - } -} - -@Composable -private fun EmptyScreen(state: WalletConnectScreenState) { - if (state.isLoading) { - TangemLinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - color = TangemTheme.colors.icon.accent, - ) - } - Column( - modifier = Modifier - .fillMaxSize() - .padding(bottom = 64.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - painter = painterResource(id = R.drawable.ic_wallet_connect_24), - contentDescription = "", - colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive), - contentScale = ContentScale.FillWidth, - modifier = Modifier.width(width = 100.dp), - ) - Spacer(modifier = Modifier.size(24.dp)) - Text( - text = stringResourceSafe(id = R.string.wallet_connect_subtitle), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.tertiary, - ) - } -} - -@Composable -private fun WalletConnectSessions(state: WalletConnectScreenState) { - if (state.isLoading) { - TangemLinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(2.dp), - color = TangemTheme.colors.icon.accent, - ) - } else { - Spacer(modifier = Modifier.height(2.dp)) - } - - LazyColumn( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - items(state.sessions) { session -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = session.description, - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, - modifier = Modifier.weight(1f), - ) - IconButton( - onClick = { - Analytics.send(Settings.ButtonStopWalletConnectSession()) - state.onRemoveSession(session.sessionId) - }, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_cross_rounded_24), - contentDescription = "", - tint = TangemTheme.colors.icon.warning, - ) - } - } - } - } -} - -@Composable -@Preview -private fun WalletConnectScreenPreview() { - WalletConnectScreen( - state = WalletConnectScreenState( - sessions = persistentListOf( - WcSessionForScreen( - description = "session from some dApp", - sessionId = "", - ), - ), - isLoading = true, - ), - {}, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreenState.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreenState.kt deleted file mode 100644 index 0a3d429cd6..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreenState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect - -import kotlinx.collections.immutable.ImmutableList - -internal data class WalletConnectScreenState( - val sessions: ImmutableList, - val isLoading: Boolean = false, - val onRemoveSession: (String) -> Unit = {}, - val onAddSession: () -> Unit = {}, -) - -data class WcSessionForScreen( - val description: String, - val sessionId: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/api/WalletConnectComponent.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/api/WalletConnectComponent.kt deleted file mode 100644 index 2c97e35722..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/api/WalletConnectComponent.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.api - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.models.wallet.UserWalletId - -interface WalletConnectComponent : ComposableContentComponent { - data class Params(val userWalletId: UserWalletId) - interface Factory : ComponentFactory -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/di/WalletConnectFeatureModule.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/di/WalletConnectFeatureModule.kt deleted file mode 100644 index 20a85d18be..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/di/WalletConnectFeatureModule.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.di - -import com.tangem.core.decompose.model.Model -import com.tangem.tap.features.details.ui.walletconnect.DefaultWalletConnectComponent -import com.tangem.tap.features.details.ui.walletconnect.WalletConnectModel -import com.tangem.tap.features.details.ui.walletconnect.api.WalletConnectComponent -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 WalletConnectFeatureModule { - - @Binds - fun bindFactory(impl: DefaultWalletConnectComponent.Factory): WalletConnectComponent.Factory - - @Binds - @IntoMap - @ClassKey(WalletConnectModel::class) - fun bindModel(model: WalletConnectModel): Model -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/BnbTransactionDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/BnbTransactionDialog.kt deleted file mode 100644 index aee88834e4..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/BnbTransactionDialog.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.features.details.redux.walletconnect.BinanceMessageData -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.store -import com.tangem.wallet.R - -object BnbTransactionDialog { - fun create(preparedData: WcPreparedRequest.BnbTransaction, context: Context): AlertDialog { - val data = preparedData.preparedRequestData.data - val message = when (data) { - is BinanceMessageData.Trade -> data.tradeData.map { - context.getString( - R.string.wallet_connect_bnb_trade_order_message, - it.symbol, - it.price, - it.quantity, - it.amount, - ) - }.joinToString(separator = "\n\n") - is BinanceMessageData.Transfer -> context.getString( - R.string.wallet_connect_bnb_transaction_message, - data.address, - data.outputAddress, - data.amount, - ) - } - - val fullMessage = context.getString( - R.string.wallet_connect_bnb_sign_message, - preparedData.preparedRequestData.dAppName, - message, - ) - val positiveButtonTitle = context.getText(R.string.common_sign) - - return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply { - setTitle(context.getString(R.string.wallet_connect_title)) - setMessage(fullMessage) - setPositiveButton(positiveButtonTitle) { _, _ -> - store.dispatch(WalletConnectAction.PerformRequestedAction(preparedData)) - } - setNegativeButton(context.getText(R.string.common_reject)) { _, _ -> - store.dispatch(WalletConnectAction.RejectRequest(preparedData.topic, preparedData.requestId)) - } - setOnDismissListener { - store.dispatch(GlobalAction.HideDialog) - } - }.create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/PersonalSignDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/PersonalSignDialog.kt deleted file mode 100644 index 4403b6d4a6..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/PersonalSignDialog.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.store -import com.tangem.wallet.R - -object PersonalSignDialog { - fun create(preparedData: WcPreparedRequest.EthSign, context: Context): AlertDialog { - val data = preparedData.preparedRequestData.dialogData - val message = context.getString(R.string.wallet_connect_alert_sign_message, data.message) - return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply { - setTitle(context.getString(R.string.wallet_connect_title)) - setMessage(message) - setPositiveButton(context.getText(R.string.common_sign)) { _, _ -> - store.dispatch(WalletConnectAction.PerformRequestedAction(preparedData)) - } - setNegativeButton(context.getText(R.string.common_reject)) { _, _ -> - store.dispatch(WalletConnectAction.RejectRequest(data.topic, data.id)) - } - setOnDismissListener { - store.dispatch(GlobalAction.HideDialog) - } - }.create() - } -} - -data class PersonalSignDialogData( - val dAppName: String, - val message: String, - val topic: String, - val id: Long, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SessionProposalDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SessionProposalDialog.kt deleted file mode 100644 index 1dd8aff5a7..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SessionProposalDialog.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectEvents -import com.tangem.tap.store -import com.tangem.wallet.R - -object SessionProposalDialog { - fun create( - sessionProposal: WalletConnectEvents.SessionProposal, - networks: String, - context: Context, - onApprove: () -> Unit, - onReject: () -> Unit, - ): AlertDialog { - val message = context.getString( - R.string.wallet_connect_request_session_start, - sessionProposal.name, - networks, - sessionProposal.url, - ) - return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply { - setTitle(context.getString(R.string.wallet_connect_title)) - setMessage(message) - setPositiveButton(context.getText(R.string.common_start)) { _, _ -> - store.dispatch(GlobalAction.HideDialog) - onApprove() - } - setNegativeButton(context.getText(R.string.common_reject)) { _, _ -> - store.dispatch(GlobalAction.HideDialog) - onReject() - } - setOnCancelListener { - store.dispatch(GlobalAction.HideDialog) - onReject() - } - }.create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionDialog.kt deleted file mode 100644 index e459b6065c..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionDialog.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.store -import com.tangem.wallet.R - -internal object SignTransactionDialog { - - fun create(preparedData: WcPreparedRequest.SolanaSignTransaction, context: Context): AlertDialog { - val signMessage = context.getString(R.string.wallet_connect_alert_sign_message, "") - val message = "${preparedData.preparedRequestData.dAppName}\n$signMessage" - return AlertDialog.Builder(context).apply { - setTitle(context.getString(R.string.wallet_connect_title)) - setMessage(message) - setPositiveButton(context.getText(R.string.common_sign)) { _, _ -> - store.dispatch(WalletConnectAction.PerformRequestedAction(preparedData)) - } - setNegativeButton(context.getText(R.string.common_reject)) { _, _ -> - store.dispatch(WalletConnectAction.RejectRequest(preparedData.topic, preparedData.requestId)) - } - setOnDismissListener { - store.dispatch(GlobalAction.HideDialog) - } - }.create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionsDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionsDialog.kt deleted file mode 100644 index 579940d0ce..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionsDialog.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.store -import com.tangem.wallet.R - -internal object SignTransactionsDialog { - - fun create(preparedData: WcPreparedRequest.SolanaSignMultipleTransactions, context: Context): AlertDialog { - val signMessage = context.getString(R.string.wallet_connect_alert_sign_message, "") - val message = "${preparedData.preparedRequestData.dAppName}\n$signMessage" - return AlertDialog.Builder(context).apply { - setTitle(context.getString(R.string.wallet_connect_title)) - setMessage(message) - setPositiveButton(context.getText(R.string.common_sign)) { _, _ -> - store.dispatch(WalletConnectAction.PerformRequestedAction(preparedData)) - } - setNegativeButton(context.getText(R.string.common_reject)) { _, _ -> - store.dispatch(WalletConnectAction.RejectRequest(preparedData.topic, preparedData.requestId)) - } - setOnDismissListener { - store.dispatch(GlobalAction.HideDialog) - } - }.create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/TransactionDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/TransactionDialog.kt deleted file mode 100644 index 4fd49641fb..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/TransactionDialog.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.features.details.redux.walletconnect.WcEthTransactionType -import com.tangem.tap.store -import com.tangem.wallet.R - -object TransactionDialog { - fun create(preparedData: WcPreparedRequest.EthTransaction, context: Context): AlertDialog { - val data = preparedData.preparedRequestData.dialogData - val message = context.getString( - R.string.wallet_connect_create_tx_message, - data.dAppName, - data.dAppUrl, - data.amount, - data.feeAmount, - data.totalAmount, - data.balance, - ) - - val positiveButtonTitle = when (data.type) { - WcEthTransactionType.EthSignTransaction -> context.getText(R.string.common_sign) - WcEthTransactionType.EthSendTransaction -> context.getText(R.string.common_sign_and_send) - } - return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply { - setTitle(context.getString(R.string.wallet_connect_title)) - setMessage(message) - setPositiveButton(positiveButtonTitle) { _, _ -> - if (data.isEnoughFundsToSend) { - store.dispatch(WalletConnectAction.PerformRequestedAction(preparedData)) - } else { - store.dispatch(WalletConnectAction.RejectRequest(data.topic, data.id)) - } - } - setNegativeButton(context.getText(R.string.common_reject)) { _, _ -> - store.dispatch(WalletConnectAction.RejectRequest(data.topic, data.id)) - } - setOnDismissListener { - store.dispatch(GlobalAction.HideDialog) - } - }.create() - } -} - -data class TransactionRequestDialogData( - val dAppName: String, - val dAppUrl: String, - val amount: String, - val feeAmount: String, - val totalAmount: String, - val balance: String, - val isEnoughFundsToSend: Boolean, - val topic: String, - val id: Long, - val type: WcEthTransactionType, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/hot/TangemHotSDKProxy.kt b/app/src/main/java/com/tangem/tap/features/hot/TangemHotSDKProxy.kt index af00ff8af8..237bc5ebd3 100644 --- a/app/src/main/java/com/tangem/tap/features/hot/TangemHotSDKProxy.kt +++ b/app/src/main/java/com/tangem/tap/features/hot/TangemHotSDKProxy.kt @@ -32,6 +32,13 @@ class TangemHotSDKProxy @Inject constructor() : TangemHotSdk { override suspend fun exportBackup(unlockHotWallet: UnlockHotWallet): ByteArray = callSdk { exportBackup(unlockHotWallet) } + override suspend fun clearUnlockContext(hotWalletId: HotWalletId) { + callSdk { clearUnlockContext(hotWalletId) } + } + + override suspend fun getContextUnlock(unlockHotWallet: UnlockHotWallet): UnlockHotWallet = + callSdk { getContextUnlock(unlockHotWallet) } + override suspend fun delete(id: HotWalletId) = callSdk { delete(id) } override suspend fun changeAuth(unlockHotWallet: UnlockHotWallet, auth: HotAuth): HotWalletId = diff --git a/app/src/main/java/com/tangem/tap/features/intentHandler/IntentHandler.kt b/app/src/main/java/com/tangem/tap/features/intentHandler/IntentHandler.kt index c420052753..7458f759ba 100644 --- a/app/src/main/java/com/tangem/tap/features/intentHandler/IntentHandler.kt +++ b/app/src/main/java/com/tangem/tap/features/intentHandler/IntentHandler.kt @@ -7,5 +7,5 @@ import android.content.Intent */ interface IntentHandler { - fun handleIntent(intent: Intent?, isFromForeground: Boolean): Boolean + fun handleIntent(intent: Intent?): Boolean } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/intentHandler/IntentProcessor.kt b/app/src/main/java/com/tangem/tap/features/intentHandler/IntentProcessor.kt deleted file mode 100644 index 6883c38214..0000000000 --- a/app/src/main/java/com/tangem/tap/features/intentHandler/IntentProcessor.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.tangem.tap.features.intentHandler - -import android.content.Intent -import java.util.concurrent.CopyOnWriteArrayList - -/** -[REDACTED_AUTHOR] - */ -// TODO: fixme: close it with the combined interfaces IntentHandler and IntentHandlerHolder -class IntentProcessor { - - private val intentHandlers = CopyOnWriteArrayList() - - fun addHandler(handler: IntentHandler) { - intentHandlers.add(handler) - } - - fun removeAll() { - intentHandlers.clear() - } - - fun handleIntent(intent: Intent?, isFromForeground: Boolean, skipNavigationHandlers: Boolean = false) { - intentHandlers - .filterNot { handler -> skipNavigationHandlers && handler is AffectsNavigation } - .forEach { handler -> handler.handleIntent(intent, isFromForeground) } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/OnPushClickedIntentHandler.kt b/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/OnPushClickedIntentHandler.kt deleted file mode 100644 index e6b36001d8..0000000000 --- a/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/OnPushClickedIntentHandler.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.tap.features.intentHandler.handlers - -import android.content.Intent -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.tap.common.analytics.events.Push -import com.tangem.tap.features.intentHandler.IntentHandler - -internal class OnPushClickedIntentHandler(val analyticsEventHandler: AnalyticsEventHandler) : IntentHandler { - - override fun handleIntent(intent: Intent?, isFromForeground: Boolean): Boolean { - val fromPush = intent?.extras?.containsKey(OPENED_FROM_GCM_PUSH) ?: false - - return if (fromPush) { - analyticsEventHandler.send(Push.PushNotificationOpened) - true - } else { - false - } - } - - companion object { - const val OPENED_FROM_GCM_PUSH = "google.sent_time" // every bundle from FCM contains this key - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/WalletConnectLinkIntentHandler.kt b/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/WalletConnectLinkIntentHandler.kt deleted file mode 100644 index e715290a1e..0000000000 --- a/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/WalletConnectLinkIntentHandler.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.tangem.tap.features.intentHandler.handlers - -import android.content.Intent -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.extensions.removePrefixOrNull -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.features.intentHandler.AffectsNavigation -import com.tangem.tap.features.intentHandler.IntentHandler -import com.tangem.tap.store -import timber.log.Timber -import java.net.URLDecoder - -/** -[REDACTED_AUTHOR] - */ -class WalletConnectLinkIntentHandler : IntentHandler, AffectsNavigation { - - override fun handleIntent(intent: Intent?, isFromForeground: Boolean): Boolean { - val intentData = intent?.data ?: return false - val scheme = intent.scheme ?: return false - - val wcUri = when (scheme) { - WC_SCHEME -> intentData.toString() - TANGEM_SCHEME -> intentData.toString().removePrefixOrNull(TANGEM_WC_PREFIX) - else -> null - } - - return if (wcUri.isNullOrBlank()) { - false - } else { - val decodedWcUri = try { - URLDecoder.decode(wcUri, DEFAULT_CHARSET_NAME) - } catch (e: Exception) { - Timber.e(e) - return false - } - store.dispatchOnMain(WalletConnectAction.HandleDeepLink(decodedWcUri)) - true - } - } - - private companion object { - const val TANGEM_SCHEME = "tangem" - const val TANGEM_WC_PREFIX = "tangem://wc?uri=" - const val DEFAULT_CHARSET_NAME = "UTF-8" - const val WC_SCHEME = "wc" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index 643710e4c2..1734b45276 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -24,11 +24,10 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.ListenToFlipsUseCase import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase import com.tangem.domain.common.LogConfig +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.notifications.GetApplicationIdUseCase import com.tangem.domain.notifications.SendPushTokenUseCase import com.tangem.domain.notifications.models.ApplicationId -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles -import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.onramp.FetchHotCryptoUseCase import com.tangem.domain.promo.GetStoryContentUseCase import com.tangem.domain.promo.models.StoryContentIds @@ -37,18 +36,16 @@ import com.tangem.domain.settings.DeleteDeprecatedLogsUseCase import com.tangem.domain.settings.IncrementAppLaunchCounterUseCase import com.tangem.domain.settings.usercountry.FetchUserCountryUseCase import com.tangem.domain.staking.FetchStakingTokensUseCase -import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.usecase.AssociateWalletsWithApplicationIdUseCase import com.tangem.domain.wallets.usecase.GetSavedWalletsCountUseCase +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.UpdateRemoteWalletsInfoUseCase import com.tangem.feature.swap.analytics.StoriesEvents import com.tangem.tap.common.extensions.setContext -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.features.onboarding.products.wallet.redux.BackupDialog import com.tangem.tap.network.exchangeServices.ExchangeService import com.tangem.tap.network.exchangeServices.moonpay.MoonPayService import com.tangem.tap.proxy.AppStateHolder -import com.tangem.tap.store +import com.tangem.tap.routing.configurator.AppRouterConfig import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.wallet.BuildConfig import dagger.hilt.android.lifecycle.HiltViewModel @@ -69,7 +66,6 @@ internal class MainViewModel @Inject constructor( deleteDeprecatedLogsUseCase: DeleteDeprecatedLogsUseCase, private val incrementAppLaunchCounterUseCase: IncrementAppLaunchCounterUseCase, private val blockchainSDKFactory: BlockchainSDKFactory, - private val userWalletsListManager: UserWalletsListManager, private val dispatchers: CoroutineDispatcherProvider, private val fetchStakingTokensUseCase: FetchStakingTokensUseCase, private val fetchUserCountryUseCase: FetchUserCountryUseCase, @@ -79,8 +75,6 @@ internal class MainViewModel @Inject constructor( private val getStoryContentUseCase: GetStoryContentUseCase, private val imagePreloader: ImagePreloader, private val fetchHotCryptoUseCase: FetchHotCryptoUseCase, - private val onboardingRepository: OnboardingRepository, - private val notificationsToggles: NotificationsFeatureToggles, private val getApplicationIdUseCase: GetApplicationIdUseCase, private val subscribeOnWalletsUseCase: GetSavedWalletsCountUseCase, private val associateWalletsWithApplicationIdUseCase: AssociateWalletsWithApplicationIdUseCase, @@ -90,6 +84,8 @@ internal class MainViewModel @Inject constructor( private val multiQuoteUpdater: MultiQuoteUpdater, private val appStateHolder: AppStateHolder, private val environmentConfigStorage: EnvironmentConfigStorage, + private val getSelectedWalletUseCase: GetSelectedWalletUseCase, + private val appRouterConfig: AppRouterConfig, getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, ) : ViewModel() { @@ -140,13 +136,6 @@ internal class MainViewModel @Inject constructor( multiQuoteUpdater.unsubscribe() } - fun checkForUnfinishedBackup() { - viewModelScope.launch(dispatchers.main) { - val onboardingScanResponse = onboardingRepository.getUnfinishedFinalizeOnboarding() ?: return@launch - store.dispatch(GlobalAction.ShowDialog(BackupDialog.UnfinishedBackupFound(onboardingScanResponse))) - } - } - /** Loading the resources needed to run the application */ private fun loadApplicationResources() { viewModelScope.launch { @@ -160,6 +149,9 @@ internal class MainViewModel @Inject constructor( prepareSelectedWalletFeedback() + // await while initial route stack is initialized + appRouterConfig.isInitialized.first { it } + isSplashScreenShown = false } } @@ -185,13 +177,16 @@ internal class MainViewModel @Inject constructor( } private fun prepareSelectedWalletFeedback() { - userWalletsListManager.selectedUserWallet - .distinctUntilChanged() - .onEach { userWallet -> - Analytics.setContext(userWallet) + getSelectedWalletUseCase.invoke() + .mapLeft { emptyFlow() } + .onRight { + it.distinctUntilChanged() + .onEach { userWallet -> + Analytics.setContext(userWallet) + } + .flowOn(dispatchers.io) + .launchIn(viewModelScope) } - .flowOn(dispatchers.io) - .launchIn(viewModelScope) } private suspend fun fetchStakingTokens() { @@ -214,7 +209,7 @@ internal class MainViewModel @Inject constructor( apiKey = environmentConfig.moonPayApiKey, secretKey = environmentConfig.moonPayApiSecretKey, logEnabled = LogConfig.network.moonPayService, - userWalletProvider = { userWalletsListManager.selectedUserWalletSync }, + userWalletProvider = { getSelectedWalletUseCase.sync().getOrNull() }, ) } @@ -411,18 +406,20 @@ internal class MainViewModel @Inject constructor( } private suspend fun initPushNotifications() { - if (notificationsToggles.isNotificationsEnabled) { - getApplicationIdUseCase().onRight { applicationId -> + getApplicationIdUseCase() + .onRight { applicationId -> sendPushTokenUseCase(applicationId = applicationId) associateWalletsWithApplicationId(applicationId = applicationId) updateRemoteWalletsInfoUseCase(applicationId = applicationId) - }.onLeft { Timber.e(it.toString()) } - } + } + .onLeft(Timber::e) } private fun associateWalletsWithApplicationId(applicationId: ApplicationId) { - subscribeOnWalletsUseCase().onEach { wallets -> - associateWalletsWithApplicationIdUseCase(applicationId, wallets) - }.launchIn(viewModelScope) + subscribeOnWalletsUseCase() + .onEach { wallets -> + associateWalletsWithApplicationIdUseCase(applicationId, wallets) + } + .launchIn(viewModelScope) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/BackupMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/BackupMiddleware.kt index c3567c8f3a..2433af1e95 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/BackupMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/BackupMiddleware.kt @@ -2,12 +2,15 @@ package com.tangem.tap.features.onboarding.products.wallet.redux import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.Analytics -import com.tangem.tap.* -import com.tangem.tap.common.analytics.events.Onboarding.* -import com.tangem.tap.common.extensions.* +import com.tangem.tap.backupService +import com.tangem.tap.common.analytics.events.Onboarding.Finished +import com.tangem.tap.common.extensions.dispatchNavigationAction +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.redux.AppState import com.tangem.tap.features.demo.DemoHelper +import com.tangem.tap.mainScope import com.tangem.tap.proxy.redux.DaggerGraphState +import com.tangem.tap.store import kotlinx.coroutines.launch import org.rekotlin.Middleware @@ -24,7 +27,7 @@ class BackupMiddleware { @Suppress("LongMethod", "ComplexMethod", "MagicNumber") private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) { - if (DemoHelper.tryHandle(appState, action)) return + if (DemoHelper.tryHandle(appState)) return when (action) { is BackupAction.DiscardBackup -> { @@ -46,7 +49,7 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) is BackupAction.ResumeFoundUnfinishedBackup -> { if (action.unfinishedBackupScanResponse != null) { store.dispatchNavigationAction { - push( + replaceAll( AppRoute.Onboarding( scanResponse = action.unfinishedBackupScanResponse, mode = AppRoute.Onboarding.Mode.ContinueFinalize, diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/WalletActivationErrorDialog.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/WalletActivationErrorDialog.kt index d5851ab0ed..880f9177a0 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/WalletActivationErrorDialog.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/WalletActivationErrorDialog.kt @@ -30,7 +30,7 @@ object WalletActivationErrorDialog { val scanResponse = store.state.globalState.scanResponse ?: error("ScanResponse must be not null") - val cardInfo = store.inject(DaggerGraphState::getCardInfoUseCase).invoke(scanResponse).getOrNull() + val cardInfo = store.inject(DaggerGraphState::getWalletMetaInfoUseCase).invoke(scanResponse).getOrNull() ?: error("CardInfo must be not null") scope.launch { diff --git a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt index d2f1baf710..2aa7a3ef24 100644 --- a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt @@ -30,7 +30,7 @@ object TradeCryptoMiddleware { } private fun handle(state: () -> AppState?, action: TradeCryptoAction) { - if (DemoHelper.tryHandle(state, action)) return + if (DemoHelper.tryHandle(state)) return when (action) { is TradeCryptoAction.FinishSelling -> openReceiptUrl(action.transactionId) diff --git a/app/src/main/java/com/tangem/tap/features/welcome/component/WelcomeComponent.kt b/app/src/main/java/com/tangem/tap/features/welcome/component/WelcomeComponent.kt index 215560a1ad..120b0b5484 100644 --- a/app/src/main/java/com/tangem/tap/features/welcome/component/WelcomeComponent.kt +++ b/app/src/main/java/com/tangem/tap/features/welcome/component/WelcomeComponent.kt @@ -1,7 +1,6 @@ package com.tangem.tap.features.welcome.component import com.tangem.common.routing.entity.InitScreenLaunchMode -import com.tangem.common.routing.entity.SerializableIntent import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent @@ -9,7 +8,6 @@ interface WelcomeComponent : ComposableContentComponent { data class Params( val launchMode: InitScreenLaunchMode, - val intent: SerializableIntent?, ) interface Factory : ComponentFactory diff --git a/app/src/main/java/com/tangem/tap/features/welcome/model/WelcomeModel.kt b/app/src/main/java/com/tangem/tap/features/welcome/model/WelcomeModel.kt index 5965f011f3..ae5afc8d33 100644 --- a/app/src/main/java/com/tangem/tap/features/welcome/model/WelcomeModel.kt +++ b/app/src/main/java/com/tangem/tap/features/welcome/model/WelcomeModel.kt @@ -47,7 +47,7 @@ internal class WelcomeModel @Inject constructor( val welcomeAction = when (params.launchMode) { is InitScreenLaunchMode.WithCardScan -> WelcomeAction.ProceedWithCard - is InitScreenLaunchMode.Standard -> WelcomeAction.ProceedWithBiometrics(params.intent?.toIntent()) + is InitScreenLaunchMode.Standard -> WelcomeAction.ProceedWithBiometrics } store.dispatch(welcomeAction) @@ -55,7 +55,7 @@ internal class WelcomeModel @Inject constructor( private fun unlockWallets() { Analytics.send(SignIn.ButtonBiometricSignIn()) - store.dispatch(WelcomeAction.ProceedWithBiometrics()) + store.dispatch(WelcomeAction.ProceedWithBiometrics) } private fun scanCard() { diff --git a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeAction.kt b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeAction.kt index bd88693c6b..78fb0451e0 100644 --- a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeAction.kt +++ b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeAction.kt @@ -1,12 +1,11 @@ package com.tangem.tap.features.welcome.redux -import android.content.Intent import com.tangem.common.core.TangemError import org.rekotlin.Action internal sealed interface WelcomeAction : Action { - data class ProceedWithBiometrics(val afterUnlockIntent: Intent? = null) : WelcomeAction { + data object ProceedWithBiometrics : WelcomeAction { object Success : WelcomeAction data class Error(val error: TangemError) : WelcomeAction } @@ -17,8 +16,6 @@ internal sealed interface WelcomeAction : Action { data class ChangeProgress(val showProgress: Boolean) : WelcomeAction } - data class ProceedWithIntent(val intent: Intent) : WelcomeAction - object CloseError : WelcomeAction object ClearUserWallets : WelcomeAction diff --git a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt index 8a842072d8..af60602c2b 100644 --- a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt @@ -1,6 +1,5 @@ package com.tangem.tap.features.welcome.redux -import android.content.Intent import com.tangem.common.core.TangemSdkError import com.tangem.common.doOnFailure import com.tangem.common.doOnResult @@ -20,7 +19,6 @@ import com.tangem.domain.wallets.legacy.unlockIfLockable import com.tangem.tap.* import com.tangem.tap.common.extensions.* import com.tangem.tap.common.redux.AppState -import com.tangem.tap.features.intentHandler.handlers.WalletConnectLinkIntentHandler import com.tangem.tap.proxy.redux.DaggerGraphState import kotlinx.coroutines.launch import org.rekotlin.Middleware @@ -32,52 +30,25 @@ internal class WelcomeMiddleware { { action -> val appState = appStateProvider() if (action is WelcomeAction && appState != null) { - handleAction(action, appState.welcomeState) + handleAction(action) } next(action) } } } - private fun handleAction(action: WelcomeAction, state: WelcomeState) { + private fun handleAction(action: WelcomeAction) { mainScope.launch { when (action) { - is WelcomeAction.ProceedWithIntent -> proceedWithIntent(action.intent) - is WelcomeAction.ProceedWithBiometrics -> proceedWithBiometrics( - afterUnlockIntent = action.afterUnlockIntent ?: state.intent, - ) - is WelcomeAction.ProceedWithCard -> proceedWithCard(afterScanIntent = state.intent) + is WelcomeAction.ProceedWithBiometrics -> proceedWithBiometrics() + is WelcomeAction.ProceedWithCard -> proceedWithCard() is WelcomeAction.ClearUserWallets -> disableUserWalletsSaving() else -> Unit } } } - private suspend fun proceedWithIntent(initialIntent: Intent) { - Timber.d( - """ - Proceeding with intent - |- Intent: $initialIntent - """.trimIndent(), - ) - - val hasUncompletedBackup = backupService.hasIncompletedBackup - - if (!hasUncompletedBackup) { - store.dispatchWithMain(WelcomeAction.ProceedWithBiometrics(initialIntent)) - } else { - store.dispatchWithMain(WelcomeAction.ProceedWithCard) - } - } - - private suspend fun proceedWithBiometrics(afterUnlockIntent: Intent?) { - Timber.d( - """ - Proceeding with biometry - |- Intent: $afterUnlockIntent - """.trimIndent(), - ) - + private suspend fun proceedWithBiometrics() { val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager) userWalletsListManager.unlockIfLockable(type = UnlockType.ANY) .doOnFailure { error -> @@ -93,21 +64,10 @@ internal class WelcomeMiddleware { store.dispatchNavigationAction { replaceAll(AppRoute.Wallet) } store.dispatchWithMain(WelcomeAction.ProceedWithBiometrics.Success) store.onUserWalletSelected(userWallet = selectedUserWallet) - - afterUnlockIntent?.let { - WalletConnectLinkIntentHandler().handleIntent(it, false) - } } } - private suspend fun proceedWithCard(afterScanIntent: Intent?) { - Timber.d( - """ - Proceeding with card - |- Intent: $afterScanIntent - """.trimIndent(), - ) - + private suspend fun proceedWithCard() { scanCardInternal { scanResponse -> val userWalletBuilder = store.inject(DaggerGraphState::coldUserWalletBuilderFactory).create(scanResponse) @@ -125,10 +85,6 @@ internal class WelcomeMiddleware { store.dispatchNavigationAction { replaceAll(AppRoute.Wallet) } store.dispatchWithMain(WelcomeAction.ProceedWithCard.Success) store.onUserWalletSelected(userWallet = userWallet) - - afterScanIntent?.let { - WalletConnectLinkIntentHandler().handleIntent(it, false) - } } } } diff --git a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeReducer.kt b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeReducer.kt index 1f475f9a80..0d9645dea6 100644 --- a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeReducer.kt +++ b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeReducer.kt @@ -14,7 +14,6 @@ internal object WelcomeReducer { private fun internalReduce(action: WelcomeAction, state: WelcomeState): WelcomeState { return when (action) { - is WelcomeAction.ProceedWithIntent -> state.copy(intent = action.intent) is WelcomeAction.ProceedWithBiometrics -> state.copy(isUnlockWithBiometricsInProgress = true) is WelcomeAction.ProceedWithCard -> state.copy(isUnlockWithCardInProgress = true) is WelcomeAction.ProceedWithBiometrics.Error -> state.copy( diff --git a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeState.kt b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeState.kt index a8a55d3984..a4fce23f93 100644 --- a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeState.kt +++ b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeState.kt @@ -1,12 +1,10 @@ package com.tangem.tap.features.welcome.redux -import android.content.Intent import com.tangem.common.core.TangemError import org.rekotlin.StateType data class WelcomeState( val isUnlockWithBiometricsInProgress: Boolean = false, val isUnlockWithCardInProgress: Boolean = false, - val intent: Intent? = null, val error: TangemError? = null, ) : StateType \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt index f71c4160f9..97cb5f65e6 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt @@ -6,6 +6,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet import com.tangem.tap.common.extensions.inject import com.tangem.tap.domain.model.Currency import com.tangem.tap.proxy.redux.DaggerGraphState @@ -24,9 +25,7 @@ internal class CryptoCurrencyConverter( cryptoCurrencyFactory.createCoin( blockchain = value.blockchain, extraDerivationPath = value.derivationPath, - userWallet = requireNotNull( - store.inject(DaggerGraphState::generalUserWalletsListManager).selectedUserWalletSync, - ), + userWallet = getSelectedWallet(), ), ) is Currency.Token -> requireNotNull( @@ -34,9 +33,7 @@ internal class CryptoCurrencyConverter( sdkToken = value.token, blockchain = value.blockchain, extraDerivationPath = value.derivationPath, - userWallet = requireNotNull( - store.inject(DaggerGraphState::generalUserWalletsListManager).selectedUserWalletSync, - ), + userWallet = getSelectedWallet(), ), ) } @@ -63,4 +60,15 @@ internal class CryptoCurrencyConverter( ) } } + + fun getSelectedWallet(): UserWallet { + val userWalletListManager = store.inject(DaggerGraphState::generalUserWalletsListManager) + val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository) + val hotWalletFeatureToggles = store.inject(DaggerGraphState::hotWalletFeatureToggles) + return if (hotWalletFeatureToggles.isHotWalletEnabled) { + requireNotNull(userWalletsListRepository.selectedUserWallet.value) + } else { + requireNotNull(userWalletListManager.selectedUserWalletSync) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt b/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt index 767198d50e..d11526b9f4 100644 --- a/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt +++ b/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt @@ -5,7 +5,7 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.WalletManager import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.lib.crypto.UserWalletManager import com.tangem.lib.crypto.models.ProxyAmount import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -15,13 +15,13 @@ import java.math.BigDecimal class UserWalletManagerImpl( private val walletManagersFacade: WalletManagersFacade, - private val userWalletsListManager: UserWalletsListManager, + private val getSelectedWalletUseCase: GetSelectedWalletUseCase, private val dispatchers: CoroutineDispatcherProvider, ) : UserWalletManager { override fun getWalletId(): String { val selectedUserWallet = requireNotNull( - userWalletsListManager.selectedUserWalletSync, + getSelectedWalletUseCase.sync().getOrNull(), ) { "selectedUserWallet shouldn't be null" } return selectedUserWallet.walletId.stringValue } @@ -62,7 +62,7 @@ class UserWalletManagerImpl( @Throws(IllegalArgumentException::class) private suspend fun getActualWalletManager(blockchain: Blockchain, derivationPath: String?): WalletManager { val selectedUserWallet = requireNotNull( - userWalletsListManager.selectedUserWalletSync, + getSelectedWalletUseCase.sync().getOrNull(), ) { "userWallet or userWalletsListManager is null" } val walletManager = withContext(dispatchers.io) { walletManagersFacade.getOrCreateWalletManager( diff --git a/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt b/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt index 4d59c1f56e..677d7e5ebd 100644 --- a/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt +++ b/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt @@ -1,7 +1,7 @@ package com.tangem.tap.proxy.di import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.lib.crypto.UserWalletManager import com.tangem.tap.proxy.AppStateHolder import com.tangem.tap.proxy.UserWalletManagerImpl @@ -26,12 +26,12 @@ internal object ProxyModule { @Singleton fun provideUserWalletManager( walletManagersFacade: WalletManagersFacade, - userWalletsListManager: UserWalletsListManager, + getSelectedWalletUseCase: GetSelectedWalletUseCase, dispatchers: CoroutineDispatcherProvider, ): UserWalletManager { return UserWalletManagerImpl( walletManagersFacade = walletManagersFacade, - userWalletsListManager = userWalletsListManager, + getSelectedWalletUseCase = getSelectedWalletUseCase, dispatchers = dispatchers, ) } diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt index 86116d6849..ef0fab7352 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt @@ -2,14 +2,12 @@ package com.tangem.tap.proxy.redux import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor import org.rekotlin.Action sealed interface DaggerGraphAction : Action { data class SetActivityDependencies( val scanCardUseCase: ScanCardUseCase, - val walletConnectInteractor: WalletConnectInteractor, val cardSdkConfigRepository: CardSdkConfigRepository, ) : DaggerGraphAction } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt index 76802dd20d..6a2e43b96a 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt @@ -14,7 +14,6 @@ object DaggerGraphReducer { return when (action) { is DaggerGraphAction.SetActivityDependencies -> state.daggerGraphState.copy( scanCardUseCase = action.scanCardUseCase, - walletConnectInteractor = action.walletConnectInteractor, cardSdkConfigRepository = action.cardSdkConfigRepository, ) } diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt index e0b2794648..ef68891955 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt @@ -22,7 +22,7 @@ import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.core.wallets.UserWalletsListRepository -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase @@ -37,8 +37,6 @@ import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.hot.sdk.TangemHotSdk import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles -import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository -import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor import com.tangem.tap.proxy.AppStateHolder import org.rekotlin.StateType @@ -46,8 +44,6 @@ data class DaggerGraphState( val networkConnectionManager: NetworkConnectionManager? = null, val cardScanningFeatureToggles: CardScanningFeatureToggles? = null, val scanCardUseCase: ScanCardUseCase? = null, - val walletConnectRepository: LegacyWalletConnectRepository? = null, - val walletConnectInteractor: WalletConnectInteractor? = null, val scanCardProcessor: ScanCardProcessor? = null, val cardSdkConfigRepository: CardSdkConfigRepository? = null, val appCurrencyRepository: AppCurrencyRepository? = null, @@ -63,7 +59,7 @@ data class DaggerGraphState( val settingsRepository: SettingsRepository? = null, val blockchainSDKFactory: BlockchainSDKFactory? = null, val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase? = null, - val getCardInfoUseCase: GetCardInfoUseCase? = null, + val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase? = null, val issuersConfigStorage: IssuersConfigStorage? = null, val urlOpener: UrlOpener? = null, val shareManager: ShareManager? = null, diff --git a/app/src/main/java/com/tangem/tap/routing/component/RoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/RoutingComponent.kt index 9fdef856a9..22bb2b0b4a 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/RoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/RoutingComponent.kt @@ -3,6 +3,7 @@ package com.tangem.tap.routing.component import android.content.Intent import androidx.compose.runtime.Immutable import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.entity.InitScreenLaunchMode import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableContentComponent @@ -22,6 +23,10 @@ internal interface RoutingComponent : ComposableContentComponent { } interface Factory { - fun create(context: AppComponentContext, initialStack: List?): RoutingComponent + fun create( + context: AppComponentContext, + initialStack: List?, + launchMode: InitScreenLaunchMode, + ): RoutingComponent } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt index 86b7acd8d4..8030c44f97 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt @@ -9,6 +9,7 @@ import com.arkivanov.decompose.value.subscribe import com.arkivanov.essenty.lifecycle.subscribe import com.google.android.material.snackbar.Snackbar import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.entity.InitScreenLaunchMode import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext @@ -17,27 +18,36 @@ import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.card.repository.CardRepository +import com.tangem.domain.core.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.features.hotwallet.HotAccessCodeRequestComponent import com.tangem.features.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy import com.tangem.features.walletconnect.components.WcRoutingComponent import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.android.create import com.tangem.tap.common.SnackbarHandler +import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.features.hot.TangemHotSDKProxy +import com.tangem.tap.features.onboarding.products.wallet.redux.BackupDialog import com.tangem.tap.routing.RootContent import com.tangem.tap.routing.component.RoutingComponent import com.tangem.tap.routing.component.RoutingComponent.Child import com.tangem.tap.routing.configurator.AppRouterConfig import com.tangem.tap.routing.utils.ChildFactory import com.tangem.tap.routing.utils.DeepLinkFactory +import com.tangem.tap.store import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.launch @Suppress("LongParameterList") internal class DefaultRoutingComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted val initialStack: List?, + @Assisted val launchMode: InitScreenLaunchMode, private val childFactory: ChildFactory, private val appRouterConfig: AppRouterConfig, private val uiDependencies: UiDependencies, @@ -46,6 +56,9 @@ internal class DefaultRoutingComponent @AssistedInject constructor( private val tangemHotSDKProxy: TangemHotSDKProxy, private val hotAccessCodeRequestComponentFactory: HotAccessCodeRequestComponent.Factory, private val hotAccessCodeRequesterProxy: HotWalletPasswordRequesterProxy, + private val userWalletsListRepository: UserWalletsListRepository, + private val cardRepository: CardRepository, + private val onboardingRepository: OnboardingRepository, ) : RoutingComponent, AppComponentContext by context, SnackbarHandler { @@ -87,6 +100,42 @@ internal class DefaultRoutingComponent @AssistedInject constructor( } configureProxies() + initializeInitialNavigation() + } + + private fun initializeInitialNavigation() { + if (initialStack.isNullOrEmpty()) { + componentScope.launch { + val initialRoute = resolveInitialRoute() + router.replaceAll(initialRoute) + } + } + } + + private suspend fun resolveInitialRoute(): AppRoute { + val userWallets = userWalletsListRepository.userWalletsSync() + + return when { + userWallets.isEmpty() -> { + val shouldShowTos = !cardRepository.isTangemTOSAccepted() + if (shouldShowTos) { + AppRoute.Disclaimer(isTosAccepted = false) + } else { + AppRoute.Home(launchMode = launchMode) + } + } + userWallets.any { it.isLocked } -> { + AppRoute.Welcome( + launchMode = launchMode, + ) + } + else -> { + AppRoute.Wallet + } + }.also { + appRouterConfig.isInitialized.value = true + checkForUnfinishedBackup() + } } @Composable @@ -131,7 +180,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor( messageSender.send(SnackbarMessage(message = TextReference.EMPTY)) } - // TODO: Find correct initial route here: [REDACTED_JIRA] private fun getInitialStackOrInit(): List = if (initialStack.isNullOrEmpty()) { listOf(AppRoute.Initial) } else { @@ -153,6 +201,17 @@ internal class DefaultRoutingComponent @AssistedInject constructor( @AssistedFactory interface Factory : RoutingComponent.Factory { - override fun create(context: AppComponentContext, initialStack: List?): DefaultRoutingComponent + override fun create( + context: AppComponentContext, + initialStack: List?, + launchMode: InitScreenLaunchMode, + ): DefaultRoutingComponent + } + + private fun checkForUnfinishedBackup() { + componentScope.launch(dispatchers.main) { + val onboardingScanResponse = onboardingRepository.getUnfinishedFinalizeOnboarding() ?: return@launch + store.dispatch(GlobalAction.ShowDialog(BackupDialog.UnfinishedBackupFound(onboardingScanResponse))) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/configurator/AppRouterConfig.kt b/app/src/main/java/com/tangem/tap/routing/configurator/AppRouterConfig.kt index 5c2c9b3843..393ab3a7df 100644 --- a/app/src/main/java/com/tangem/tap/routing/configurator/AppRouterConfig.kt +++ b/app/src/main/java/com/tangem/tap/routing/configurator/AppRouterConfig.kt @@ -4,12 +4,14 @@ import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.navigation.Router import com.tangem.tap.common.SnackbarHandler import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow internal interface AppRouterConfig { var routerScope: CoroutineScope? var componentRouter: Router? var stack: List? + val isInitialized: MutableStateFlow // TODO: Replace with UI message handler: [REDACTED_JIRA] var snackbarHandler: SnackbarHandler? diff --git a/app/src/main/java/com/tangem/tap/routing/configurator/MutableAppRouterConfig.kt b/app/src/main/java/com/tangem/tap/routing/configurator/MutableAppRouterConfig.kt index d1b1e93f7c..776802e429 100644 --- a/app/src/main/java/com/tangem/tap/routing/configurator/MutableAppRouterConfig.kt +++ b/app/src/main/java/com/tangem/tap/routing/configurator/MutableAppRouterConfig.kt @@ -4,11 +4,12 @@ import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.navigation.Router import com.tangem.tap.common.SnackbarHandler import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow internal class MutableAppRouterConfig : AppRouterConfig { - override var routerScope: CoroutineScope? = null override var componentRouter: Router? = null override var stack: List? = null override var snackbarHandler: SnackbarHandler? = null + override val isInitialized: MutableStateFlow = MutableStateFlow(false) } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 615b1c3778..4f4eca31b6 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -8,20 +8,14 @@ import com.tangem.feature.referral.api.ReferralComponent import com.tangem.feature.stories.api.StoriesComponent import com.tangem.feature.usedesk.api.UsedeskComponent import com.tangem.feature.walletsettings.component.WalletSettingsComponent -import com.tangem.features.account.ArchivedAccountListComponent import com.tangem.features.account.AccountCreateEditComponent import com.tangem.features.account.AccountDetailsComponent +import com.tangem.features.account.ArchivedAccountListComponent import com.tangem.features.createwalletselection.CreateWalletSelectionComponent import com.tangem.features.details.component.DetailsComponent import com.tangem.features.disclaimer.api.components.DisclaimerComponent import com.tangem.features.home.api.HomeComponent -import com.tangem.features.hotwallet.AddExistingWalletComponent -import com.tangem.features.hotwallet.CreateMobileWalletComponent -import com.tangem.features.hotwallet.WalletActivationComponent -import com.tangem.features.hotwallet.CreateWalletBackupComponent -import com.tangem.features.hotwallet.UpdateAccessCodeComponent -import com.tangem.features.hotwallet.HotWalletFeatureToggles -import com.tangem.features.hotwallet.WalletBackupComponent +import com.tangem.features.hotwallet.* import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensSource @@ -40,17 +34,17 @@ import com.tangem.features.staking.api.StakingComponent import com.tangem.features.swap.SwapComponent import com.tangem.features.swap.v2.api.SendWithSwapComponent import com.tangem.features.tangempay.components.TangemPayDetailsComponent +import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.wallet.WalletEntryComponent import com.tangem.features.walletconnect.components.WalletConnectEntryComponent -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles +import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent import com.tangem.tap.features.details.ui.appcurrency.api.AppCurrencySelectorComponent import com.tangem.tap.features.details.ui.appsettings.api.AppSettingsComponent import com.tangem.tap.features.details.ui.cardsettings.api.CardSettingsComponent import com.tangem.tap.features.details.ui.cardsettings.coderecovery.api.AccessCodeRecoveryComponent import com.tangem.tap.features.details.ui.resetcard.api.ResetCardComponent import com.tangem.tap.features.details.ui.securitymode.api.SecurityModeComponent -import com.tangem.tap.features.details.ui.walletconnect.api.WalletConnectComponent import com.tangem.tap.features.welcome.component.WelcomeComponent import com.tangem.tap.routing.component.RoutingComponent.Child import dagger.hilt.android.scopes.ActivityScoped @@ -81,7 +75,6 @@ internal class ChildFactory @Inject constructor( private val swapComponentFactory: SwapComponent.Factory, private val homeComponentFactory: HomeComponent.Factory, private val tokenDetailsComponentFactory: TokenDetailsComponent.Factory, - private val walletConnectComponentFactory: WalletConnectComponent.Factory, private val qrScanningComponentFactory: QrScanningComponent.Factory, private val accessCodeRecoveryComponentFactory: AccessCodeRecoveryComponent.Factory, private val cardSettingsComponentFactory: CardSettingsComponent.Factory, @@ -103,14 +96,17 @@ internal class ChildFactory @Inject constructor( private val chooseManagedTokensComponentFactory: ChooseManagedTokensComponent.Factory, private val createWalletSelectionComponentFactory: CreateWalletSelectionComponent.Factory, private val createMobileWalletComponentFactory: CreateMobileWalletComponent.Factory, + private val upgradeWalletComponentFactory: UpgradeWalletComponent.Factory, private val addExistingWalletComponentFactory: AddExistingWalletComponent.Factory, private val walletActivationComponentFactory: WalletActivationComponent.Factory, private val createWalletBackupComponentFactory: CreateWalletBackupComponent.Factory, private val updateAccessCodeComponentFactory: UpdateAccessCodeComponent.Factory, + private val viewPhraseComponentFactory: ViewPhraseComponent.Factory, private val sendWithSwapComponentFactory: SendWithSwapComponent.Factory, private val sendEntryPointComponentFactory: SendEntryPointComponent.Factory, private val tangemPayDetailsComponentFactory: TangemPayDetailsComponent.Factory, - private val walletConnectFeatureToggles: WalletConnectFeatureToggles, + private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, + private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) { @@ -159,7 +155,6 @@ internal class ChildFactory @Inject constructor( context = context, params = WelcomeComponent.Params( launchMode = route.launchMode, - intent = route.intent, ), componentFactory = welcomeComponentFactory, ) @@ -205,6 +200,7 @@ internal class ChildFactory @Inject constructor( userWalletId = route.userWalletId, cryptoCurrency = route.currency, source = route.source, + launchSepa = route.launchSepa, ), componentFactory = onrampComponentFactory, ) @@ -242,14 +238,19 @@ internal class ChildFactory @Inject constructor( context = context, params = OnboardingEntryComponent.Params( scanResponse = route.scanResponse, - mode = when (route.mode) { - AppRoute.Onboarding.Mode.Onboarding -> OnboardingEntryComponent.Mode.Onboarding - AppRoute.Onboarding.Mode.AddBackupWallet1 -> OnboardingEntryComponent.Mode.AddBackupWallet1 - AppRoute.Onboarding.Mode.WelcomeOnlyTwin -> OnboardingEntryComponent.Mode.WelcomeOnlyTwin - AppRoute.Onboarding.Mode.RecreateWalletTwin -> + mode = when (val mode = route.mode) { + is AppRoute.Onboarding.Mode.Onboarding -> + OnboardingEntryComponent.Mode.Onboarding + is AppRoute.Onboarding.Mode.AddBackupWallet1 -> + OnboardingEntryComponent.Mode.AddBackupWallet1 + is AppRoute.Onboarding.Mode.WelcomeOnlyTwin -> + OnboardingEntryComponent.Mode.WelcomeOnlyTwin + is AppRoute.Onboarding.Mode.RecreateWalletTwin -> OnboardingEntryComponent.Mode.RecreateWalletTwin - AppRoute.Onboarding.Mode.ContinueFinalize -> + is AppRoute.Onboarding.Mode.ContinueFinalize -> OnboardingEntryComponent.Mode.ContinueFinalize + is AppRoute.Onboarding.Mode.UpgradeHotWallet -> + OnboardingEntryComponent.Mode.UpgradeHotWallet(mode.userWalletId) }, ), componentFactory = onboardingEntryComponentFactory, @@ -322,19 +323,11 @@ internal class ChildFactory @Inject constructor( ) } is AppRoute.WalletConnectSessions -> { - if (walletConnectFeatureToggles.isRedesignedWalletConnectEnabled) { - createComponentChild( - context = context, - params = RedesignedWalletConnectComponent.Params(route.userWalletId), - componentFactory = redesignedWalletConnectComponentFactory, - ) - } else { - createComponentChild( - context = context, - params = WalletConnectComponent.Params(route.userWalletId), - componentFactory = walletConnectComponentFactory, - ) - } + createComponentChild( + context = context, + params = RedesignedWalletConnectComponent.Params(route.userWalletId), + componentFactory = redesignedWalletConnectComponentFactory, + ) } is AppRoute.QrScanning -> { val source = when (route.source) { @@ -486,6 +479,15 @@ internal class ChildFactory @Inject constructor( componentFactory = createMobileWalletComponentFactory, ) } + is AppRoute.UpgradeWallet -> { + createComponentChild( + context = context, + params = UpgradeWalletComponent.Params( + userWalletId = route.userWalletId, + ), + componentFactory = upgradeWalletComponentFactory, + ) + } is AppRoute.AddExistingWallet -> { createComponentChild( context = context, @@ -520,6 +522,15 @@ internal class ChildFactory @Inject constructor( componentFactory = updateAccessCodeComponentFactory, ) } + is AppRoute.ViewPhrase -> { + createComponentChild( + context = context, + params = ViewPhraseComponent.Params( + userWalletId = route.userWalletId, + ), + componentFactory = viewPhraseComponentFactory, + ) + } is AppRoute.SendEntryPoint -> { createComponentChild( context = context, @@ -579,10 +590,24 @@ internal class ChildFactory @Inject constructor( is AppRoute.TangemPayDetails -> { createComponentChild( context = context, - params = TangemPayDetailsComponent.Params(), + params = TangemPayDetailsComponent.Params(userWalletId = route.userWalletId), componentFactory = tangemPayDetailsComponentFactory, ) } + is AppRoute.TangemPayOnboarding -> { + createComponentChild( + context = context, + params = TangemPayOnboardingComponent.Params(route.deeplink), + componentFactory = tangemPayOnboardingComponentFactory, + ) + } + is AppRoute.YieldSupplyPromo -> { + createComponentChild( + context = context, + params = YieldSupplyPromoComponent.Params(route.userWalletId, route.cryptoCurrency), + componentFactory = yieldSupplyPromoComponentFactory, + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 3412cac097..45a9e27528 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -14,6 +14,7 @@ import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler +import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler @@ -48,6 +49,7 @@ internal class DeepLinkFactory @Inject constructor( private val sellDeepLink: SellDeepLinkHandler.Factory, private val swapDeepLink: SwapDeepLinkHandler.Factory, private val promoDeepLink: PromoDeeplinkHandler.Factory, + private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -135,6 +137,7 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.Swap.host -> swapDeepLink.create() DeepLinkRoute.WalletConnect.host -> walletConnectDeepLink.create(deeplinkUri) DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams) + DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri) else -> { Timber.i( """ diff --git a/app/src/main/res/drawable/inset_splash.xml b/app/src/main/res/drawable/inset_splash.xml index 1d66dc1301..2886ecbd00 100644 --- a/app/src/main/res/drawable/inset_splash.xml +++ b/app/src/main/res/drawable/inset_splash.xml @@ -1,10 +1,10 @@ + android:insetLeft="55dp" + android:insetRight="55dp" + android:insetTop="55dp" + android:insetBottom="55dp"/> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index db52974e28..bf4703e8e2 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -26,6 +26,4 @@ #1E1E1E #656565 - #000000 - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 2b866d0894..d91dbb1dbe 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -16,7 +16,7 @@ diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000000..1891e87ad4 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index dde88e9295..ea548398d0 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -12,6 +12,7 @@ import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler +import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler @@ -73,6 +74,10 @@ class DeepLinkFactoryTest { every { create(any(), any()) } returns mockk() } + private val onboardVisaDeepLink = mockk(relaxed = true) { + every { create(any()) } returns mockk() + } + private val cardSdkProvider = mockk(relaxed = true) { every { sdk.uiVisibility() } returns MutableStateFlow(false) } @@ -97,6 +102,7 @@ class DeepLinkFactoryTest { sellDeepLink = sellDeepLinkFactory, swapDeepLink = swapDeepLinkFactory, promoDeepLink = promoDeepLinkFactory, + onboardVisaDeepLink = onboardVisaDeepLink, ) @OptIn(ExperimentalCoroutinesApi::class) diff --git a/build.gradle.kts b/build.gradle.kts index c490aef8c2..5d73b767fa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,13 @@ plugins { alias(deps.plugins.ksp) apply false } +buildscript { + dependencies { + classpath(deps.gradle.android) + classpath(deps.agconnect.agcp) + } +} + val clean by tasks.registering { delete(rootProject.buildDir) } diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index d0ffcca403..716edd6750 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -1,14 +1,12 @@ package com.tangem.common.routing import android.os.Bundle -import com.tangem.common.routing.AppRoute.ManageTokens.Source import com.tangem.common.routing.bundle.RouteBundleParams import com.tangem.common.routing.bundle.bundle import com.tangem.common.routing.entity.InitScreenLaunchMode -import com.tangem.common.routing.entity.SerializableIntent import com.tangem.core.decompose.navigation.Route import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.feedback.models.CardInfo +import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency @@ -33,9 +31,6 @@ sealed class AppRoute(val path: String) : Route { data class Welcome( @Deprecated("No longer used, will be removed in future releases") val launchMode: InitScreenLaunchMode = InitScreenLaunchMode.Standard, - // we still have this param to be handled by WalletConnectLinkIntentHandler in WelcomeMiddleware - @Deprecated("No longer used, will be removed in future releases") - val intent: SerializableIntent? = null, ) : AppRoute(path = "/welcome"), RouteBundleParams { override fun getBundle(): Bundle = bundle(serializer()) @@ -83,8 +78,8 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class Usedesk( - val cardInfo: CardInfo, - ) : AppRoute(path = "/usedesk/${cardInfo.cardId}") + val walletMetaInfo: WalletMetaInfo, + ) : AppRoute(path = "/usedesk/${walletMetaInfo.userWalletId}") @Serializable data class CardSettings( @@ -239,6 +234,7 @@ sealed class AppRoute(val path: String) : Route { val source: OnrampSource, val userWalletId: UserWalletId, val currency: CryptoCurrency, + val launchSepa: Boolean = false, ) : AppRoute(path = "/onramp/${userWalletId.stringValue}/${currency.symbol}"), RouteBundleParams { override fun getBundle(): Bundle = bundle(serializer()) } @@ -274,14 +270,16 @@ sealed class AppRoute(val path: String) : Route { data class Onboarding( val scanResponse: ScanResponse, val mode: Mode = Mode.Onboarding, - ) : AppRoute(path = "/onboarding_v2/${mode.name}") { + ) : AppRoute(path = "/onboarding_v2/$mode") { - enum class Mode { - Onboarding, // general Mode - AddBackupWallet1, // continue backup process for existing wallet 1 - WelcomeOnlyTwin, // show welcome screen and then navigate to wallet for twins - RecreateWalletTwin, // reset twins - ContinueFinalize, // continue finalize process (unfinished backup dialog) + @Serializable + sealed class Mode { + data object Onboarding : Mode() // general Mode + data object AddBackupWallet1 : Mode() // continue backup process for existing wallet 1 + data object WelcomeOnlyTwin : Mode() // show welcome screen and then navigate to wallet for twins + data object RecreateWalletTwin : Mode() // reset twins + data object ContinueFinalize : Mode() // continue finalize process (unfinished backup dialog) + data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() // upgrade hot wallet } } @@ -311,6 +309,11 @@ sealed class AppRoute(val path: String) : Route { @Serializable object CreateMobileWallet : AppRoute(path = "/create_mobile_wallet") + @Serializable + data class UpgradeWallet( + val userWalletId: UserWalletId, + ) : AppRoute(path = "/upgrade_wallet/${userWalletId.stringValue}") + @Serializable object AddExistingWallet : AppRoute(path = "/add_existing_wallet") @@ -329,6 +332,11 @@ sealed class AppRoute(val path: String) : Route { val userWalletId: UserWalletId, ) : AppRoute(path = "/update_access_code/${userWalletId.stringValue}") + @Serializable + data class ViewPhrase( + val userWalletId: UserWalletId, + ) : AppRoute(path = "/view_seed_phrase/${userWalletId.stringValue}") + @Serializable data class SendEntryPoint( val userWalletId: UserWalletId, @@ -364,5 +372,16 @@ sealed class AppRoute(val path: String) : Route { ) : AppRoute(path = "/archived_account/${userWalletId.stringValue}") @Serializable - data object TangemPayDetails : AppRoute(path = "/tangem_pay_details") + data class TangemPayDetails(val userWalletId: UserWalletId) : AppRoute(path = "/tangem_pay_details") + + @Serializable + data class TangemPayOnboarding( + val deeplink: String, + ) : AppRoute(path = "/tangem_pay_onboarding/$deeplink") + + @Serializable + data class YieldSupplyPromo( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + ) : AppRoute(path = "/yield_supply_promo/${userWalletId.stringValue}/${cryptoCurrency.symbol}") } \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index 1fbffcb8a8..9b5ba24bfa 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -59,6 +59,10 @@ sealed class DeepLinkRoute { data object Promo : DeepLinkRoute() { override val host: String = "promo" } + + data object OnboardVisa : DeepLinkRoute() { + override val host: String = "onboard-visa" + } } enum class DeepLinkScheme(val scheme: String) { diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/SerializableBundle.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/SerializableBundle.kt deleted file mode 100644 index fcac1b0422..0000000000 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/SerializableBundle.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.common.routing.entity - -import android.os.Bundle -import kotlinx.serialization.Serializable - -@Serializable -data class SerializableBundle( - val map: Map, -) { - - constructor(bundle: Bundle) : this( - map = bundle.keySet().mapNotNull { key -> - bundle.getString(key)?.let { key to it } - }.toMap(), - ) - - fun toBundle(): Bundle { - return Bundle().apply { - map.forEach { (key, value) -> - putString(key, value) - } - } - } -} \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/SerializableIntent.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/SerializableIntent.kt deleted file mode 100644 index cb4f2b871a..0000000000 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/SerializableIntent.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.tangem.common.routing.entity - -import android.content.ComponentName -import android.content.Intent -import android.net.Uri -import kotlinx.serialization.Serializable - -@Serializable -data class SerializableIntent( - val action: String?, - val dataString: String?, - val categories: Set?, - val type: String?, - val packageValue: String?, - val component: String?, - val flags: Int, - // CAUTION: works wrong with SerializableBundle constructor(bundle: Bundle), need to be removed - val extras: SerializableBundle?, -) { - - constructor(intent: Intent) : this( - action = intent.action, - dataString = intent.dataString, - categories = intent.categories, - type = intent.type, - packageValue = intent.`package`, - component = intent.component?.flattenToString(), - flags = intent.flags, - extras = intent.extras?.let(::SerializableBundle), - ) - - fun toIntent(): Intent { - val intent = Intent() - - intent.action = action - intent.setDataAndType( - dataString?.let { Uri.parse(it) }, - type, - ) - categories?.let { categories -> - for (category in categories) { - intent.addCategory(category) - } - } - intent.`package` = packageValue - intent.component = component?.let { ComponentName.unflattenFromString(it) } - intent.flags = flags - extras?.let { intent.putExtras(it.toBundle()) } - - return intent - } -} \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt index 34fac4a101..99a612b847 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt @@ -30,6 +30,7 @@ object MockNetworkStatusFactory { ), amounts = mapOf(), pendingTransactions = mapOf(), + yieldSupplyStatuses = mapOf(), source = source, ) .let(transform), diff --git a/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt index 5f86c429c3..a216096699 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt @@ -1,10 +1,13 @@ package com.tangem.common.test.domain.wallet +import com.tangem.common.card.WalletData import com.tangem.common.test.domain.card.MockScanResponseFactory import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.configs.GenericCardConfig +import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.builder.UserWalletIdBuilder /** @@ -29,4 +32,29 @@ object MockUserWalletFactory { hasBackupError = false, ) } + + fun createSingleWalletWithToken(): UserWallet.Cold { + return UserWallet.Cold( + name = "NODL", + walletId = UserWalletId("011"), + cardsInWallet = setOf(), + isMultiCurrency = false, + scanResponse = MockScanResponseFactory.create( + cardConfig = GenericCardConfig(maxWalletCount = 2), + derivedKeys = emptyMap(), + ).copy( + productType = ProductType.Note, + walletData = WalletData( + blockchain = "ETH", + token = WalletData.Token( + name = "Ethereum", + symbol = "ETH", + contractAddress = "0x", + decimals = 8, + ), + ), + ), + hasBackupError = false, + ) + } } \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/domain/walletmanager/MockUpdateWalletManagerResultFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/walletmanager/MockUpdateWalletManagerResultFactory.kt index 482cb7c1f6..7abe7f7217 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/walletmanager/MockUpdateWalletManagerResultFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/walletmanager/MockUpdateWalletManagerResultFactory.kt @@ -2,7 +2,9 @@ package com.tangem.common.test.domain.walletmanager import com.tangem.blockchainsdk.models.UpdateWalletManagerResult import com.tangem.blockchainsdk.models.UpdateWalletManagerResult.* +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import java.math.BigDecimal /** @@ -41,6 +43,41 @@ class MockUpdateWalletManagerResultFactory { ) } + fun createVerifiedWithToken(): Verified { + return Verified( + selectedAddress = "0x1", + addresses = setOf(Address(value = "0x1", type = Address.Type.Primary)), + currenciesAmounts = setOf( + CryptoCurrencyAmount.Token.BasicToken( + value = BigDecimal.ONE, + currencyRawId = CryptoCurrency.RawID("token"), + contractAddress = "0xTokenAddress", + ), + ), + currentTransactions = setOf(CryptoCurrencyTransaction.Coin(txInfo)), + ) + } + + fun createVerifiedWithSuppliedToken(): Verified { + return Verified( + selectedAddress = "0x1", + addresses = setOf(Address(value = "0x1", type = Address.Type.Primary)), + currenciesAmounts = setOf( + CryptoCurrencyAmount.Token.YieldSupplyToken( + value = BigDecimal.ONE, + currencyRawId = CryptoCurrency.RawID("token"), + contractAddress = "0xTokenAddress", + yieldSupplyStatus = YieldSupplyStatus( + isActive = true, + isInitialized = true, + isAllowedToSpend = false, + ), + ), + ), + currentTransactions = setOf(CryptoCurrencyTransaction.Coin(txInfo)), + ) + } + private companion object { val txInfo = TxInfo( diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt new file mode 100644 index 0000000000..e2b2b60cd9 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt @@ -0,0 +1,100 @@ +package com.tangem.common.ui.account + +import com.tangem.common.ui.R +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.utils.converter.Converter + +class AccountCryptoPortfolioItemStateConverter( + private val appCurrency: AppCurrency, + private val account: Account.CryptoPortfolio, + private val onItemClick: ((Account.CryptoPortfolio) -> Unit)? = null, + private val onItemLongClick: ((Account.CryptoPortfolio) -> Unit)? = null, +) : Converter { + + override fun convert(value: TotalFiatBalance): TokenItemState { + return when (value) { + is TotalFiatBalance.Loaded -> account.mapToContentState(value) + TotalFiatBalance.Failed -> account.mapToUnreachableState() + TotalFiatBalance.Loading -> account.mapToLoadingState() + } + } + + private fun Account.CryptoPortfolio.mapToContentState( + fiatBalance: TotalFiatBalance.Loaded, + ): TokenItemState.Content { + return TokenItemState.Content( + id = account.accountId.value, + iconState = AccountIconItemStateConverter.convert(this), + titleState = TokenItemState.TitleState.Content( + text = accountName.toUM().value, + ), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + fiatAmountState = FiatAmountState.Content( + text = fiatBalance.amount + .format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, + isFlickering = fiatBalance.source == StatusSource.CACHE, + ), + subtitle2State = null, + onItemClick = onItemClick?.let { onItemClick -> { onItemClick(account) } }, + onItemLongClick = onItemLongClick?.let { onItemLongClick -> { onItemLongClick(account) } }, + ) + } + + private fun Account.CryptoPortfolio.mapToLoadingState(): TokenItemState.Loading { + return TokenItemState.Loading( + id = account.accountId.value, + iconState = AccountIconItemStateConverter.convert(account), + titleState = TokenItemState.TitleState.Content( + text = accountName.toUM().value, + ), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + ) + } + + private fun Account.CryptoPortfolio.mapToUnreachableState(): TokenItemState.Unreachable { + return TokenItemState.Unreachable( + id = account.accountId.value, + iconState = AccountIconItemStateConverter.convert(account), + titleState = TokenItemState.TitleState.Content( + text = accountName.toUM().value, + ), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + onItemClick = onItemClick?.let { onItemClick -> + { onItemClick(account) } + }, + onItemLongClick = onItemLongClick?.let { onItemLongClick -> + { onItemLongClick(account) } + }, + ) + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt index 8a441922f5..377c483f00 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt @@ -1,43 +1,31 @@ package com.tangem.common.ui.account -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.tangem.common.ui.account.AccountIconPreviewData.randomAccountIcon +import com.tangem.core.ui.components.account.AccountCharIcon +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.account.AccountResIcon 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 import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.CryptoPortfolioIcon.Color -enum class AccountIconSize { - Default, Large, Medium, Small, ExtraSmall -} - /** - * Displays an account icon that can either show a letter (derived from [name]) - * or a predefined vector resource (from [icon]). + * Displays a portfolio account icon that can either show: + * - a single character (derived from the [name]) using [AccountCharIcon], or + * - a predefined vector resource (from [icon]) using [AccountResIcon]. * - * The background color is determined by the icon's [CryptoPortfolioIconUM.color], - * and the icon size, text style, and box modifier are adapted based on the given [size]. + * The background color is taken from [CryptoPortfolioIconUM.color], + * and the icon size, text style, and container shape are adapted based on the given [size]. * - * @param name The text reference used to resolve and display the first letter - * when [icon] is set to [CryptoPortfolioIcon.Icon.Letter]. + * @param name A [TextReference] used to resolve and display the first letter + * when [icon.value] is set to [CryptoPortfolioIcon.Icon.Letter]. * @param icon The account icon definition, which can be a letter or a drawable resource. - * @param size The size of the icon, defined by [AccountIconSize]. + * @param size The icon size, defined by [AccountIconSize]. + * + * @see AccountCharIcon + * @see AccountResIcon + * @see AccountIconSize */ @Composable fun AccountIcon( @@ -46,82 +34,20 @@ fun AccountIcon( size: AccountIconSize, modifier: Modifier = Modifier, ) { - val boxModifier = modifier.selectBoxModifier(size) - val iconSize = Modifier.selectIconSize(size) - val textStyle = when (size) { - AccountIconSize.Default -> TangemTheme.typography.h3 - AccountIconSize.Large -> TangemTheme.typography.h1 - AccountIconSize.Medium -> TangemTheme.typography.subtitle1 - AccountIconSize.Small -> TangemTheme.typography.subtitle2 - AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1 - } - Box( - contentAlignment = Alignment.Center, - modifier = boxModifier.background(icon.color.getUiColor()), - ) { - val icon = icon.value - val letter = name.resolveReference().firstOrNull() - when { - icon == CryptoPortfolioIcon.Icon.Letter -> Text( - text = letter?.uppercase() ?: "", - style = textStyle, - color = TangemTheme.colors.text.constantWhite, - ) - else -> Icon( - modifier = iconSize, - tint = TangemTheme.colors.text.constantWhite, - imageVector = ImageVector.vectorResource(id = icon.getResId()), - contentDescription = null, - ) - } - } -} - -private fun Modifier.selectIconSize(size: AccountIconSize): Modifier = when (size) { - AccountIconSize.Default -> this.size(20.dp) - AccountIconSize.Large -> this.size(40.dp) - AccountIconSize.Medium -> this.size(16.dp) - AccountIconSize.Small -> this.size(12.dp) - AccountIconSize.ExtraSmall -> this.size(8.dp) -} - -private fun Modifier.selectBoxModifier(size: AccountIconSize): Modifier = when (size) { - AccountIconSize.Default -> size(36.dp).clip(RoundedCornerShape(10.dp)) - AccountIconSize.Large -> size(88.dp).clip(RoundedCornerShape(24.dp)) - AccountIconSize.Medium -> size(28.dp).clip(RoundedCornerShape(8.dp)) - AccountIconSize.Small -> size(20.dp).clip(RoundedCornerShape(6.dp)) - AccountIconSize.ExtraSmall -> size(14.dp).clip(RoundedCornerShape(4.dp)) -} - -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun Preview_AccountIcon() { - TangemThemePreview { - Sample() - } -} - -@Composable -private fun Sample() { - val name = stringReference("Account Name") - Row( - modifier = Modifier.background(TangemTheme.colors.background.primary), - ) { - Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { - AccountIcon(name = name, randomAccountIcon(), size = AccountIconSize.Default) - AccountIcon(name = name, randomAccountIcon(), size = AccountIconSize.Large) - AccountIcon(name = name, randomAccountIcon(), size = AccountIconSize.Medium) - AccountIcon(name = name, randomAccountIcon(), size = AccountIconSize.Small) - AccountIcon(name = name, randomAccountIcon(), size = AccountIconSize.ExtraSmall) - } - Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { - AccountIcon(name = name, randomAccountIcon(letter = true), size = AccountIconSize.Default) - AccountIcon(name = name, randomAccountIcon(letter = true), size = AccountIconSize.Large) - AccountIcon(name = name, randomAccountIcon(letter = true), size = AccountIconSize.Medium) - AccountIcon(name = name, randomAccountIcon(letter = true), size = AccountIconSize.Small) - AccountIcon(name = name, randomAccountIcon(letter = true), size = AccountIconSize.ExtraSmall) - } + val letter = name.resolveReference().firstOrNull() + when { + icon.value == CryptoPortfolioIcon.Icon.Letter -> AccountCharIcon( + char = letter ?: 'N', + color = icon.color.getUiColor(), + size = size, + modifier = modifier, + ) + else -> AccountResIcon( + resId = icon.value.getResId(), + color = icon.color.getUiColor(), + size = size, + modifier = modifier, + ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt new file mode 100644 index 0000000000..f89a37e3db --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt @@ -0,0 +1,24 @@ +package com.tangem.common.ui.account + +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.utils.converter.Converter + +object AccountIconItemStateConverter : Converter { + + override fun convert(value: Account): CurrencyIconState.CryptoPortfolio = when (value) { + is Account.CryptoPortfolio -> when { + value.icon.value == CryptoPortfolioIcon.Icon.Letter -> CurrencyIconState.CryptoPortfolio.Letter( + char = value.accountName.toUM().value, + color = value.icon.color.getUiColor(), + isGrayscale = false, + ) + else -> CurrencyIconState.CryptoPortfolio.Icon( + resId = value.icon.value.getResId(), + color = value.icon.color.getUiColor(), + isGrayscale = false, + ) + } + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountNameUM.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountNameUM.kt new file mode 100644 index 0000000000..e86dda636b --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountNameUM.kt @@ -0,0 +1,56 @@ +package com.tangem.common.ui.account + +import androidx.compose.runtime.Immutable +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.core.ui.R +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.models.account.AccountName + +/** + * Represents a user model (UM) for an [AccountName] in the UI layer. + * This sealed interface provides a way to handle different types of account names. + */ +@Immutable +sealed interface AccountNameUM { + + /** The textual representation of the account name */ + val value: TextReference + + /** + * Represents the default main account name. + * If the user renames the main account, it will be converted to a [Custom] account name. + */ + data object DefaultMain : AccountNameUM { + + override val value: TextReference = resourceReference(R.string.account_main_account_title) + } + + /** + * Represents a custom account name provided by the user + * + * @property raw the raw string value of the custom account name + */ + class Custom(internal val raw: String) : AccountNameUM { + + override val value: TextReference = stringReference(value = raw) + } +} + +/** Extension function to convert a domain model [AccountName] to its corresponding UI model [AccountNameUM] */ +fun AccountName.toUM(): AccountNameUM { + return when (this) { + is AccountName.Custom -> AccountNameUM.Custom(raw = value) + AccountName.DefaultMain -> AccountNameUM.DefaultMain + } +} + +/** Extension function to convert a UI model [AccountNameUM] to its corresponding domain model [AccountName] */ +fun AccountNameUM.toDomain(): Either = either { + when (this@toDomain) { + is AccountNameUM.Custom -> AccountName(value = raw).bind() + AccountNameUM.DefaultMain -> AccountName.DefaultMain + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt index c48fa40072..0b51e38465 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt @@ -9,8 +9,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.tangem.common.ui.R +import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference @@ -73,6 +75,8 @@ private fun Title(title: TextReference) { text = title.resolveReference(), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } @@ -82,6 +86,8 @@ private fun Subtitle(subtitle: TextReference) { color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.caption2, text = subtitle.resolveReference(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } 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 47418a9338..8e21c12dd9 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 @@ -26,7 +26,7 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.StakingSendDetailsScreenTestTags +import com.tangem.core.ui.test.BaseAmountBlockTestTags import java.math.BigDecimal @Composable @@ -66,7 +66,7 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis modifier = Modifier .fillMaxWidth() .padding(top = TangemTheme.dimens.spacing24) - .testTag(StakingSendDetailsScreenTestTags.PRIMARY_AMOUNT), + .testTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT), ) Text( text = secondAmount, @@ -76,7 +76,7 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis modifier = Modifier .fillMaxWidth() .padding(top = TangemTheme.dimens.spacing8) - .testTag(StakingSendDetailsScreenTestTags.SECONDARY_AMOUNT), + .testTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT), ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt index 753a0fc99e..6263c12b93 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt @@ -23,7 +23,7 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags import kotlinx.collections.immutable.PersistentList private const val AMOUNT_BUTTONS_KEY = "amountButtonsKey" @@ -80,7 +80,7 @@ internal fun LazyListScope.buttons( vertical = TangemTheme.dimens.spacing10, horizontal = TangemTheme.dimens.spacing34, ) - .testTag(StakingSendScreenTestTags.MAX_BUTTON), + .testTag(SendScreenTestTags.MAX_BUTTON), ) } } @@ -94,7 +94,7 @@ private fun AmountCurrencyButton(button: AmountSegmentedButtonsConfig, isSegment .padding( horizontal = TangemTheme.dimens.spacing10, ) - .testTag(StakingSendScreenTestTags.CURRENCY_BUTTON), + .testTag(SendScreenTestTags.CURRENCY_BUTTON), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { @@ -106,13 +106,13 @@ private fun AmountCurrencyButton(button: AmountSegmentedButtonsConfig, isSegment url = button.iconUrl, size = TangemTheme.dimens.size18, isGrayscale = !isSegmentedButtonsEnabled, - modifier = iconModifier.testTag(StakingSendScreenTestTags.FIAT_ICON), + modifier = iconModifier.testTag(SendScreenTestTags.FIAT_ICON), ) } else if (button.iconState != null) { CurrencyIcon( state = button.iconState, shouldDisplayNetwork = false, - modifier = iconModifier.testTag(StakingSendScreenTestTags.CURRENCY_ICON), + modifier = iconModifier.testTag(SendScreenTestTags.CURRENCY_ICON), ) } Text( 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 f491629420..848bd0ab36 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 @@ -29,7 +29,7 @@ import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags import com.tangem.core.ui.utils.rememberDecimalFormat import kotlinx.coroutines.delay @@ -119,7 +119,7 @@ private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: Stri modifier = Modifier .align(TopCenter) .padding(bottom = TangemTheme.dimens.spacing32) - .testTag(StakingSendScreenTestTags.SECONDARY_AMOUNT), + .testTag(SendScreenTestTags.SECONDARY_AMOUNT), ) AmountFieldError( isError = amountField.isError, 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 d831aad665..6fd563f173 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 @@ -29,7 +29,7 @@ import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags private const val AMOUNT_FIELD_KEY = "amountFieldKey" @@ -54,7 +54,7 @@ internal fun LazyListScope.amountField( color = TangemTheme.colors.text.tertiary, modifier = Modifier .padding(top = TangemTheme.dimens.spacing14) - .testTag(StakingSendScreenTestTags.AMOUNT_CONTAINER_TITLE), + .testTag(SendScreenTestTags.AMOUNT_CONTAINER_TITLE), ) val balance = amountState.availableBalance.orMaskWithStars(isBalanceHidden).resolveReference() @@ -69,7 +69,7 @@ internal fun LazyListScope.amountField( textAlign = TextAlign.Center, modifier = Modifier .padding(top = TangemTheme.dimens.spacing2) - .testTag(StakingSendScreenTestTags.AMOUNT_CONTAINER_TEXT), + .testTag(SendScreenTestTags.AMOUNT_CONTAINER_TEXT), ) } CurrencyIcon( diff --git a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt index 4815b47788..617f1dda94 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt @@ -87,7 +87,7 @@ private fun GiveTxPermissionBottomSheetContent(content: GiveTxPermissionBottomSh PrimaryButtonIconEnd( text = stringResourceSafe(id = R.string.common_approve), - iconResId = R.drawable.ic_tangem_24, + iconResId = content.walletInteractionIcon, showProgress = data.approveButton.loading, modifier = Modifier .fillMaxWidth() @@ -319,5 +319,6 @@ private val previewData = GiveTxPermissionBottomSheetConfig( dialogText = resourceReference(R.string.give_permission_staking_footer), footerText = resourceReference(R.string.swap_give_permission_fee_footer), ), + walletInteractionIcon = R.drawable.ic_tangem_24, onCancel = {}, ) \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/state/GiveTxPermissionBottomSheetConfig.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/state/GiveTxPermissionBottomSheetConfig.kt index 08ffdcc086..9c1ecb8212 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/state/GiveTxPermissionBottomSheetConfig.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/state/GiveTxPermissionBottomSheetConfig.kt @@ -1,8 +1,10 @@ package com.tangem.common.ui.bottomsheet.permission.state +import androidx.annotation.DrawableRes import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent data class GiveTxPermissionBottomSheetConfig( val data: GiveTxPermissionState.ReadyForRequest, + @DrawableRes val walletInteractionIcon: Int?, val onCancel: () -> Unit, ) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt index b22f5341e4..496559367f 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt @@ -36,7 +36,7 @@ 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.singleEvent -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags @Composable fun NavigationButtonsBlock( @@ -149,7 +149,7 @@ private fun PreviousButton(prevButton: NavigationButton?) { .background(TangemTheme.colors.button.secondary) .clickable(onClick = button.onClick) .padding(TangemTheme.dimens.spacing12) - .testTag(StakingSendScreenTestTags.PREVIOUS_BUTTON), + .testTag(SendScreenTestTags.PREVIOUS_BUTTON), ) } } 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 31c66358b4..ad44dcf7dd 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 @@ -139,33 +139,59 @@ private fun NameAndInfo( ) } } - Text( - text = " $DOT ", - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - maxLines = 1, - ) - AnimatedContent( - targetState = balance, - label = "Balance content", - ) { balance -> - val (balanceValue, isFlickering) = getBalanceValueAndFlickerState(balance) + BalanceContent(balance) + } + } +} - if (balanceValue == null) { - TextShimmer( - style = TangemTheme.typography.caption2, - text = "aaaaa", - ) - } else { +@Composable +private fun BalanceContent(balance: UserWalletItemUM.Balance, modifier: Modifier = Modifier) { + AnimatedContent( + modifier = modifier, + targetState = balance, + label = "Balance content", + ) { balance -> + when (balance) { + UserWalletItemUM.Balance.Locked -> { + Icon( + modifier = Modifier + .padding(start = 4.dp, bottom = 2.dp, top = 2.dp) + .size(12.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_lock_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } + UserWalletItemUM.Balance.NotShowing -> { + /** No balance and no dot */ + } + else -> { + Row { Text( - text = balanceValue, - style = TangemTheme.typography.caption2.applyBladeBrush( - isEnabled = isFlickering, - textColor = TangemTheme.colors.text.tertiary, - ), + text = " $DOT ", + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, maxLines = 1, ) + + val (balanceValue, isFlickering) = getBalanceValueAndFlickerState(balance) + + if (balanceValue == null) { + TextShimmer( + style = TangemTheme.typography.caption2, + text = "aaaaa", + ) + } else { + Text( + text = balanceValue, + style = TangemTheme.typography.caption2.applyBladeBrush( + isEnabled = isFlickering, + textColor = TangemTheme.colors.text.tertiary, + ), + maxLines = 1, + ) + } } } } @@ -246,9 +272,8 @@ fun getBalanceValueAndFlickerState(balance: UserWalletItemUM.Balance): Pair DASH_SIGN to false is UserWalletItemUM.Balance.Hidden -> THREE_STARS to false - is UserWalletItemUM.Balance.Loading -> null to false - is UserWalletItemUM.Balance.Locked -> stringResourceSafe(R.string.common_locked) to false is UserWalletItemUM.Balance.Loaded -> balance.value to balance.isFlickering + else -> null to false } } @@ -392,6 +417,15 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider { - private val artworkUMConverter = ArtworkUMConverter() + private val artwork = artwork ?: UserWalletItemUM.ImageState.Loading override fun convert(value: UserWallet): UserWalletItemUM { return with(value) { @@ -52,18 +51,18 @@ class UserWalletItemUMConverter( isEnabled = isEnabled(userWallet = this), endIcon = endIcon, onClick = { onClick(value.walletId) }, - imageState = getImageState(userWallet = value), + imageState = artwork, label = getLabelOrNull(userWallet = this), ) } } private fun isEnabled(userWallet: UserWallet): Boolean { - return authMode || userWallet.isLocked.not() + return isAuthMode || userWallet.isLocked.not() } private fun getLabelOrNull(userWallet: UserWallet): LabelUM? { - return if (authMode.not() && userWallet is UserWallet.Hot && !userWallet.backedUp) { + return if (isAuthMode.not() && userWallet is UserWallet.Hot && !userWallet.backedUp) { LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, @@ -73,14 +72,6 @@ class UserWalletItemUMConverter( } } - private fun getImageState(userWallet: UserWallet): UserWalletItemUM.ImageState { - return when { - userWallet is UserWallet.Hot -> UserWalletItemUM.ImageState.MobileWallet - artwork != null -> UserWalletItemUM.ImageState.Image(artworkUMConverter.convert(artwork)) - else -> UserWalletItemUM.ImageState.Loading - } - } - private fun getInfo(userWallet: UserWallet): UserWalletItemUM.Information.Loaded { val text = when (userWallet) { is UserWallet.Cold -> { @@ -100,8 +91,9 @@ class UserWalletItemUMConverter( private fun getBalanceInfo(userWallet: UserWallet): UserWalletItemUM.Balance { return when { - isBalanceHidden -> UserWalletItemUM.Balance.Hidden userWallet.isLocked -> UserWalletItemUM.Balance.Locked + isAuthMode -> UserWalletItemUM.Balance.NotShowing + isBalanceHidden -> UserWalletItemUM.Balance.Hidden balance == null -> UserWalletItemUM.Balance.Loading else -> { when (balance) { diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/ext/UserWalletExtensions.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/ext/UserWalletExtensions.kt new file mode 100644 index 0000000000..00f4acf5a9 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/ext/UserWalletExtensions.kt @@ -0,0 +1,11 @@ +package com.tangem.common.ui.userwallet.ext + +import com.tangem.common.ui.R +import com.tangem.domain.models.wallet.UserWallet + +fun walletInterationIcon(userWallet: UserWallet): Int? { + return when (userWallet) { + is UserWallet.Cold -> R.drawable.ic_tangem_24 + is UserWallet.Hot -> null + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt index c0abb2b485..b31b4d5ddb 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt @@ -28,6 +28,8 @@ data class UserWalletItemUM( data object Hidden : Balance() + data object NotShowing : Balance() + data object Locked : Balance() data object Failed : Balance() diff --git a/core/config-toggles/build.gradle.kts b/core/config-toggles/build.gradle.kts index 96fb87b639..9b23b82a3c 100644 --- a/core/config-toggles/build.gradle.kts +++ b/core/config-toggles/build.gradle.kts @@ -1,4 +1,6 @@ -import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants +import com.squareup.kotlinpoet.* +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import org.json.JSONArray plugins { alias(deps.plugins.android.library) @@ -9,8 +11,24 @@ plugins { id("configuration") } +buildscript { + dependencies { + classpath("com.squareup:kotlinpoet:1.15.0") + classpath("org.json:json:20231013") + } +} + android { namespace = "com.tangem.core.configtoggle" + sourceSets["main"].java.srcDir("build/generated/source/toggles") +} + +tasks.named("preBuild") { + dependsOn(generateFeatureToggles, generateExcludedBlockchainToggles) +} + +tasks.withType().configureEach { + useJUnitPlatform() } dependencies { @@ -32,7 +50,72 @@ dependencies { implementation(projects.core.utils) 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) +} + +val generateFeatureToggles by tasks.registering { + generateToggles( + inputFilePath = "src/main/assets/configs/feature_toggles_config.json", + generatedFileName = "FeatureToggles", + ) +} + +val generateExcludedBlockchainToggles by tasks.registering { + generateToggles( + inputFilePath = "src/main/assets/configs/excluded_blockchains_config.json", + generatedFileName = "ExcludedBlockchainToggles", + ) +} + +fun Task.generateToggles(inputFilePath: String, generatedFileName: String) { + val inputFile = file(inputFilePath) + val outputDir = file("build/generated/source/toggles") + + inputs.file(inputFile) + outputs.dir(outputDir) + + doLast { + val jsonText = inputFile.readText() + val jsonArray = JSONArray(jsonText) + + val entries = (0 until jsonArray.length()).map { i -> + val obj = jsonArray.getJSONObject(i) + val name = obj.getString("name") + val version = obj.getString("version") + CodeBlock.of("%S to %S", name, version) + } + + val mapInitializer = CodeBlock.builder() + .add("mapOf(\n") + .indent() + .apply { + entries.forEachIndexed { index, entry -> + add(entry) + if (index != entries.lastIndex) add(",\n") else add("\n") + } + } + .unindent() + .add(")") + .build() + + val objectBuilder = TypeSpec.objectBuilder(name = generatedFileName) + .addKdoc("Generated from $inputFilePath") + .addProperty( + PropertySpec.builder("values", MAP.parameterizedBy(STRING, STRING)) + .initializer(mapInitializer) + .build() + ) + + val fileSpec = FileSpec.builder(packageName = "com.tangem.core.configtoggle", fileName = generatedFileName) + .addType(objectBuilder.build()) + .build() + + val outputPackageDir = File(outputDir, "") + outputPackageDir.mkdirs() + fileSpec.writeTo(outputPackageDir) + } } \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index ac2c36e249..711bf35291 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -19,14 +19,6 @@ "name": "STAKING_CARDANO_ENABLED", "version": "undefined" }, - { - "name": "WALLET_CONNECT_REDESIGN_ENABLED", - "version": "5.27.0" - }, - { - "name": "PUSH_NOTIFICATIONS_ENABLED", - "version": "5.26.2" - }, { "name": "USEDESK_ENABLED", "version": "undefined" @@ -43,10 +35,6 @@ "name": "SEND_REDESIGN_ENABLED", "version": "5.28.0" }, - { - "name": "WALLET_BALANCE_FETCHER_ENABLED", - "version": "5.27.0" - }, { "name": "HOT_WALLET_ENABLED", "version": "undefined" @@ -62,5 +50,17 @@ { "name": "NEW_TOKEN_RECEIVE_ENABLED", "version": "5.28.0" + }, + { + "name": "YIELD_SUPPLY_FEATURE_ENABLED", + "version": "undefined" + }, + { + "name": "NEW_ONRAMP_MAIN_ENABLED", + "version": "5.29.0" + }, + { + "name": "ACCOUNTS_FEATURE_ENABLED", + "version": "undefined" } ] diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/ExcludedBlockchainsManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/ExcludedBlockchainsManager.kt index f5db3637b9..4ff86959b7 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/ExcludedBlockchainsManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/ExcludedBlockchainsManager.kt @@ -3,6 +3,4 @@ package com.tangem.core.configtoggle.blockchain interface ExcludedBlockchainsManager { val excludedBlockchainsIds: Set - - suspend fun init() } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DefaultExcludedBlockchainsManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DefaultExcludedBlockchainsManager.kt deleted file mode 100644 index 0a260230e9..0000000000 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DefaultExcludedBlockchainsManager.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.tangem.core.configtoggle.blockchain.impl - -import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles -import com.tangem.core.configtoggle.version.VersionProvider -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.PreferencesKeys -import com.tangem.datasource.local.preferences.utils.getObjectMapSync -import com.tangem.datasource.local.preferences.utils.storeObjectMap - -internal class DefaultExcludedBlockchainsManager( - private val localTogglesStorage: TogglesStorage, - private val appPreferencesStore: AppPreferencesStore, - private val versionProvider: VersionProvider, -) : MutableExcludedBlockchainsManager { - - private var isInitialized: Boolean = false - - private lateinit var currentExcludedBlockchains: MutableMap - private lateinit var localExcludedBlockchains: Map - - override val excludedBlockchainsIds: Set - get() { - if (!isInitialized) error("ExcludedBlockchainsManager is not initialized") - - return currentExcludedBlockchains - .filterValues { it } - .keys - } - - override suspend fun init() { - localTogglesStorage.populate(path = "configs/excluded_blockchains_config") - - val storedExcludedBlockchainsIds = appPreferencesStore.getObjectMapSync( - key = PreferencesKeys.EXCLUDED_BLOCKCHAINS_KEY, - ) - - localExcludedBlockchains = localTogglesStorage.toggles - .associateToggles(currentVersion = versionProvider.get().orEmpty()) - .mapValues { (_, isIncluded) -> !isIncluded } - - currentExcludedBlockchains = (localExcludedBlockchains.keys + storedExcludedBlockchainsIds.keys) - .fold(mutableMapOf()) { acc, blockchainId -> - val isExcluded = storedExcludedBlockchainsIds[blockchainId] ?: localExcludedBlockchains[blockchainId] - - requireNotNull(isExcluded) { - "Unable to find $blockchainId in local or stored excluded blockchains" - } - - acc[blockchainId] = isExcluded - acc - } - - isInitialized = true - } - - override suspend fun excludeBlockchain(mainnetId: String, isExcluded: Boolean) { - currentExcludedBlockchains[mainnetId] = isExcluded - - storeCurrent() - } - - override fun isMatchLocalConfig(): Boolean { - return currentExcludedBlockchains == localExcludedBlockchains - } - - override suspend fun recoverLocalConfig() { - currentExcludedBlockchains = localExcludedBlockchains.toMutableMap() - - storeCurrent() - } - - private suspend fun storeCurrent() { - appPreferencesStore.storeObjectMap( - key = PreferencesKeys.EXCLUDED_BLOCKCHAINS_KEY, - value = currentExcludedBlockchains, - ) - } -} \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManager.kt new file mode 100644 index 0000000000..6f6645c865 --- /dev/null +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManager.kt @@ -0,0 +1,66 @@ +package com.tangem.core.configtoggle.blockchain.impl + +import com.tangem.core.configtoggle.ExcludedBlockchainToggles +import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager +import com.tangem.core.configtoggle.storage.LocalTogglesStorage +import com.tangem.core.configtoggle.utils.defineTogglesAvailability +import com.tangem.core.configtoggle.utils.toTableString +import com.tangem.core.configtoggle.version.VersionProvider +import kotlinx.coroutines.runBlocking +import kotlin.properties.Delegates + +/** + * [MutableExcludedBlockchainsManager] implementation in dev or mocked build + * + * @property versionProvider application version provider + * @property localTogglesStorage local storage for blockchain toggles + */ +internal class DevExcludedBlockchainsManager( + private val versionProvider: VersionProvider, + private val localTogglesStorage: LocalTogglesStorage, +) : MutableExcludedBlockchainsManager { + + private val fileBlockchainToggles: Map = getFileBlockchainToggles() + private var blockchainTogglesMap: MutableMap by Delegates.notNull() + + override val excludedBlockchainsIds: Set + get() = blockchainTogglesMap.filterValues { !it }.keys + + init { + val savedExcludedBlockchains = runBlocking { localTogglesStorage.getSyncOrEmpty() } + + blockchainTogglesMap = fileBlockchainToggles + .mapValues { (blockchainId, isEnabled) -> + savedExcludedBlockchains[blockchainId] ?: isEnabled + } + .toMutableMap() + } + + override suspend fun excludeBlockchain(mainnetId: String, isExcluded: Boolean) { + blockchainTogglesMap[mainnetId] = isExcluded + + localTogglesStorage.store(blockchainTogglesMap) + } + + override fun isMatchLocalConfig(): Boolean { + return blockchainTogglesMap == fileBlockchainToggles + } + + override suspend fun recoverLocalConfig() { + blockchainTogglesMap = fileBlockchainToggles.toMutableMap() + + localTogglesStorage.store(blockchainTogglesMap) + } + + override fun toString(): String { + return blockchainTogglesMap + .filterKeys { it.isNotEmpty() } + .toTableString(tableName = this@DevExcludedBlockchainsManager::class.java.simpleName) + } + + private fun getFileBlockchainToggles(): Map { + val appVersion = versionProvider.get() + + return ExcludedBlockchainToggles.values.defineTogglesAvailability(appVersion = appVersion) + } +} \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManager.kt new file mode 100644 index 0000000000..bc9eb689c4 --- /dev/null +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManager.kt @@ -0,0 +1,27 @@ +package com.tangem.core.configtoggle.blockchain.impl + +import com.tangem.core.configtoggle.ExcludedBlockchainToggles +import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager +import com.tangem.core.configtoggle.utils.defineTogglesAvailability +import com.tangem.core.configtoggle.version.VersionProvider + +/** + * [ExcludedBlockchainsManager] implementation in PROD build + * + * @property versionProvider application version provider + */ +internal class ProdExcludedBlockchainsManager( + private val versionProvider: VersionProvider, +) : ExcludedBlockchainsManager { + + override val excludedBlockchainsIds: Set = getBlockchainToggles() + + private fun getBlockchainToggles(): Set { + val appVersion = versionProvider.get() + + return ExcludedBlockchainToggles.values + .defineTogglesAvailability(appVersion = appVersion) + .filterValues { !it } + .keys + } +} \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/ExcludedBlockchainsManagerModule.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/ExcludedBlockchainsManagerModule.kt index 2e4fcddac5..14d9514eae 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/ExcludedBlockchainsManagerModule.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/ExcludedBlockchainsManagerModule.kt @@ -3,11 +3,10 @@ package com.tangem.core.configtoggle.di import android.content.Context import com.tangem.core.configtoggle.BuildConfig import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager -import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager -import com.tangem.core.configtoggle.blockchain.impl.DefaultExcludedBlockchainsManager +import com.tangem.core.configtoggle.blockchain.impl.DevExcludedBlockchainsManager +import com.tangem.core.configtoggle.blockchain.impl.ProdExcludedBlockchainsManager import com.tangem.core.configtoggle.storage.LocalTogglesStorage import com.tangem.core.configtoggle.version.DefaultVersionProvider -import com.tangem.datasource.asset.loader.AssetLoader import com.tangem.datasource.local.preferences.AppPreferencesStore import dagger.Module import dagger.Provides @@ -24,26 +23,20 @@ internal object ExcludedBlockchainsManagerModule { @Singleton fun provideExcludedBlockchainsManager( @ApplicationContext context: Context, - assetLoader: AssetLoader, appPreferencesStore: AppPreferencesStore, ): ExcludedBlockchainsManager { - val localTogglesStorage = LocalTogglesStorage(assetLoader) val versionProvider = DefaultVersionProvider(context) - return DefaultExcludedBlockchainsManager( - localTogglesStorage, - appPreferencesStore, - versionProvider, - ) - } - - @Provides - @Singleton - fun provideMutableExcludedBlockchainsManager( - manager: ExcludedBlockchainsManager, - ): MutableExcludedBlockchainsManager? { - if (!BuildConfig.TESTER_MENU_ENABLED) return null - - return manager as MutableExcludedBlockchainsManager + return if (BuildConfig.TESTER_MENU_ENABLED) { + DevExcludedBlockchainsManager( + versionProvider = versionProvider, + localTogglesStorage = LocalTogglesStorage( + appPreferencesStore = appPreferencesStore, + preferencesKey = LocalTogglesStorage.EXCLUDED_BLOCKCHAINS_KEY, + ), + ) + } else { + ProdExcludedBlockchainsManager(versionProvider = versionProvider) + } } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt index fa5c04f6a1..877f25842d 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt @@ -7,14 +7,12 @@ import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager import com.tangem.core.configtoggle.storage.LocalTogglesStorage import com.tangem.core.configtoggle.version.DefaultVersionProvider -import com.tangem.datasource.asset.loader.AssetLoader import com.tangem.datasource.local.preferences.AppPreferencesStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.runBlocking import javax.inject.Singleton @Module @@ -25,29 +23,20 @@ internal object FeatureTogglesManagerModule { @Singleton fun provideFeatureTogglesManager( @ApplicationContext context: Context, - assetLoader: AssetLoader, appPreferencesStore: AppPreferencesStore, ): FeatureTogglesManager { - val localTogglesStorage = LocalTogglesStorage(assetLoader) val versionProvider = DefaultVersionProvider(context) return if (BuildConfig.TESTER_MENU_ENABLED) { DevFeatureTogglesManager( - localTogglesStorage = localTogglesStorage, - appPreferencesStore = appPreferencesStore, versionProvider = versionProvider, + featureTogglesLocalStorage = LocalTogglesStorage( + appPreferencesStore = appPreferencesStore, + preferencesKey = LocalTogglesStorage.FEATURE_TOGGLES_KEY, + ), ) } else { - ProdFeatureTogglesManager( - localTogglesStorage = localTogglesStorage, - versionProvider = versionProvider, - ) - }.also { - // We need to initialize during the hilt graph creation - // in order to provide the feature toggles correctly to other dependencies. - runBlocking { - it.init() - } + ProdFeatureTogglesManager(versionProvider = versionProvider) } } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt index 86a027924c..0296565727 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt @@ -7,9 +7,6 @@ package com.tangem.core.configtoggle.feature */ interface FeatureTogglesManager { - /** Initialize manager */ - suspend fun init() - /** Check feature toggle availability by name [name] */ fun isFeatureEnabled(name: String): Boolean } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt index ce0d035439..84dd090158 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt @@ -1,77 +1,68 @@ package com.tangem.core.configtoggle.feature.impl import androidx.annotation.VisibleForTesting +import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles +import com.tangem.core.configtoggle.storage.LocalTogglesStorage +import com.tangem.core.configtoggle.utils.defineTogglesAvailability +import com.tangem.core.configtoggle.utils.toTableString import com.tangem.core.configtoggle.version.VersionProvider -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.PreferencesKeys -import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull -import com.tangem.datasource.local.preferences.utils.storeObject +import kotlinx.coroutines.runBlocking +import kotlin.properties.Delegates /** - * Feature toggles manager implementation in DEV build + * Feature toggles manager implementation in dev or mocked build * - * @property localTogglesStorage local feature toggles storage - * @property appPreferencesStore application local store * @property versionProvider application version provider + * @property featureTogglesLocalStorage local storage for feature toggles */ internal class DevFeatureTogglesManager( - private val localTogglesStorage: TogglesStorage, - private val appPreferencesStore: AppPreferencesStore, private val versionProvider: VersionProvider, + private val featureTogglesLocalStorage: LocalTogglesStorage, ) : MutableFeatureTogglesManager { - private var featureTogglesMap: MutableMap? = null - private var localFeatureTogglesMap: Map? = null + private var fileFeatureTogglesMap: Map = getFileFeatureToggles() + private var featureTogglesMap: MutableMap by Delegates.notNull() - override suspend fun init() { - if (featureTogglesMap != null && localFeatureTogglesMap != null) { - return // Already initialized - } + init { + val savedFeatureToggles = runBlocking { featureTogglesLocalStorage.getSyncOrEmpty() } - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - - val savedFeatureToggles = appPreferencesStore.getObjectSyncOrNull>( - key = PreferencesKeys.FEATURE_TOGGLES_KEY, - ) ?: emptyMap() - - val localFeatureToggles = localTogglesStorage.toggles - .associateToggles(currentVersion = versionProvider.get().orEmpty()) - - localFeatureTogglesMap = localFeatureToggles - - featureTogglesMap = localFeatureToggles + featureTogglesMap = fileFeatureTogglesMap .mapValues { resultToggle -> savedFeatureToggles[resultToggle.key] ?: resultToggle.value } .toMutableMap() } - override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap!![name] ?: false + override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap[name] == true - override fun isMatchLocalConfig(): Boolean = featureTogglesMap == localFeatureTogglesMap + override fun getFeatureToggles(): Map = featureTogglesMap - override fun getFeatureToggles(): Map = featureTogglesMap!! + override fun isMatchLocalConfig(): Boolean = featureTogglesMap == fileFeatureTogglesMap override suspend fun changeToggle(name: String, isEnabled: Boolean) { - featureTogglesMap!![name] ?: return - featureTogglesMap!![name] = isEnabled - appPreferencesStore.storeFeatureToggles(value = featureTogglesMap!!) + featureTogglesMap[name] ?: return + featureTogglesMap[name] = isEnabled + featureTogglesLocalStorage.store(value = featureTogglesMap) } override suspend fun recoverLocalConfig() { - featureTogglesMap = localFeatureTogglesMap!!.toMutableMap() - appPreferencesStore.storeFeatureToggles(value = localFeatureTogglesMap!!) + featureTogglesMap = fileFeatureTogglesMap.toMutableMap() + featureTogglesLocalStorage.store(value = fileFeatureTogglesMap) + } + + override fun toString(): String { + return featureTogglesMap.toTableString(tableName = this@DevFeatureTogglesManager::class.java.simpleName) + } + + private fun getFileFeatureToggles(): Map { + val appVersion = versionProvider.get() + + return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion) } @VisibleForTesting(otherwise = VisibleForTesting.NONE) fun setFeatureToggles(map: MutableMap) { featureTogglesMap = map } - - private suspend fun AppPreferencesStore.storeFeatureToggles(value: Map) { - storeObject(PreferencesKeys.FEATURE_TOGGLES_KEY, value) - } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/ProdFeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/ProdFeatureTogglesManager.kt index 54fe7a011d..261ba22e8e 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/ProdFeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/ProdFeatureTogglesManager.kt @@ -1,41 +1,30 @@ package com.tangem.core.configtoggle.feature.impl import androidx.annotation.VisibleForTesting +import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles +import com.tangem.core.configtoggle.utils.defineTogglesAvailability import com.tangem.core.configtoggle.version.VersionProvider /** * Feature toggles manager implementation in PROD build * - * @property localTogglesStorage local feature toggles storage - * @property versionProvider application version provider + * @property versionProvider application version provider */ internal class ProdFeatureTogglesManager( - private val localTogglesStorage: TogglesStorage, private val versionProvider: VersionProvider, ) : FeatureTogglesManager { - private var featureToggles: Map? = null + private val featureToggles: Map = getFileFeatureToggles() - override suspend fun init() { - if (featureToggles != null) { - return // Already initialized - } + override fun isFeatureEnabled(name: String): Boolean = featureToggles[name] == true - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - featureToggles = localTogglesStorage.toggles - .associateToggles(currentVersion = versionProvider.get() ?: "") + private fun getFileFeatureToggles(): Map { + val appVersion = versionProvider.get() + + return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion) } - override fun isFeatureEnabled(name: String): Boolean = featureToggles!![name] ?: false - @VisibleForTesting(otherwise = VisibleForTesting.NONE) - fun getProdFeatureToggles() = featureToggles!! - - @VisibleForTesting(otherwise = VisibleForTesting.NONE) - fun setProdFeatureToggles(map: Map) { - featureToggles = map - } + fun getProdFeatureToggles() = featureToggles } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorage.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorage.kt index 59086d9658..fe575dc0ef 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorage.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorage.kt @@ -1,24 +1,34 @@ package com.tangem.core.configtoggle.storage -import com.tangem.datasource.asset.loader.AssetLoader -import kotlin.properties.Delegates +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.getObjectMapSync +import com.tangem.datasource.local.preferences.utils.storeObjectMap /** - * Storage implementation for storing local feature toggles. - * Feature toggles are declared in file [LOCAL_CONFIG_PATH]. + * Local storage for toggles * - * @property assetLoader asset loader + * @property appPreferencesStore app preferences store + * @property preferencesKey preferences key * [REDACTED_AUTHOR] */ internal class LocalTogglesStorage( - private val assetLoader: AssetLoader, -) : TogglesStorage { + private val appPreferencesStore: AppPreferencesStore, + private val preferencesKey: Preferences.Key, +) { - override var toggles: List by Delegates.notNull() - private set + suspend fun getSyncOrEmpty(): Map { + return appPreferencesStore.getObjectMapSync(key = preferencesKey) + } - override suspend fun populate(path: String) { - toggles = assetLoader.loadList(path) + suspend fun store(value: Map) { + appPreferencesStore.storeObjectMap(key = preferencesKey, value = value) + } + + companion object { + val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") } + val EXCLUDED_BLOCKCHAINS_KEY by lazy { stringPreferencesKey(name = "excludedBlockchainsV2") } } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt index 68ee66b88c..052ec8ab62 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt @@ -1,13 +1,13 @@ package com.tangem.core.configtoggle.utils -import com.tangem.core.configtoggle.storage.ConfigToggle import com.tangem.core.configtoggle.version.VersionAvailabilityContract -internal fun List.associateToggles(currentVersion: String): Map { - return associate { localToggle -> - Pair( - first = localToggle.name, - second = VersionAvailabilityContract(currentVersion, localToggle.version), - ) +internal fun Map.defineTogglesAvailability(appVersion: String?): Map { + return if (appVersion == null) { + mapValues { false } + } else { + mapValues { (_, version) -> + VersionAvailabilityContract(currentVersion = appVersion, localVersion = version) + } } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/StringLogExt.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/StringLogExt.kt new file mode 100644 index 0000000000..85293bf111 --- /dev/null +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/StringLogExt.kt @@ -0,0 +1,16 @@ +package com.tangem.core.configtoggle.utils + +import java.util.Locale + +internal fun Map.toTableString(tableName: String): String { + return buildString { + append("$tableName:\n") + append("|------------------------------------------|-----------|\n") + append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", "name", "isEnabled")) + append("|------------------------------------------|-----------|\n") + entries.forEachIndexed { index, (name, isEnabled) -> + append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", name, isEnabled)) + } + append("|------------------------------------------|-----------|") + } +} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManagerTest.kt new file mode 100644 index 0000000000..18eb584b52 --- /dev/null +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManagerTest.kt @@ -0,0 +1,203 @@ +package com.tangem.core.configtoggle.blockchain.impl + +import com.google.common.truth.Truth +import com.tangem.core.configtoggle.ExcludedBlockchainToggles +import com.tangem.core.configtoggle.storage.LocalTogglesStorage +import com.tangem.core.configtoggle.version.VersionProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DevExcludedBlockchainsManagerTest { + + private val versionProvider = mockk() + private val localTogglesStorage = mockk(relaxUnitFun = true) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Initialization { + + @BeforeAll + fun setupAll() { + val toggles = mapOf("CHAIN_1" to "1.0.0", "CHAIN_2" to "2.0.0") + mockkObject(ExcludedBlockchainToggles) + every { ExcludedBlockchainToggles.values } returns toggles + } + + @AfterAll + fun tearDownAll() { + unmockkObject(ExcludedBlockchainToggles) + } + + @AfterEach + fun tearDownEach() { + clearMocks(versionProvider, localTogglesStorage) + } + + @Test + fun `successfully initialize manager`() = runTest { + // Arrange + val appVersion = "1.0.0" + val savedToggles = mapOf("CHAIN_1" to false, "CHAIN_2" to true) + every { versionProvider.get() } returns appVersion + coEvery { localTogglesStorage.getSyncOrEmpty() } returns savedToggles + + // Act + val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds + + // Assert + val expected = setOf("CHAIN_1") + Truth.assertThat(actual).containsExactlyElementsIn(expected) + + coVerifyOrder { + versionProvider.get() + localTogglesStorage.getSyncOrEmpty() + } + } + + @Test + fun `successfully initialize manager if versionProvider returns null`() = runTest { + // Arrange + every { versionProvider.get() } returns null + coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap() + + // Act + val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds + + // Assert + Truth.assertThat(actual).containsExactly("CHAIN_1", "CHAIN_2") + + coVerifyOrder { + versionProvider.get() + localTogglesStorage.getSyncOrEmpty() + } + } + + @Test + fun `successfully initialize manager if storage returns empty map`() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap() + + // Act + val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds + + // Assert + Truth.assertThat(actual).containsExactly("CHAIN_2") + + coVerifyOrder { + versionProvider.get() + localTogglesStorage.getSyncOrEmpty() + } + } + + @Test + fun `failure initialize manager if storage throws exception`() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + val exception = Exception("Test exception") + coEvery { localTogglesStorage.getSyncOrEmpty() } throws exception + + // Act + val actual = runCatching { DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) } + .exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { + versionProvider.get() + localTogglesStorage.getSyncOrEmpty() + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ExcludeBlockchain { + + @Test + fun excludeBlockchain_changesStatusAndSaves() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap() + + val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) + manager.excludeBlockchain("CHAIN_1", false) + + // Act + val actual = manager.excludedBlockchainsIds + + // Assert + Truth.assertThat(actual).contains("CHAIN_1") + coVerify { localTogglesStorage.store(match { it["CHAIN_1"] == false }) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsMatchLocalConfig { + + @Test + fun isMatchLocalConfig_returnsTrueIfMatchesFile() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap() + + val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) + + // Act + val actual = manager.isMatchLocalConfig() + + // Assert + Truth.assertThat(actual).isTrue() + } + + @Test + fun isMatchLocalConfig_returnsFalseIfDiffersFromFile() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap() + + val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) + manager.excludeBlockchain("CHAIN_1", false) + + // Act + val actual = manager.isMatchLocalConfig() + + // Assert + Truth.assertThat(actual).isFalse() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class RecoverLocalConfig { + + @Test + fun recoverLocalConfig_resetsToFileAndSaves() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + val toggles = mapOf("CHAIN_1" to "2.0.0", "CHAIN_2" to "2.0.0") + mockkObject(ExcludedBlockchainToggles) + every { ExcludedBlockchainToggles.values } returns toggles + + coEvery { localTogglesStorage.getSyncOrEmpty() } returns mapOf("CHAIN_1" to true) + + val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) + manager.recoverLocalConfig() + + // Act + val actual = manager.excludedBlockchainsIds + + // Assert + Truth.assertThat(actual).containsExactly("CHAIN_1", "CHAIN_2") + + unmockkObject(ExcludedBlockchainToggles) + clearMocks(versionProvider, localTogglesStorage) + } + } +} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManagerTest.kt new file mode 100644 index 0000000000..68fdf3c8b6 --- /dev/null +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManagerTest.kt @@ -0,0 +1,98 @@ +package com.tangem.core.configtoggle.blockchain.impl + +import com.google.common.truth.Truth +import com.tangem.core.configtoggle.ExcludedBlockchainToggles +import com.tangem.core.configtoggle.version.VersionProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ProdExcludedBlockchainsManagerTest { + + private val versionProvider = mockk() + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Initialization { + + @BeforeAll + fun setupAll() { + val toggles = mapOf("CHAIN_1" to "1.0.0", "CHAIN_2" to "2.0.0") + mockkObject(ExcludedBlockchainToggles) + every { ExcludedBlockchainToggles.values } returns toggles + } + + @AfterAll + fun tearDownAll() { + unmockkObject(ExcludedBlockchainToggles) + } + + @AfterEach + fun tearDownEach() { + clearMocks(versionProvider) + } + + @Test + fun `successfully initialize excluded blockchains`() = runTest { + // Arrange + val appVersion = "1.0.0" + every { versionProvider.get() } returns appVersion + + // Act + val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds + + // Assert + val expected = setOf("CHAIN_2") + Truth.assertThat(actual).containsExactlyElementsIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `all blockchains excluded if versionProvider returns null`() = runTest { + // Arrange + every { versionProvider.get() } returns null + + // Act + val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds + + // Assert + val expected = setOf("CHAIN_1", "CHAIN_2") + Truth.assertThat(actual).containsExactlyElementsIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `all blockchains excluded if versionProvider returns empty string`() = runTest { + // Arrange + every { versionProvider.get() } returns "" + + // Act + val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds + + // Assert + val expected = setOf("CHAIN_1", "CHAIN_2") + Truth.assertThat(actual).containsExactlyElementsIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `failure initialize if versionProvider throws exception`() = runTest { + // Arrange + val exception = Exception("Test exception") + every { versionProvider.get() } throws exception + + // Act + val actual = runCatching { ProdExcludedBlockchainsManager(versionProvider) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { versionProvider.get() } + } + } +} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt new file mode 100644 index 0000000000..4970a9c1cf --- /dev/null +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt @@ -0,0 +1,489 @@ +package com.tangem.core.configtoggle.manager + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager +import com.tangem.core.configtoggle.manager.ProdFeatureTogglesManagerTest.IsFeatureEnabledModel +import com.tangem.core.configtoggle.storage.LocalTogglesStorage +import com.tangem.core.configtoggle.version.VersionProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DevFeatureTogglesManagerTest { + + private val versionProvider = mockk() + private val featureTogglesLocalStorage = mockk(relaxUnitFun = true) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Initialization { + + @BeforeAll + fun setupAll() { + val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0") + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns featureToggles + } + + @AfterAll + fun tearDownAll() { + unmockkObject(FeatureToggles) + } + + @AfterEach + fun tearDownEach() { + clearMocks(versionProvider, featureTogglesLocalStorage) + } + + @Test + fun `successfully initialize manager`() = runTest { + // Arrange + val appVersion = "1.0.0" + val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true) + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = savedFeatureToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `successfully initialize manager if versionProvider returns null`() = runTest { + // Arrange + val appVersion = null + val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true) + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = savedFeatureToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `successfully initialize manager if versionProvider returns empty string`() = runTest { + // Arrange + val appVersion = "" + val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true) + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = savedFeatureToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `failure initialize manager if versionProvider throws exception`() = runTest { + // Arrange + val exception = Exception("Test exception") + every { versionProvider.get() } throws exception + + // Act + val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) } + .exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { versionProvider.get() } + coVerify(inverse = true) { featureTogglesLocalStorage.getSyncOrEmpty() } + } + + @Test + fun `successfully initialize manager if storage returns empty map`() = runTest { + // Arrange + val appVersion = "1.0.0" + val savedFeatureToggles = emptyMap() + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `successfully initialize manager if storage returns unknown toggles`() = runTest { + // Arrange + val appVersion = "1.0.0" + val savedFeatureToggles = mapOf("TOGGLE_3" to true) + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `failure initialize manager if storage throws exception`() = runTest { + // Arrange + val appVersion = "1.0.0" + val exception = Exception("Test exception") + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } throws exception + + // Act + val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) } + .exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsFeatureEnabled { + + private lateinit var manager: DevFeatureTogglesManager + + @BeforeAll + fun setupAll() { + every { versionProvider.get() } returns "1.0.0" + + val featureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to "undefined", + "ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0", + ) + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns featureToggles + + val savedFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to true, + "ACTIVE2_TEST_FEATURE_ENABLED" to false, + ) + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) + } + + @AfterAll + fun tearDownAll() { + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + + @ParameterizedTest + @ProvideTestModels + fun isFeatureEnabled(model: IsFeatureEnabledModel) { + // Act + val actual = manager.isFeatureEnabled(name = model.name) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = false), + IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = true), + IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false), + IsFeatureEnabledModel(name = "", expected = false), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsMatchLocalConfig { + + @ParameterizedTest + @ProvideTestModels + fun isMatchLocalConfig(model: IsMatchLocalConfigModel) = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns model.fileFeatureToggles + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap() + + val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).apply { + setFeatureToggles(model.storedFeatureToggles.toMutableMap()) + } + + // Act + val actual = manager.isMatchLocalConfig() + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + + private fun provideTestModels() = listOf( + IsMatchLocalConfigModel( + fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"), + storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false), + expected = true, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"), + storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true), + expected = false, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"), + storedFeatureToggles = mapOf("TOGGLE_1" to true), + expected = false, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"), + storedFeatureToggles = mapOf("TOGGLE_3" to true, "TOGGLE_4" to false), + expected = false, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = emptyMap(), + storedFeatureToggles = emptyMap(), + expected = true, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = emptyMap(), + storedFeatureToggles = mapOf("TOGGLE_1" to true), + expected = false, + ), + ) + } + + data class IsMatchLocalConfigModel( + val fileFeatureToggles: Map, + val storedFeatureToggles: Map, + val expected: Boolean, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetFeatureToggles { + + @Test + fun getFeatureToggles() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + val fileFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to "undefined", + "ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0", + ) + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns fileFeatureToggles + + val savedFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to true, + "ACTIVE2_TEST_FEATURE_ENABLED" to false, + ) + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) + + // Act + val actual = manager.getFeatureToggles() + + // Assert + val expected = savedFeatureToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ChangeToggle { + + @ParameterizedTest + @ProvideTestModels + fun changeToggle(model: ChangeToggleModel) = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns model.initialToggles + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap() + + val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) + + // Act + manager.changeToggle(name = model.name, isEnabled = model.isEnabled) + val actual = manager.getFeatureToggles() + + // Assert + val expected = model.expectedToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + + if (model.expectedStoreSaving) { + featureTogglesLocalStorage.store(model.expectedToggles) + } + } + + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + + private fun provideTestModels() = listOf( + ChangeToggleModel( + initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"), + name = "TOGGLE_1", + isEnabled = false, + expectedToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false), + expectedStoreSaving = true, + ), + ChangeToggleModel( + initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"), + name = "TOGGLE_2", + isEnabled = true, + expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true), + expectedStoreSaving = true, + ), + ChangeToggleModel( + initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"), + name = "TOGGLE_3", + isEnabled = true, + expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false), + expectedStoreSaving = false, + ), + ChangeToggleModel( + initialToggles = emptyMap(), + name = "TOGGLE_1", + isEnabled = true, + expectedToggles = emptyMap(), + expectedStoreSaving = false, + ), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class RecoverLocalConfig { + + @Test + fun recoverLocalConfig() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + val fileFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to "undefined", + "ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0", + ) + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns fileFeatureToggles + + val savedFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to true, + "ACTIVE2_TEST_FEATURE_ENABLED" to false, + ) + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) + + // Act + manager.recoverLocalConfig() + val actual = manager.getFeatureToggles() + + // Assert + val expected = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to false, + "ACTIVE2_TEST_FEATURE_ENABLED" to true, + ) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + featureTogglesLocalStorage.store(expected) + } + + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + } + + data class ChangeToggleModel( + val initialToggles: Map, + val name: String, + val isEnabled: Boolean, + val expectedToggles: Map, + val expectedStoreSaving: Boolean, + ) +} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt deleted file mode 100644 index a4c3d6d7aa..0000000000 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt +++ /dev/null @@ -1,237 +0,0 @@ -package com.tangem.core.configtoggle.manager - -import android.annotation.SuppressLint -import com.google.common.truth.Truth -import com.squareup.moshi.Moshi -import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager -import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants -import com.tangem.core.configtoggle.storage.ConfigToggle -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles -import com.tangem.core.configtoggle.version.VersionProvider -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.PreferencesKeys -import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull -import com.tangem.datasource.local.preferences.utils.getSyncOrNull -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.* -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.collections.set - -/** -[REDACTED_AUTHOR] - */ -@SuppressLint("CheckResult") -internal class DevTogglesManagerTest { - - private val localTogglesStorage = mockk() - private val appPreferenceStore = AppPreferencesStore( - moshi = Moshi.Builder().build(), - dispatchers = TestingCoroutineDispatcherProvider(), - preferencesDataStore = mockk(relaxed = true), - ) - private val versionProvider = mockk() - private val manager = DevFeatureTogglesManager( - localTogglesStorage = localTogglesStorage, - appPreferencesStore = appPreferenceStore, - versionProvider = versionProvider, - ) - - @Test - fun `successfully initialize storage if shared prefs kept feature toggles`() = runTest { - val currentVersion = "0.1.0" - - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - coEvery { - appPreferenceStore.getObjectSyncOrNull>(PreferencesKeys.FEATURE_TOGGLES_KEY) - } returns savedFeatureTogglesMap - coEvery { localTogglesStorage.toggles } returns localFeatureToggles - coEvery { versionProvider.get() } returns currentVersion - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles - .associateToggles(currentVersion) - .mapValues { resultToggle -> - savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value - } - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `successfully initialize storage if shared prefs kept empty list`() = runTest { - val currentVersion = "0.1.0" - - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - coEvery { - appPreferenceStore.getObjectSyncOrNull>(PreferencesKeys.FEATURE_TOGGLES_KEY) - } returns emptyMap() - coEvery { localTogglesStorage.toggles } returns localFeatureToggles - coEvery { versionProvider.get() } returns currentVersion - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles - .associateToggles(currentVersion) - .mapValues { resultToggle -> - savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value - } - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `successfully initialize storage if shared prefs didn't keep feature toggles`() = runTest { - val currentVersion = "0.1.0" - - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - coEvery { - appPreferenceStore.getObjectSyncOrNull>(PreferencesKeys.FEATURE_TOGGLES_KEY) - } returns null - coEvery { localTogglesStorage.toggles } returns localFeatureToggles - coEvery { versionProvider.get() } returns currentVersion - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles - .associateToggles(currentVersion) - .mapValues(Map.Entry::value) - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `successfully initialize storage if versionProvider returns null`() = runTest { - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - coEvery { appPreferenceStore.getSyncOrNull(PreferencesKeys.FEATURE_TOGGLES_KEY) } returns savedFeatureToggles - coEvery { localTogglesStorage.toggles } returns localFeatureToggles - coEvery { versionProvider.get() } returns null - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles - .associateToggles(currentVersion = "") - .mapValues { resultToggle -> - savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value - } - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `get feature availability if feature toggle exists`() { - val featureToggles = mutableMapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to true, - "ACTIVE2_TEST_FEATURE_ENABLED" to true, - ) - manager.setFeatureToggles(featureToggles) - - val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED") - - Truth.assertThat(actual).isTrue() - } - - @Test - fun `get feature availability if feature toggle doesn't exists`() { - val featureToggles = mutableMapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to true, - ) - manager.setFeatureToggles(featureToggles) - - val actual = manager.isFeatureEnabled(name = "") - - Truth.assertThat(actual).isFalse() - } - - @Test - fun getFeatureToggles() { - val expected = mutableMapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - - manager.setFeatureToggles(expected) - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `change toggle that contains in map`() = runTest { - val changeableToggleName = "INACTIVE_TEST_FEATURE_ENABLED" - val resultMap = mutableMapOf( - changeableToggleName to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - - manager.setFeatureToggles(resultMap) - - manager.changeToggle(changeableToggleName, true) - - resultMap[changeableToggleName] = true - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap) - } - - @Test - fun `change toggle that doesn't contains in map`() = runTest { - val resultMap = mutableMapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - - manager.setFeatureToggles(resultMap) - - manager.changeToggle("FEATURE_TOGGLE", true) - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap) - } - - private companion object { - - val savedFeatureToggles = """ - [ - { - "name": "INACTIVE_TEST_FEATURE_ENABLED", - "version": "undefined" - }, - { - "name": "ACTIVE2_TEST_FEATURE_ENABLED", - "version": "1.0.0" - } - ] - """.trimIndent() - - val savedFeatureTogglesMap = mapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - - val localFeatureToggles = listOf( - ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"), - ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"), - ) - } -} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt new file mode 100644 index 0000000000..ffef24d22e --- /dev/null +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt @@ -0,0 +1,155 @@ +package com.tangem.core.configtoggle.manager + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager +import com.tangem.core.configtoggle.version.VersionProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ProdFeatureTogglesManagerTest { + + private val versionProvider = mockk() + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Initialization { + + @BeforeAll + fun setupAll() { + val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0") + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns featureToggles + } + + @AfterAll + fun tearDownAll() { + unmockkObject(FeatureToggles) + } + + @AfterEach + fun tearDownEach() { + clearMocks(versionProvider) + } + + @Test + fun `successfully initialize storage`() = runTest { + // Arrange + val appVersion = "1.0.0" + every { versionProvider.get() } returns appVersion + + // Act + val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `successfully initialize storage if versionProvider returns null`() = runTest { + // Arrange + val appVersion = null + every { versionProvider.get() } returns appVersion + + // Act + val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `successfully initialize storage if versionProvider returns empty string`() = runTest { + // Arrange + val appVersion = "" + every { versionProvider.get() } returns appVersion + + // Act + val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `failure initialize storage if versionProvider throws exception`() = runTest { + // Arrange + val exception = Exception("Test exception") + every { versionProvider.get() } throws exception + + // Act + val actual = runCatching { ProdFeatureTogglesManager(versionProvider) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { versionProvider.get() } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsFeatureEnabled { + + private lateinit var manager: ProdFeatureTogglesManager + + @BeforeAll + fun setupAll() { + every { versionProvider.get() } returns "1.0.0" + + val featureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to "undefined", + "ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0", + ) + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns featureToggles + + manager = ProdFeatureTogglesManager(versionProvider) + } + + @AfterAll + fun tearDownAll() { + clearMocks(versionProvider) + unmockkObject(FeatureToggles) + } + + @ParameterizedTest + @ProvideTestModels + fun isFeatureEnabled(model: IsFeatureEnabledModel) { + // Act + val actual = manager.isFeatureEnabled(name = model.name) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = true), + IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = false), + IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false), + IsFeatureEnabledModel(name = "", expected = false), + ) + } + + data class IsFeatureEnabledModel(val name: String, val expected: Boolean) +} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdTogglesManagerTest.kt deleted file mode 100644 index 6453d3eb4d..0000000000 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdTogglesManagerTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.tangem.core.configtoggle.manager - -import android.content.pm.PackageManager -import com.google.common.truth.Truth -import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants -import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager -import com.tangem.core.configtoggle.storage.ConfigToggle -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles -import com.tangem.core.configtoggle.version.VersionProvider -import io.mockk.* -import kotlinx.coroutines.test.runTest -import org.junit.Test - -/** -[REDACTED_AUTHOR] - */ -internal class ProdTogglesManagerTest { - - private val localTogglesStorage = mockk() - private val versionProvider = mockk() - private val manager = ProdFeatureTogglesManager(localTogglesStorage, versionProvider) - - @Test - fun `successfully initialize storage`() = runTest { - val currentVersion = "1.0.0" - - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - every { localTogglesStorage.toggles } returns localFeatureToggles - every { versionProvider.get() } returns currentVersion - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles.associateToggles(currentVersion) - Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `successfully initialize storage if versionProvider returns null`() = runTest { - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - every { localTogglesStorage.toggles } returns localFeatureToggles - every { versionProvider.get() } returns null - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(disabledFeatureToggles) - } - - @Test - fun `failure initialize storage if localFeatureTogglesStorage throws exception`() = runTest { - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - every { localTogglesStorage.toggles } throws IllegalStateException( - "Property featureToggles should be initialized before get.", - ) - - runCatching { manager.init() } - .onSuccess { throw IllegalStateException("localFeatureToggles shouldn't be initialized") } - .onFailure { - Truth - .assertThat(it) - .hasMessageThat() - .contains("Property featureToggles should be initialized before get.") - - Truth.assertThat(it).isInstanceOf(IllegalStateException::class.java) - } - - coVerifyOrder { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } - verifyAll(inverse = true) { versionProvider.get() } - } - - @Test - fun `failure initialize storage if versionProvider throws exception`() = runTest { - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - every { localTogglesStorage.toggles } returns localFeatureToggles - every { versionProvider.get() } throws PackageManager.NameNotFoundException() - - runCatching { manager.init() } - .onSuccess { throw IllegalStateException("versionProvider should throws exception") } - .onFailure { - Truth.assertThat(it).isInstanceOf(PackageManager.NameNotFoundException::class.java) - } - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - } - - @Test - fun `get feature availability if feature toggle exists`() { - val featureToggles = mapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to true, - "ACTIVE2_TEST_FEATURE_ENABLED" to true, - ) - manager.setProdFeatureToggles(featureToggles) - - val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED") - - Truth.assertThat(actual).isTrue() - } - - @Test - fun `get feature availability if feature toggle doesn't exists`() { - val featureToggles = mapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to true, - ) - manager.setProdFeatureToggles(featureToggles) - - val actual = manager.isFeatureEnabled(name = "") - - Truth.assertThat(actual).isFalse() - } - - private companion object { - val localFeatureToggles = listOf( - ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"), - ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"), - ) - - val disabledFeatureToggles = mapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - } -} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt deleted file mode 100644 index b2f5bc4be7..0000000000 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.tangem.core.configtoggle.storage - -import android.annotation.SuppressLint -import com.google.common.truth.Truth -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.Moshi -import com.squareup.moshi.Types -import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants -import com.tangem.datasource.asset.loader.AssetLoader -import com.tangem.datasource.asset.reader.AssetReader -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.* -import kotlinx.coroutines.test.runTest -import org.junit.Test -import java.io.IOException - -/** -[REDACTED_AUTHOR] - */ -@SuppressLint("CheckResult") -internal class LocalTogglesStorageTest { - - private val assetReader = mockk() - private val moshi = mockk() - private val jsonAdapter = mockk>>() - - // Impossible to mockk AssetLoader because it implement inline functions - private val assetLoader = AssetLoader( - assetReader = assetReader, - moshi = moshi, - dispatchers = TestingCoroutineDispatcherProvider(), - ) - - private val storage = LocalTogglesStorage(assetLoader) - - @Test - fun `successfully initialize storage`() = runTest { - everyReadingJson() returns json - everyCreatingMoshiAdapter() returns jsonAdapter - everyMappingJson() returns featureToggles - - storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - - coVerifyOrder { - assetReader.read(CONFIG_FILE_NAME) - jsonAdapter.fromJson(json) - } - - Truth.assertThat(storage.toggles).containsExactlyElementsIn(featureToggles) - } - - @Test - fun `failure initialize storage if assetReader throws exception`() = runTest { - everyReadingJson() returns json - everyCreatingMoshiAdapter() returns jsonAdapter - everyMappingJson() throws IOException() - - storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - - coVerifyOrder { - assetReader.read(CONFIG_FILE_NAME) - jsonAdapter.fromJson(json) - } - - Truth.assertThat(storage.toggles).containsExactlyElementsIn(emptyList()) - } - - @Test - fun `failure initialize storage if jsonAdapter throws exception`() = runTest { - everyReadingJson() throws IOException() - - storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - - coVerifyOrder { assetReader.read(CONFIG_FILE_NAME) } - verifyAll(inverse = true) { jsonAdapter.fromJson(any()) } - - Truth.assertThat(storage.toggles).containsExactlyElementsIn(emptyList()) - } - - private fun everyReadingJson() = coEvery { assetReader.read(CONFIG_FILE_NAME) } - - private fun everyCreatingMoshiAdapter() = every { - val types = Types.newParameterizedType(List::class.java, ConfigToggle::class.java) - moshi.adapter>(types) - } - - private fun everyMappingJson() = every { jsonAdapter.fromJson(json) } - - private companion object { - const val CONFIG_FILE_NAME = "configs/feature_toggles_config.json" - - val json = """ - [ - { - "name": "INACTIVE_TEST_FEATURE_ENABLED", - "version": "undefined" - }, - { - "name": "ACTIVE2_TEST_FEATURE_ENABLED", - "version": "1.0.0" - } - ] - """.trimIndent() - - val featureToggles = listOf( - ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"), - ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"), - ) - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt index 0348dba0e3..b85ea4e10e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt @@ -7,34 +7,51 @@ package com.tangem.datasource.api.common.response */ sealed class ApiResponse { - /** - * Represents a successful response from the API. - * - * @property data The data returned by the API. - */ - data class Success(val data: T) : ApiResponse() + /** Map of headers (header name with list of values) */ + abstract val headers: Map> /** - * Represents an error response or failure from the API. + * Represents a successful response from the API * - * @property cause The cause of the error. + * @property data the data returned by the API + * @property headers the headers returned by the API */ - data class Error(val cause: ApiResponseError) : ApiResponse() + data class Success( + val data: T, + override val headers: Map> = emptyMap(), + ) : ApiResponse() + + /** + * Represents an error response or failure from the API + * + * @property cause the cause of the error + * @property headers the headers returned by the API + */ + data class Error( + val cause: ApiResponseError, + override val headers: Map> = emptyMap(), + ) : ApiResponse() } /** - * Wraps data in a [ApiResponse.Success] instance. + * Wraps data in a [ApiResponse.Success] instance * - * @param data The data to wrap. - * @return A [ApiResponse.Success] instance containing the provided data. + * @param data the data to wrap + * @param headers the headers returned by the API + * @return a [ApiResponse.Success] instance containing the provided data */ -internal fun apiSuccess(data: T): ApiResponse = ApiResponse.Success(data) +internal fun apiSuccess(data: T, headers: Map>): ApiResponse { + return ApiResponse.Success(data, headers) +} /** - * Wraps an [ApiResponseError] in a [ApiResponse.Error] instance. + * Wraps an [ApiResponseError] in a [ApiResponse.Error] instance * - * @param cause The error to wrap. - * @return A [ApiResponse.Error] instance containing the provided error. + * @param cause the error to wrap + * @param headers the headers returned by the API + * @return a [ApiResponse.Error] instance containing the provided error */ @Suppress("UNCHECKED_CAST") -internal fun apiError(cause: ApiResponseError): ApiResponse = ApiResponse.Error(cause) as ApiResponse \ No newline at end of file +internal fun apiError(cause: ApiResponseError, headers: Map>): ApiResponse { + return ApiResponse.Error(cause, headers) as ApiResponse +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt index 50ee235c7b..28c7fa4f72 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt @@ -47,7 +47,8 @@ internal class ApiResponseCallDelegate( Timber.e(e, "onFailure UnknownException") ApiResponseError.UnknownException(e) } - val safeResponse = apiError(error) + + val safeResponse = apiError(cause = error, headers = emptyMap()) responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse)) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt index 10ec8add5d..f63826874b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt @@ -20,6 +20,8 @@ sealed class ApiResponseError : Exception() { // region Error Codes enum class Code(val numericCode: Int) { + // 3xx Server Errors + NOT_MODIFIED(numericCode = 304), // 4xx Server Errors BAD_REQUEST(numericCode = 400), UNAUTHORIZED(numericCode = 401), @@ -64,10 +66,6 @@ sealed class ApiResponseError : Exception() { ; override fun toString(): String = "$numericCode - $name" - - companion object { - val values = values() - } } // endregion Error Codes } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt index 0caae8ba1a..a6b364259c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt @@ -19,4 +19,9 @@ inline fun catchApiResponseError(onError: (ApiResponseError) -> Unit, block: onError(e) throw e } +} + +/** Checks if the ApiResponseError is a network error with the specified HTTP status [code] */ +fun ApiResponseError.isNetworkError(code: ApiResponseError.HttpException.Code): Boolean { + return this is ApiResponseError.HttpException && this.code == code } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseHeaders.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseHeaders.kt new file mode 100644 index 0000000000..52464501c9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseHeaders.kt @@ -0,0 +1,3 @@ +package com.tangem.datasource.api.common.response + +const val IF_NONE_MATCH_HEADER = "IfNoneMatch" \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt index 926d9213aa..a8272de6f1 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt @@ -12,12 +12,13 @@ import java.util.concurrent.TimeoutException import javax.net.ssl.SSLHandshakeException internal fun Response.toSafeApiResponse(analyticsErrorHandler: AnalyticsErrorHandler): ApiResponse { + val headers = headers().toMultimap() val body = body() return if (isSuccessful && body != null) { - apiSuccess(body) + apiSuccess(data = body, headers = headers) } else { - val code = ApiResponseError.HttpException.Code.values + val code = ApiResponseError.HttpException.Code.entries .firstOrNull { it.numericCode == code() } val e = try { if (code == null) { @@ -33,7 +34,7 @@ internal fun Response.toSafeApiResponse(analyticsErrorHandler: Anal ApiResponseError.UnknownException(e) } - apiError(e) + apiError(e, headers) } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt index 90785de95d..b240920600 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt @@ -56,7 +56,7 @@ data class TxDetails( val txData: String?, // transaction data if DEX, null if CEX @Json(name = "txValue") - val txValue: String, // amount (same as fromAmount for Coin, but for bridge equal to otherNativeFee) + val txValue: String?, // amount (same as fromAmount for Coin, but for bridge equal to otherNativeFee) @Json(name = "otherNativeFee") val otherNativeFee: String?, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 8678e1959c..b753ffc387 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -9,6 +9,7 @@ import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Query +@Suppress("TooManyFunctions") interface TangemPayApi { // region: auth @@ -32,6 +33,11 @@ interface TangemPayApi { @POST("v1/auth/token") suspend fun getTokenByCustomerWallet(@Body request: GetTokenByCustomerWalletRequest): ApiResponse + @POST("v1/auth/token/refresh") + suspend fun refreshCustomerWalletAccessToken( + @Body request: RefreshCustomerWalletAccessTokenRequest, + ): ApiResponse + @POST("v1/auth/token") suspend fun getAccessTokenByCardWallet(@Body request: GetAccessTokenByCardWalletRequest): ApiResponse @@ -103,4 +109,10 @@ interface TangemPayApi { @GET("v1/customer/kyc") suspend fun getKycAccess(@Header("Authorization") authHeader: String): ApiResponse + + @GET("v1/customer/me") + suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse + + @POST("v1/deeplink/validate") + suspend fun validateDeeplink(@Body body: DeeplinkValidityRequest): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/DeeplinkValidityRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/DeeplinkValidityRequest.kt new file mode 100644 index 0000000000..c6586ae6c7 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/DeeplinkValidityRequest.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DeeplinkValidityRequest( + @Json(name = "link") val link: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshCustomerWalletAccessTokenRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshCustomerWalletAccessTokenRequest.kt new file mode 100644 index 0000000000..d4b509360f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshCustomerWalletAccessTokenRequest.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class RefreshCustomerWalletAccessTokenRequest( + @Json(name = "auth_type") val authType: String = "customer_wallet", + @Json(name = "refresh_token") val refreshToken: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt new file mode 100644 index 0000000000..6c60787d46 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -0,0 +1,48 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CustomerMeResponse( + @Json(name = "result") val result: Result?, + @Json(name = "error") val error: String?, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "id") val id: String, + @Json(name = "state") val state: String, + @Json(name = "createdAt") val createdAt: String, + @Json(name = "product_instance") val productInstance: ProductInstance?, + @Json(name = "payment_account") val paymentAccount: PaymentAccount?, + @Json(name = "kyc") val kyc: Kyc?, + ) + + @JsonClass(generateAdapter = true) + data class ProductInstance( + @Json(name = "id") val id: String, + @Json(name = "cid") val cid: String, + @Json(name = "card_id") val cardId: String, + @Json(name = "card_wallet_address") val cardWalletAddress: String, + @Json(name = "status") val status: String, + @Json(name = "updated_at") val updatedAt: String, + @Json(name = "payment_account_id") val paymentAccountId: String, + ) + + @JsonClass(generateAdapter = true) + data class PaymentAccount( + @Json(name = "id") val id: String, + @Json(name = "address") val address: String, + @Json(name = "customer_wallet_address") val customerWalletAddress: String, + ) + + @JsonClass(generateAdapter = true) + data class Kyc( + @Json(name = "id") val id: String, + @Json(name = "provider") val provider: String, + @Json(name = "status") val status: String, + @Json(name = "risk") val risk: String, + @Json(name = "review_answer") val reviewAnswer: String, + @Json(name = "created_at") val createdAt: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/DeeplinkValidityResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/DeeplinkValidityResponse.kt new file mode 100644 index 0000000000..638055ef5c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/DeeplinkValidityResponse.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 DeeplinkValidityResponse( + @Json(name = "result") val result: Result?, + @Json(name = "error") val error: String?, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "status") val status: String, + ) +} \ No newline at end of file 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 be6fe202e7..d173c69c57 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 @@ -119,9 +119,6 @@ interface TangemTechApi { @Path("application_id") applicationId: String, @Body body: NotificationApplicationCreateBody, ): ApiResponse - - @PATCH("v1/user-wallets/wallets/{wallet_id}/notify") - suspend fun setNotificationsEnabled(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse // endregion // region user-wallets @@ -148,17 +145,22 @@ interface TangemTechApi { // region account @GET("/v1/wallets/{walletId}/accounts") - suspend fun getWalletAccounts(@Path("walletId") walletId: String): ApiResponse + suspend fun getWalletAccounts( + @Path("walletId") walletId: String, + @Header("If-None-Match") eTag: String? = null, + ): ApiResponse @PUT("/v1/wallets/{walletId}/accounts") suspend fun saveWalletAccounts( @Path("walletId") walletId: String, - @Header("If-Match") ifMatch: String, - ): ApiResponse + @Header("If-Match") eTag: String, + @Body body: SaveWalletAccountsResponse, + ): ApiResponse @GET("/v1/wallets/{walletId}/accounts/archived") suspend fun getWalletArchivedAccounts( @Path("walletId") walletId: String, + @Header("If-None-Match") eTag: String? = null, ): ApiResponse // endregion } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt index 6c3afd812f..ac0ce437eb 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt @@ -15,7 +15,7 @@ data class GetWalletAccountsResponse( @JsonClass(generateAdapter = true) data class Wallet( - @Json(name = "version") val version: Int, + @Json(name = "version") val version: Int = 0, @Json(name = "group") val group: GroupType, @Json(name = "sort") val sort: SortType, @Json(name = "totalAccounts") val totalAccounts: Int, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/WalletAccountDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/WalletAccountDTO.kt index 343b6cdf2a..2e9e49b4af 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/WalletAccountDTO.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/WalletAccountDTO.kt @@ -7,7 +7,7 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse @JsonClass(generateAdapter = true) data class WalletAccountDTO( @Json(name = "id") val id: String, - @Json(name = "name") val name: String, + @Json(name = "name") val name: String?, @Json(name = "derivation") val derivationIndex: Int, @Json(name = "icon") val icon: String, @Json(name = "iconColor") val iconColor: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/WalletConnectModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/WalletConnectModule.kt index 4c5f149013..e790ce4dc7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/WalletConnectModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/WalletConnectModule.kt @@ -8,6 +8,7 @@ import com.tangem.datasource.local.walletconnect.DefaultWalletConnectStore import com.tangem.datasource.local.walletconnect.WalletConnectStore import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.setTypes +import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO import com.tangem.domain.walletconnect.model.WcSessionDTO import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -30,6 +31,7 @@ object WalletConnectModule { @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, ): WalletConnectStore { + val scope = CoroutineScope(context = dispatchers.io + SupervisorJob()) return DefaultWalletConnectStore( persistenceStore = DataStoreFactory.create( serializer = MoshiDataStoreSerializer( @@ -38,7 +40,16 @@ object WalletConnectModule { defaultValue = emptySet(), ), produceFile = { context.dataStoreFile(fileName = "wallet_connect_sessions") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = scope, + ), + pendingApprovalSessionsStore = DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = setTypes(), + defaultValue = emptySet(), + ), + produceFile = { context.dataStoreFile(fileName = "wallet_connect_pending_approval_sessions") }, + scope = scope, ), ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt index 9da0b616fc..5f44b31c6e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt @@ -9,6 +9,8 @@ data class EnvironmentConfig( val mercuryoWidgetId: String = "", val mercuryoSecret: String = "", val amplitudeApiKey: String = "", + val appsFlyerApiKey: String = "", + val appsAppId: String = "", val blockchainSdkConfig: BlockchainSdkConfig = BlockchainSdkConfig(), val walletConnectProjectId: String = "", val express: ExpressModel? = null, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt index 80f41f68b3..a70611411d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt @@ -19,6 +19,8 @@ internal object EnvironmentConfigConverter : Converter { /** Get flow of elements [T] */ fun get(): StateFlow + /** Get element [T] synchronously or null */ + suspend fun getSyncOrNull(): T? + /** Store [value] */ suspend fun store(value: T) + /** Update current value by [function] */ suspend fun update(function: (T) -> T) + /** Clear stored value */ + fun clear() + companion object { /** @@ -32,6 +39,8 @@ interface RuntimeStateStore { override fun get(): StateFlow = flow + override suspend fun getSyncOrNull(): T? = flow.value + override suspend fun store(value: T) { flow.value = value } @@ -39,6 +48,10 @@ interface RuntimeStateStore { override suspend fun update(function: (T) -> T) { flow.update(function) } + + override fun clear() { + flow.value = defaultValue + } } } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt index 280c5f4040..48a30365ee 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt @@ -2,8 +2,6 @@ package com.tangem.datasource.local.network.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass -import com.tangem.datasource.local.network.entity.NetworkStatusDM.NoAccount -import com.tangem.datasource.local.network.entity.NetworkStatusDM.Verified import dev.onenowy.moshipolymorphicadapter.PolymorphicAdapterType import dev.onenowy.moshipolymorphicadapter.annotations.NameLabel import java.math.BigDecimal @@ -44,6 +42,7 @@ sealed interface NetworkStatusDM { @Json(name = "selected_address") override val selectedAddress: String, @Json(name = "available_addresses") override val availableAddresses: Set
, @Json(name = "amounts") val amounts: Map, + @Json(name = "yield_supply_statuses") val yieldSupplyStatuses: Map = emptyMap(), ) : NetworkStatusDM /** @@ -107,4 +106,11 @@ sealed interface NetworkStatusDM { Secondary, } } + + @JsonClass(generateAdapter = true) + data class YieldSupplyStatus( + @Json(name = "is_active") val isActive: Boolean, + @Json(name = "is_initialized") val isInitialized: Boolean, + @Json(name = "is_allowed_to_spend") val isAllowedToSpend: Boolean, + ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 06a5f51102..63fbdfebf8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -7,7 +7,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.IS_TANGEM_TOS_ACC import com.tangem.datasource.local.preferences.PreferencesKeys.SAVE_USER_WALLETS_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_OPEN_WELCOME_ON_RESUME_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY -import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY +import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_ASK_BIOMETRY_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.USED_CARDS_INFO_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.USER_WAS_INTERACT_WITH_RATING_KEY @@ -26,7 +26,7 @@ object PreferencesKeys { val SAVE_USER_WALLETS_KEY by lazy { booleanPreferencesKey(name = "saveUserWallets") } - val SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY by lazy { booleanPreferencesKey("saveUserWalletShown") } + val SHOULD_SHOW_ASK_BIOMETRY_KEY by lazy { booleanPreferencesKey("saveUserWalletShown") } val APP_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "launchCount") } @@ -56,10 +56,6 @@ object PreferencesKeys { val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") } - val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") } - - val EXCLUDED_BLOCKCHAINS_KEY by lazy { stringPreferencesKey(name = "excludedBlockchainsV2") } - val WAS_TWINS_ONBOARDING_SHOWN by lazy { booleanPreferencesKey(name = "twinsOnboardingShown") } val IS_TANGEM_TOS_ACCEPTED_KEY by lazy { booleanPreferencesKey(name = "tangem_tos_accepted") } @@ -187,7 +183,7 @@ object PreferencesKeys { internal fun getTapPrefKeysToMigrate(): Set { return setOf( SAVE_USER_WALLETS_KEY, - SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY, + SHOULD_SHOW_ASK_BIOMETRY_KEY, APP_LAUNCH_COUNT_KEY, SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY, FUNDS_FOUND_DATE_KEY, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserTokensResponseStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserTokensResponseStore.kt index 0a80baa680..89f266e889 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserTokensResponseStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserTokensResponseStore.kt @@ -39,5 +39,13 @@ internal class DefaultUserTokensResponseStore( ) } + override suspend fun clear(userWalletId: UserWalletId) { + appPreferencesStore.updateData { preferences -> + val key = createPreferencesKey(userWalletId = userWalletId.stringValue) + + preferences.toMutablePreferences().apply { remove(key) } + } + } + private fun createPreferencesKey(userWalletId: String) = stringPreferencesKey(name = "user_tokens_$userWalletId") } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensResponseStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensResponseStore.kt index 9631d25c78..9da0c72837 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensResponseStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensResponseStore.kt @@ -17,4 +17,6 @@ interface UserTokensResponseStore { suspend fun getSyncOrNull(userWalletId: UserWalletId): UserTokensResponse? suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse) + + suspend fun clear(userWalletId: UserWalletId) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/userwallet/UserWalletsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/userwallet/UserWalletsStore.kt index b4d50f01de..8f00255449 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/userwallet/UserWalletsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/userwallet/UserWalletsStore.kt @@ -15,6 +15,8 @@ interface UserWalletsStore { val userWallets: Flow> + val userWalletsSync: List + fun getSyncOrNull(key: UserWalletId): UserWallet? fun getSyncStrict(key: UserWalletId): UserWallet diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt new file mode 100644 index 0000000000..b988222b09 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt @@ -0,0 +1,16 @@ +package com.tangem.datasource.local.visa + +import com.tangem.domain.visa.model.VisaAuthTokens + +interface TangemPayStorage { + + suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens) + + suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens? + + suspend fun storeCustomerWalletAddress(customerWalletAddress: String) + + suspend fun getCustomerWalletAddress(): String? + + suspend fun clear(customerWalletAddress: String) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/DefaultWalletConnectStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/DefaultWalletConnectStore.kt index 79da8620de..d63e5bb1b6 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/DefaultWalletConnectStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/DefaultWalletConnectStore.kt @@ -1,18 +1,39 @@ package com.tangem.datasource.local.walletconnect import androidx.datastore.core.DataStore +import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO import com.tangem.domain.walletconnect.model.WcSessionDTO import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.transform +import org.joda.time.DateTime internal typealias WcSessionCollection = Set internal class DefaultWalletConnectStore( private val persistenceStore: DataStore, + private val pendingApprovalSessionsStore: DataStore>, ) : WalletConnectStore { override val sessions: Flow get() = persistenceStore.data + override val pendingApproval: Flow> + get() = pendingApprovalSessionsStore.data + .transform { pendingApprovalSet -> + val now = DateTime.now() + val expired = pendingApprovalSet + .filterTo(mutableSetOf()) { it.expiredTime < now.millis } + val shouldSomeClear = expired.isNotEmpty() + val actualData = if (shouldSomeClear) { + removePendingApproval(expired) + } else { + pendingApprovalSet + } + emit(actualData) + } + .distinctUntilChanged() + override suspend fun saveSessions(sessions: WcSessionCollection) { persistenceStore.updateData { data -> data.plus(sessions) } } @@ -20,4 +41,16 @@ internal class DefaultWalletConnectStore( override suspend fun removeSessions(sessions: WcSessionCollection) { persistenceStore.updateData { data -> data.minus(sessions) } } + + override suspend fun savePendingApproval( + sessions: Set, + ): Set { + return pendingApprovalSessionsStore.updateData { data -> data.plus(sessions) } + } + + override suspend fun removePendingApproval( + sessions: Set, + ): Set { + return pendingApprovalSessionsStore.updateData { data -> data.minus(sessions) } + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/WalletConnectStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/WalletConnectStore.kt index 4f3a8397b8..baad949d54 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/WalletConnectStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/WalletConnectStore.kt @@ -1,5 +1,6 @@ package com.tangem.datasource.local.walletconnect +import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO import com.tangem.domain.walletconnect.model.WcSessionDTO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -7,6 +8,8 @@ import kotlinx.coroutines.flow.first interface WalletConnectStore { val sessions: Flow + val pendingApproval: Flow> + suspend fun findSessionByTopic(topic: String) = sessions.first().find { it.topic == topic } suspend fun saveSessions(sessions: WcSessionCollection) @@ -14,4 +17,7 @@ interface WalletConnectStore { suspend fun removeSessions(sessions: WcSessionCollection) suspend fun removeSession(session: WcSessionDTO) = removeSessions(setOf(session)) + + suspend fun savePendingApproval(sessions: Set): Set + suspend fun removePendingApproval(sessions: Set): Set } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/DataStoreExt.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/DataStoreExt.kt new file mode 100644 index 0000000000..a8ecd2a3e2 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/DataStoreExt.kt @@ -0,0 +1,6 @@ +package com.tangem.datasource.utils + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.firstOrNull + +suspend fun DataStore.getSyncOrNull(): T? = data.firstOrNull() \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt index 958c875e21..c53422bc3f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt @@ -44,7 +44,11 @@ class NetworkLogsSaveInterceptor( throw e } - logResponseMessage(response, startNs) + if (restrictedForLogURLs.contains(request.url.host + request.url.encodedPath)) { + logResponseWithEmptyMessage(response, startNs) + } else { + logResponseMessage(response, startNs) + } return response } @@ -87,6 +91,14 @@ class NetworkLogsSaveInterceptor( } } + private fun logResponseWithEmptyMessage(response: Response, startNs: Long) { + val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs) + saveLogMessage( + "<-- ${response.code}", + " ${response.request.url} (${tookMs}ms)\n", + ) + } + private fun logResponseMessage(response: Response, startNs: Long) { val responseHeaders = response.headers val responseBody = response.body!! @@ -202,4 +214,13 @@ class NetworkLogsSaveInterceptor( private fun saveLogMessage(vararg messages: String) { appLogsStore.saveLogMessage(tag = "NetworkLogs", *messages) } + + private companion object { + /** + * List of URLs (host + path) for which logging is restricted + */ + val restrictedForLogURLs = listOf( + "api.stakek.it/v1/yields/enabled", + ) + } } \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/network/entity/NetworkStatusDMSerializationTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/network/entity/NetworkStatusDMSerializationTest.kt index a8bae9462e..4bc735b0dc 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/local/network/entity/NetworkStatusDMSerializationTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/network/entity/NetworkStatusDMSerializationTest.kt @@ -42,7 +42,10 @@ class NetworkStatusDMSerializationTest { { "value": "0x123456", "type": "primary" }, { "value": "0xabcdef", "type": "secondary" } ], - "amounts": { "ETH": "1.2345" } + "amounts": { "ETH": "1.2345" }, + "yield_supply_statuses": { + "ETH": { "is_active": false, "is_initialized": false, "is_allowed_to_spend": false } + } } """.trimIndent() @@ -62,6 +65,13 @@ class NetworkStatusDMSerializationTest { NetworkStatusDM.Address("0xabcdef", NetworkStatusDM.Address.Type.Secondary), ), amounts = mapOf("ETH" to BigDecimal("1.2345")), + yieldSupplyStatuses = mapOf( + "ETH" to NetworkStatusDM.YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ), ) Truth.assertThat(result).isEqualTo(expected) @@ -82,6 +92,13 @@ class NetworkStatusDMSerializationTest { NetworkStatusDM.Address("0xabcdef", NetworkStatusDM.Address.Type.Secondary), ), amounts = mapOf("ETH" to BigDecimal("1.2345")), + yieldSupplyStatuses = mapOf( + "ETH" to NetworkStatusDM.YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ), ) // Act @@ -100,7 +117,10 @@ class NetworkStatusDMSerializationTest { { "value": "0x123456", "type": "primary" }, { "value": "0xabcdef", "type": "secondary" } ], - "amounts": { "ETH": "1.2345" } + "amounts": { "ETH": "1.2345" }, + "yield_supply_statuses": { + "ETH": { "is_active": false, "is_initialized": false, "is_allowed_to_spend": false } + } } """.stripJsonWhitespace() diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index e0539d9751..a590f5ba85 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -280,7 +280,7 @@ Primärring Passphrase Einfügen - Datenschutzbestimmungen + Datenschutzrichtlinie %1$s-%2$s %1$s — %2$s Weiterlesen @@ -317,6 +317,7 @@ Unterstützte Netzwerke Tauschen Tangem + Tangem Wallet Allgemeine Geschäftsbedingungen Nutzungsbedingungen Heute @@ -418,7 +419,7 @@ Getauscht von %s Besuche die Website des Anbieters, um dein Geld zurückzuerhalten Fehler beim Vorgang durch Anbieter - Dein Wechsel dauert länger als erwartet. Wende Dich für Hilfe bitte an den Support Deines Anbieters. + Dein Swap dauert länger als üblich, aber Dein Geld ist absolut sicher und wird geliefert. Bei Fragen kannst Du Dich an das Support-Team des Anbieters wenden. Lange Transaktionszeit Der Transaktionsbetrag wurde aufgrund von OKX- oder Bridge-Regeln in %1$s auf deine Wallet zurückerstattet. %2$s Der Betrag wurde in %1$s (%2$s Netzwerk) zurückerstattet. @@ -521,12 +522,9 @@ Gehe zum Backup Um Deine Wallet mit einem Zugangscode zu sichern, führe zuerst die Sicherung durch. Sicherung zuerst beenden - Kein Backup Physische Karten, die Deine Kryptowährung sicher offline speichern. Wiederherstellungs-Phrase - Über eingehende Transaktionen benachrichtigt werden Schlüssel werden in der App gespeichert - Bleibe über die neuesten Funktionen und Neuigkeiten auf dem Laufenden Sicherung der Seed-Phrase Mobile Wallet erstellen Diese Wiederherstellungsphrase wurde bereits importiert @@ -585,7 +583,7 @@ %1$d von %2$d Wallets Entfernen - z.B. BTC vertraue ich, hodl muss ich + Zum Beispiel, Bitcoin Dein Portfolio wurde aktualisiert Der ausgewählte Token ist derzeit nicht für Aktionen innerhalb der Krypto-Wallet verfügbar. Aber keine Sorge, du kannst dein Interesse bekunden, indem du den Token hochstufen. Hochstimmen diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 327988e301..79b4f20f19 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -445,7 +445,6 @@ En la red %s ¿Está seguro de que desea salir del proceso de creación de código de acceso? Si lo hace, tendrá que empezar de nuevo. - Manténgase al día de las últimas funciones y noticias Esta información fue generada con IA.\nPulse aquí si encuentra algún error. Para cambiar el código de acceso coloque la tarjeta o el anillo como se muestra arriba y no lo retire hasta el fin de la operación Toque para cambiar la contraseña @@ -487,7 +486,7 @@ %1$d de %2$d billeteras Eliminar - por ejemplo, BTC I trust, hodl I must + Por ejemplo, Bitcoin Su portafolio ha sido actualizada El token seleccionado no está disponible actualmente para acciones dentro de la billetera cripto. Pero no se preocupa, puede expresar su interés votando a favor. Votar a favor @@ -650,6 +649,9 @@ Únase ahora Comparta su código y gane 5 USDT por venta. Su amigo obtiene un 10% de descuento. ¡Obtenga RECOMPENSAS por cada amigo! + Comprar cripto + Disfrute de unas comisiones del **0%** al comprar criptomonedas mediante transferencias SEPA. + Comprar criptomonedas con SEPA Configure un código de acceso único para proteger todos sus dispositivos. Proteger Puede configurar un código de acceso individual en cada tarjeta más adelante diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 22c254f30d..bb23367928 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -418,7 +418,6 @@ Scannez à %s Via %s - Restez informé des dernières fonctionnalités et actualités Ces informations ont été générées avec l\'IA.\nAppuyez ici si vous trouvez des erreurs. Touchez, pour modifier le code d\'accès Touchez, pour modifier le mot de passe @@ -460,7 +459,7 @@ %1$d des %2$d portefeuilles Enlever - par exemple, BTC I trust, hodl I must + Par exemple, Bitcoin Votre portfolio a été mis à jour Le jeton sélectionné n\'est actuellement pas disponible pour des actions dans le portefeuille crypto. Mais ne vous inquiétez pas, vous pouvez exprimer votre intérêt en votant positivement. Vote positif @@ -623,6 +622,9 @@ Rejoignez maintenant Partagez votre code et gagnez 5 USDT par vente. Votre ami bénéficie de 10 % de réduction. Recevez des RÉCOMPENSES pour chaque ami ! + Acheter crypto + Tangem réduit les frais SEPA à presque rien, vous offrant ainsi la meilleure offre à ce jour. + Achetez des cryptomonnaies sans payer de frais ! Vous devez définir un seul code d\'accès pour protéger tous vos appareils. Protéger Vous pourrez définir un code d\'accès individuel sur chaque carte plus tard diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 0ec41dbaeb..6b3712c0c4 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -5,12 +5,12 @@ アクセスコードが設定されていません アクセスコードを入力 アクセスコードが間違っています。あと%s回間違えるとホットウォレットが削除されます。 - アクセスコードが間違っています。あと%s回入力エラーが発生するとアプリがロックされます。 + アクセスコードが間違っています。あと%s回失敗すると、アプリはロックされます。 アクセスコードが間違っています。 \n %s秒待ってから再試行してください。 - 続行するには、以前に入力したコードを確認してください + 続行するにはアクセスコードを確認してください アクセスコードを再入力 ウォレットのロックを解除するには、 %s桁のアクセスコードを設定します。 - アクセスコードの作成 + アクセスコードを作成 アクセスコード %1$s個を超えるアカウントは作成できません。新しいアカウントを追加するには、1つをアーカイブしてください。 新しいアカウントを追加できません @@ -104,11 +104,11 @@ %d 枚のカード - スマートフォンウォレット + モバイルウォレット ウォレットのバックアップが正常に完了しました。 これらの単語は紛失した場合、復元できません。必ず安全な場所に保管してください。 バックアップが完了しました - シークレットリカバリーフレーズは、ウォレットへのアクセスと復元のために使用される%sのランダムな単語のセットです。 + シークレットリカバリーフレーズとは、ウォレットへのアクセスや復元のために使う、固定された%s個のランダムな単語のセットです。 これらの単語は紛失した場合、復元できません。必ず安全な場所に保管してください。 安全に保管してください これらの%s個の単語をパスワードマネージャーなどの安全な場所に保存し、決して他の人と共有しないでください。 @@ -197,6 +197,7 @@ カメラへのアクセスを許可していません。プライバシー設定を調整してください。 キャンセル 変更 + アカウントを選択 アクションを選択 ネットワークを選択 トークンを選択 @@ -503,48 +504,45 @@ 「Tangem」に生体認証の使用を許可しますか?\n本人確認とアプリの起動のために使用されます。 %sへ %sネットワーク - アクセスコードの作成プロセスを終了してもよろしいですか? + アクセスコードの設定をキャンセルしてもよろしいですか? 今すぐバックアップ - セットアップを完了するには、ウォレットをバックアップし、アクセスコードを使用してアプリへのアクセスを保護します。 + 設定を完了するには、ウォレットをバックアップし、アクセスコードでアプリを保護してください。 今すぐ実施 - ウォレットのアクティベーションを完了する - セットアップを完了するには、アクセスコードを使用してアプリへのアクセスを保護します。 + ウォレットの設定を完了する + アクセスコードでアプリを保護して、設定を完了してください。 そうした場合は、最初からやり直す必要があります。 - アクティベーションプロセスを終了してもよろしいですか? + 本当にアクティベーション処理を終了してもよろしいですか? 実行すると、最初からやり直す必要があります。 - Googleドライブのバックアップに保存されている既存のウォレットを復元する + Googleドライブのバックアップから既存のウォレットを復元する Googleドライブのバックアップ Tangemの業界最高水準のハードウェアウォレットで、今すぐセキュリティをアップグレードしましょう。 ハードウェアウォレット バックアップへ移動 - アクセスコードを使用してウォレットを保護するには、まずバックアップを完了してください。 + アクセスコードを作成する前にウォレットをバックアップしてください。 まずバックアップを完了する - バックアップなし - 暗号資産をオフラインで安全に保管する物理カード + 秘密鍵をオフラインで安全に保存する物理デバイス。 リカバリーフレーズ - 受信取引の通知を受け取る 鍵はアプリに保存されます - 最新の機能とニュースをお届けします シードフレーズのバックアップ モバイルウォレットを作成する このリカバリーフレーズはすでにインポートされています。 モバイルウォレット - このデバイスはアップグレードに使用できません。すでに別のウォレットが含まれています。 - 別のデバイスを選択してください。このデバイスはアップグレードには使用できません。 + アップグレードできません。このデバイスにはすでにウォレットが存在します。 + 別のデバイスを選択してください。このデバイスはアップグレードに使用できません。 操作中にエラーが発生しました。 手続き中も資金は安全に保管され、完全にアクセス可能です 資金へのアクセス - すべてのプライベートウォレットデータはモバイルアプリから削除され、Tangemデバイスにのみ安全に保存されます。 + ウォレットのデータはアプリから削除され、Tangemデバイスに保存されます。 セキュリティ全般 - 秘密鍵は、アプリからTangemカード・リングに移動します + 秘密鍵はアプリからTangemデバイスに移動されます。 鍵の移行 デバイスをスキャン アップグレードを開始 ウォレットをTangemウォレットにアップグレードします。これにより、コールドストレージで資産を安全に保管できます。 Tangemウォレット ハードウェアウォレットにアップグレード - Tangemの業界最高水準のハードウェアウォレットで、暗号資産を安全に保管しましょう。 - ハードウェアバックアップで、ウォレットをアップグレード + Tangemの業界最高クラスのハードウェアウォレットで、暗号資産を安全に保管しましょう。 + ウォレットをハードウェアセキュリティにアップグレードする この情報はAIで生成されました。 \nエラーが見つかった場合は、ここをタップしてください。 アクセスコードを変更するには、上図のようにカードまたはリングをタップし、操作が終了するまで取り外さないでください。 パスコードを変更するには、上記のようにカードをタップし、操作が終了するまで取り外さないでください。 @@ -745,8 +743,8 @@ コードを共有すると、販売ごとに5 USDTを獲得できます。お友達は10%割引になります。 友達への紹介で報酬を獲得しよう! 暗号資産を買い付ける - SEPA送金で暗号資産を買い付けると、手数料はかかりません。 - SEPAで暗号資産を買い付ける + SEPA送金で暗号資産を買い付けると、**手数料 0% ** になります。 + SEPAで暗号資産を購入 すべてのデバイスを保護するには、単一のアクセスコードを設定してください。 保護する 後で各カードおよびリングに個別のアクセスコードを設定できます。 @@ -1247,6 +1245,14 @@ 受け取る トークンを選択 利用不可 + カードをGET + ウォレットに追加して、どこでもスマートフォンで支払いができます。 + Apple Pay & Google Pay + USDC残高を使用して、毎日のお買い物をシームレスに支払います。 + 日常の買い物 + カードの詳細は保護されており、アプリ内で完全に制御できます。 + 内蔵セキュリティ + 無料のCryptoカード\nを数分で入手しましょう これは私のウォレットです 残高非表示 残高表示 @@ -1384,24 +1390,24 @@ WalletConnect 接続には数秒かかる場合があります %sから - Tangemウォレットを購入 — 暗号資産をオフラインで安全に保管できる物理カードです。 + Tangemウォレットを購入ーこれは、あなたの秘密鍵をオフラインで安全に保管する物理デバイスです。 ハードウェアウォレット - 数秒でスマートフォンに安全なウォレットが作成されます。 + 数秒でスマートフォンに安全なウォレットを作成します。 モバイルウォレット - 何を選ぶべき? - すでにTangemウォレットをお持ちですか? + 何を選ぶ? + すでにTangemウォレットを使っていますか? 今すぐスキャン - ウォレットの作成方法を選択してください - Tangemウォレットを購入しますか? + ウォレットの設定方法を選択する + Tangemウォレットを手に入れる準備はできた? 今すぐ購入 Googleドライブのバックアップに保存されている既存のウォレットを復元する Googleドライブからインポート 既存のウォレットを追加 - 暗号資産をオフラインで安全に保管する物理カード。 + 秘密鍵をオフラインで安全に保存する物理デバイス。 Tangemウォレットをスキャンする - ウォレットを復元するためのシードフレーズ + リカバリーフレーズを使って既存のウォレットをインポートします。 ウォレットをインポート - リカバリーフレーズをインポートする + リカバリーフレーズを入力 ウォレットのバックアップが正常に完了しました。 ウォレットをインポート インポート完了 @@ -1415,7 +1421,7 @@ 1.3万種類以上の暗号資産にアクセス。ワンタップで買付、売却、スワップ、ステーキングが可能です。\nバックアップ用に最大3枚のカードを連携できます。 Tangemウォレットを見る このウォレットを保護するためのシークレットコードです。ログインと署名に使用されます。 - アクセスコードの設定 / 変更 + アクセスコードの設定・変更 アクセスコードの変更 ウォレットの受信取引とTangemの更新について通知を受け取る。 現在、Huaweiデバイスではプッシュ通知が機能しない可能性があります。現在、解決策の検討に取り組んでおり、今後のアップデートで修正をリリースする予定です。ご理解のほどよろしくお願いいたします。 @@ -1547,6 +1553,7 @@ 問題が起きています すべてのdAppが接続解除されました 使用を許可する + 承認することで、今後の取引においてdAppまたはスマートコントラクトがトークンを使用することを許可することになります。 アドレス 接続する 読み込み中 @@ -1634,11 +1641,9 @@ 現在のAPY 私の資金 あなたの%1$sはAaveに預けられ、利息が付きます。あなたは%2$sトークンを保有しており、これは残高を表し、時間の経過とともに増加します。入金すると、資金はAaveに預け入れられ、取引手数料を差し引いた利息が付きます。 - アクティブ 利回りを得る 総収益 Aaveへの送金 - 資産はプロトコル内に保管されています Aaveの詳細を見る あなたの%sはAaveに預けられています これは%sの現在の供給手数料です。実際のコストは受取画面に表示されます。 @@ -1647,7 +1652,6 @@ ネットワーク手数料が上限手数料を超えた場合、手数料が下がるまで取引は成立しません。この制限は後で変更できます。 最大手数料 手数料ポリシー - ここに説明文を入力してください。1〜3行が理想的です。 トークン承認が必要 アカウントへの入金はすべて自動的にAaveに貸し出されます。 残高は自動的に計算されます @@ -1656,8 +1660,8 @@ 使い方 Aaveは世界中で何百万人もの人々に信頼されています。総貸付額は104億ドルです。 分散型・自己管理型 - サービスを使用することにより、プロバイダーに同意したものとみなされます - 年間%s%の収益 + サービスを利用することにより、プロバイダー\n %1$sおよび%2$sに同意したことになります + 年間%s%%の収益 Aave • 変動金利 Aave 平均%s @@ -1670,6 +1674,8 @@ %sはAaveに供給され、すぐに利用可能になります 手数料ポリシーを見る 次回の入金は自動的にAaveに供給されます。 + アクティブ + 停止中 収益を停止する オフにすると、Aaveから資金が引き出され、ウォレットの%sに戻され、報酬の獲得が停止されます。 出金金額からネットワーク手数料が差し引かれます。 diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 60ebb679d5..2adc2175b5 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1,5 +1,6 @@ + Ваш кошелёк не защищён без кода доступа. Архив Вы архивируете свой аккаунт, но в любое время можете вернуть его обратно Аккаунт @@ -41,12 +42,14 @@ Токены в сети %1$s не поддерживаются этой картой или кольцом из-за ограничений прошивки. У вас возникли трудности со сканированием карты или кольца? Эта карта не предназначена для работы с этим приложением + Используйте %1$s, чтобы быстро и безопасно разблокировать кошелёк и выполнять чувствительные действия, например, подписывать транзакции. Для аппаратных кошельков всё ещё требуется карта или кольцо для подписи. Комиссия по-умолчанию Подключите функцию комиссии по умолчанию и при формировании транзакции на отправку средств комиссия будет выставлена автоматически, а экран комиссии пропущен. Вы всегда сможете на него вернуться. Перейдите в настройки, чтобы включить биометрическую аутентификацию в приложении Tangem Включите биометрическую аутентификацию Все сохраненные коды доступа будут удалены. Вам потребуется вводить код доступа при работе с кошельком. При отключении функции сохранения кошелька все ранее сохраненные кошельки будут удалены из приложения. + Эта опция отключает использование биометрии для выполнения чувствительных действий. Каждый раз, например при подписании транзакции, вам потребуется вводить код доступа. Сохранение кода доступа Подключите функцию хранения кодов доступа от карт на телефоне в зашифрованном виде, и при работе с картой или кольцом вместо кода доступа будет запрашиваться биометрическая аутентификация. Cохранение кошелька @@ -56,6 +59,12 @@ Как в системе Тема Настройки приложения + Эти слова невозможно восстановить, если они будут потеряны. Храните их в надёжном месте. + Ваша секретная фраза восстановления — это фиксированный набор из %s случайных слов для доступа к вашему кошельку и его восстановления. + Эти слова невозможно восстановить, если они будут потеряны. Храните их в безопасности. + Храните в безопасности + Никому не сообщайте эти слова. Tangem никогда не будет их спрашивать. Ниже приведены %s слов вашей фразы восстановления кошелька. Используйте их, чтобы восстановить кошелёк в случае потери устройства. + Запишите эти %s слов в указанном порядке и храните их в безопасности и в тайне. Чтобы скрыть или показать баланс, просто поверните ваше устройство вниз или отключите опцию его в разделе \"Настройки\" Больше не показывать Понятно @@ -422,14 +431,46 @@ в %s В сети %s Вы уверены, что хотите прервать процесс создания кода доступа? + Сделать бэкап + Чтобы завершить настройку, создайте резервную копию кошелька и защитите приложение кодом доступа. + Завершить сейчас + Закончить настройку кошелька + Завершите настройку, защитив приложение кодом доступа. + Если вы это сделаете, придётся начать заново. + Вы уверены, что хотите выйти из процесса активации? + Если вы это сделаете, придётся начать заново. + Восстановить существующий кошелёк через резервную копию Google Drive + Google Drive бэкап + Повысьте уровень безопасности с помощью продвинутого аппаратного кошелька Tangem. Аппаратный кошелёк - Будьте в курсе новых функций и новостей + Перейти к бэкапу + Пожалуйста, создайте резервную копию вашего кошелька перед установкой кода доступа. + Сначала завершите создание резервной копии + Не завершено + Физические устройства, которые надёжно хранят ваш приватный ключ офлайн. + Фраза восстановления + Ваши приватные ключи надёжно зашифрованы и хранятся на вашем телефоне + Ключи хранятся в приложении + Создайте или восстановите свой кошелёк с помощью фразы восстановления — вашей встроенной резервной копии + Резервная копия сид-фразы + Создать мобильный кошелек + Эта фраза восстановления уже была импортирована + Мобильный кошелек Это устройство не может быть использовано для апгрейда, оно уже содержит другой кошелек. + Выберите другое устройство. Это нельзя использовать для обновления. Во время операции произошла ошибка. + Ваши средства остаются в безопасности и полностью доступны в процессе. + Доступ к средствам + Данные вашего кошелька будут удалены из приложения и сохранены на вашем устройстве Tangem. + Общая безопасность Приватные ключи будут перемещены из приложения в вашу Tangem карту или кольцо Миграция ключей + Сканировать устройство Начать апгрейд - Tangem кошелек + Вы собираетесь перейти на устройство Tangem, где ваши активы будут в безопасности в холодном хранилище. + Tangem Wallet + Обновиться до аппаратного кошелька + Храните свою криптовалюту в безопасности с помощью первоклассного аппаратного кошелька Tangem. Сделать апгрейд кошелька до аппаратной версии. Эта информация была сгенерирована ИИ.\nНажмите здесь, если обнаружили ошибку. Чтобы изменить код доступа, приложите карту или кольцо как показано выше и не убирайте до окончания операции @@ -474,7 +515,7 @@ %1$d из %2$d кошельков Удалить - например Bitcoin + Например Bitcoin Ваш портфель был обновлен Выбранный токен не доступен в кошельке на данный момент. Но не переживайте, вы можете выразить свой интерес проголосовав за его добавление. Голосовать @@ -644,6 +685,9 @@ Присоединиться Поделись промокодом — заработай 5 USDT с каждой покупки. Твои друзья получат скидку 10% на карту Tangem! Получай бонусы за каждого друга! + Купить криптовалюту + Наслаждайтесь **нулевой комиссией** при покупке криптовалюты через SEPA-переводы. + Покупайте крипту через SEPA Установите единый код доступа для защиты всех ваших карт или колец Защита Установите индивидуальный код доступа для каждой карты или кольца позже. @@ -661,7 +705,7 @@ Добавление токенов Вы добавили одну резервную карту или кольцо. После того, как процесс будет завершен, Вы больше не сможете добавить еще. Если у Вас есть еще одна карта или кольцо, добавьте ее в резервную копию. Хотите продолжить? Процесс резервного копирования почти завершен. Вы не можете выйти из него сейчас. - Парольная фраза — это расширенная функция безопасности, которую используют криптокошельки. Она добавляет дополнительное слово или фразу по вашему выбору к уже существующей seed - фразе, чтобы разблокировать совершенно новый набор адресов. + Парольная фраза — это дополнительная функция безопасности, которая добавляет слово или фразу к вашей фразе восстановления, создавая новый набор адресов кошелька для дополнительной защиты. Добавить карту или кольцо Сканировать карту Сканировать карту #%d @@ -1269,6 +1313,15 @@ Подключение к dApps WalletConnect Подключение может занять несколько секунд + от %s + Купите Tangem Wallet — физическое устройство, которое надёжно хранит ваш приватный ключ офлайн. + Аппаратный кошелёк + Создайте безопасный кошелёк на своём телефоне за секунды. + Мобильный кошелек + Что выбрать + Уже используете Tangem Wallet? + Отсканировать + Выберите способ создания кошелька Рыночная цена %s за 24 часа Сеть %s diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 0e1658114b..a9c4cfc398 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -412,7 +412,6 @@ Сканувати в %s В мережі %s - Будьте в курсі нових можливостей і новин Ця інформація була створена за допомогою ШІ.\nНатисніть тут, якщо знайшли помилку. Щоб змінити код доступу, прикладіть картку або кільце, як показано вище, і не прибирайте її до закінчення операції Щоб змінити пароль, прикладіть картку, як показано вище, і не прибирайте її до закінчення операції @@ -456,7 +455,7 @@ %1$d із %2$d гаманців Видалити - наприклад Bitcoin + Наприклад Bitcoin Ваше портфоліо оновлено Обраний токен наразі недоступний для дій у криптогаманці. Але не хвилюйтеся, ви можете висловити свою зацікавленість, проголосувавши за його інтеграцію. Проголосувати @@ -626,6 +625,9 @@ Приєднатися Поділіться промокодом — заробіть 5 USDT з кожної покупки. Ваші друзі отримають знижку 10% на картку Tangem! Отримуй бонуси за кожного друга! + Купити криптовалюту + Насолоджуйтесь **нульовою комісією** при купівлі криптовалюти через перекази SEPA. + Купуйте криптовалюту через SEPA Налаштуйте єдиний код доступу для захисту всіх ваших карток або кілець Захист Встановіть індивідуальний код доступу для кожної картки або кільця пізніше. diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index d35ab1c6cf..f276d1abc5 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1,16 +1,16 @@ - Your wallet won’t be protected without an Access Code. + Without an access code, your wallet is not secure. Skip anyway - Access Code not set - Enter Access Code - Wrong access code. Your hot wallet will be deleted after %s more incorrect attempts. - Wrong access code. App will be locked with %s more input errors + Access code not set + Enter access code + Wrong access code. Your mobile wallet will be deleted after %s more incorrect attempts. + Wrong access code. The app will be locked after %s more failed attempts Wrong access code.\nPlease wait %s seconds and try again. - Confirm your previously entered code to continue - Re-enter Access Code - Set a %s-digit Access Code to unlock your wallet. - Create Access Code + Confirm your access code to continue + Re-enter access code + Set a %s-digit access code to unlock your wallet. + Create access code Access code You cannot create more than %1$s accounts. Archive one to add new. Can’t add new account @@ -77,7 +77,7 @@ Tokens in %1$s network are not supported by this card or ring due to firmware limitation. Are you having difficulty scanning your card or ring? This card is not designed to work with this app - Use %1$s to quickly and securely unlock your wallet and authorize all sensitive actions, such as signing transactions. For hardware wallets, you will still need a card to sign. + Use %1$s to unlock your wallet and approve sensitive actions, like signing transactions. For hardware wallets, a card or ring is still required to sign. Default Fee Enable Default Fee to set transaction fees automatically and skip the Fee page when sending funds. You can always go back to this page if necessary. Go to settings to enable biometric authentication in the Tangem App @@ -88,7 +88,7 @@ Removing the saved devices deletes all the saved wallets and their access codes from the app. This will delete all the saved wallet access codes. Any further interaction with the wallet will require submitting the access code. Require Access Code - This option disables biometric authentication for sensitive actions. You will be required to enter your access code every time, such as when signing a transaction. + This option turns off biometrics for sensitive actions. You’ll need to enter your access code each time you sign a transaction. Save Access Code Biometric authentication will be requested instead of the access code for interactions with your card or ring. Keep the wallet in the app @@ -98,26 +98,26 @@ System default Theme App settings - Add Wallet + Add wallet Select a wallet to log in Welcome back! %d card %d cards - Phone Wallet + Mobile Wallet You successfully backed up your wallet. - These words can’t be recovered if lost. Make sure to keep it somewhere secure. - Backup Completed - Your Secret Recovery Phrase is a fixed set of %s random words used to access and recover your wallet. - These words can’t be recovered if lost. Make sure to keep it somewhere secure. - Keep It Safe - Save these %s words in a secure location, such as a password manager, and never share them with anyone. - No Recovery Possible + These words are unrecoverable if lost. Keep them somewhere safe. + Backup completed + Your secret recovery phrase is a fixed set of %s random words for accessing and recovering your wallet. + These words cannot be recovered if lost. Keep them safe. + Keep it safe + Save these %s words in a secure location and never share them with anyone. + No recovery possible Recovery phrase - Never share these words. Anyone who learns them can steal all of your crypto. Tangem will never ask you for them. The %s words below are your wallet\'s recovery phrase. This phrase lets you recover your wallet if you lose your device. - Write down these %s words in order and keep them safe and private - Full responsibility for the security and backup of the wallet and recovery phrase lies with the user, not with Tangem. + Never share these words with anyone. Tangem will never ask you for them. The %s words below are your wallet\'s recovery phrase. Use them to restore your wallet if you lose your device. + Write down these %s words in numerical order and keep them safe and private + You are fully responsible for securing and backing up your wallet and recovery phrase. Recovery phrase To hide or show your balances, simply flip your device screen down, or switch it off in Settings Don\'t show again @@ -200,6 +200,7 @@ You have not given access to your camera, please adjust your privacy settings Cancel Change + Choose account Choose action Choose network Choose token @@ -207,7 +208,7 @@ Claim Claim rewards Close - Coming Soon + Coming soon Confirm Connecting Contact Tangem Support @@ -511,48 +512,48 @@ Do you want to allow “Tangem” to use biometric authentication? To confirm your identity and open the app to %s On %s network - Are you sure you want to exit the access code creation process? - Backup Now - To complete setup, back up your wallet and secure app access with a Access Code. - Finish Now - Finish Wallet Activation - To complete setup, secure app access with Access Code. + Are you sure you want to cancel access code setup? + Backup now + To complete setup, back up your wallet and secure the app with an access code. + Finalize now + Finalize wallet setup + Complete setup by securing the app with an access code. If you do, you\'ll need to start over. - Are you sure you want to exit the activation process? + Are you sure you want to quit the activation process? If you do, you\'ll need to start over. - Recover an existing wallet stored in your Google Drive backup - Google Drive Backup - Upgrade your security right away with a best in class hardware wallet from Tangem. + Recover existing wallet via Google Drive backup + Google Drive backup + Level up your security with the superior Tangem hardware wallet. Hardware Wallet Go to backup - To secure your wallet with a Access Code, complete the backup first. - Finish Backup First - No backup - Physical cards that securely store your crypto offline. + Please back up your wallet before creating an access code. + Finalize backup first + Incomplete + Physical devices that securely store your private key offline. Recovery phrase - Get notified of incoming transactions + Your private keys are securely encrypted and stored on your phone Keys are stored in the app - Stay up to date with the latest features and news + Create or restore your wallet using a recovery phrase — your built-in backup. Seed phrase backup Create Mobile Wallet This recovery phrase has already been imported Mobile Wallet - This device can’t be used for upgrade, it already contains another wallet. - Choose another device, this one can’t be used for upgrade. + Can’t upgrade. A wallet already exists on this device. + Pick another device. This one can’t be used for the upgrade. An error occurred during the operation. - Your funds stay safe and fully accessible during the process - Funds access - All private wallet data will be removed from the mobile app and stored securely on your Tangem device only - General Security - Private keys will be moved from the app to your Tangem card or ring + Your funds remain safe and fully accessible during the process + Access to funds + Your wallet data will be erased from the app and stored on your Tangem device + General security + Private keys will be moved from the app to your Tangem device Key Migration Scan device Start upgrade - You’re about to upgrade your wallet to Tangem Wallet. This will keep your assets safe with cold storage. + You’re about to upgrade to our hardware wallet. This will keep your assets safe in cold storage. Tangem Wallet Upgrade to Hardware Wallet - Keep your crypto safe with Tangem’s best-in-class hardware wallet. - Upgrade wallet with a hardware backup + Keep your crypto safe with Tangem\'s top-tier hardware wallet. + Upgrade your wallet to hardware security This information was generated with AI.\nTap here, if you find any errors. To change the access code tap the card or ring as shown above and do not remove until the end of the operation To change the passcode tap the card as shown above and do not remove until the end of the operation @@ -594,7 +595,7 @@ %1$d of %2$d wallets Remove - e.g. BTC I trust, hodl I must + e.g., Bitcoin Your portfolio has been updated The selected token is currently unavailable for actions within the crypto wallet. But worry not, you can express your interest by upvoting it. Upvote @@ -758,7 +759,7 @@ Share your code - earn 5 USDT per sale. Your friend gets 10% OFF. Get REWARDS for every friend! Buy crypto - Enjoy zero fees when purchasing crypto via SEPA transfer. + Enjoy **0% fees** when purchasing crypto via SEPA transfers. Buy Crypto with SEPA Set up a single access code to protect all your devices. Protect @@ -777,7 +778,7 @@ Add tokens You\'ve added one backup card or ring. When backup process is finished you can\'t add more backup devices. If you have one more card or ring, add it to the backup. Would you like to continue the backup process? The backup process is partly complete. You can\'t exit it now. - The passphrase is an advanced security feature that crypto wallets use. It adds an extra word or phrase of your own choosing to your already existing recovery phrase to unlock a brand-new set of addresses. + A passphrase is an optional security feature that adds a word or phrase to your recovery phrase, creating a new set of wallet addresses for extra protection. Add a card or ring Scan card Scan card #%d @@ -805,7 +806,7 @@ Another wallet has already been created on the card you\'re trying to add. If you have funds in this wallet, please withdraw it and then reset this card and add it as a backup. Save your wallet Creating a backup - Use biometrics + Biometrics Read more about seed phrase @@ -869,6 +870,7 @@ Search by currency Instant By using onramp functionality, you agree with provider’s %1$s and %2$s + Service is provided by an external provider.\nTangem is not responsible. The purchase amount should be no more than %s The amount to buy must be at least %s No available providers for this currency @@ -1266,6 +1268,14 @@ You receive Choose token not available + Get card + Add to your wallet and pay with your phone anywhere. + Apple Pay & Google Pay + Use your USDC balance to pay for everyday purchases seamlessly. + Everyday purchases + Your card details are protected — full control in the app. + Built-in security + Get your free Crypto Card\nin minutes This is my wallet Balances hidden Balances shown @@ -1334,9 +1344,15 @@ Use %s or scan a card/ring to have access to your wallet Connection failed: This dApp uses Wallet Connect version 1.0, which is not supported. Please ensure the dApp supports Wallet Connect version 2.0 to connect successfully. Stay up to date with the latest features and news + Real-time alerts for transactions, exchanges, and critical updates. + Transaction Alerts Get notified of incoming transactions Be the first to know about new promotions + Early access to fresh features and exclusive offers. + Feature and News Updates Would you like to use\nPush-notifications? + Enable push notifications and we’ll notify you instantly when funds arrive\n + Don’t Miss a Transaction Add new wallet Are you sure you want to forget this wallet? An error has occurred, please scan your card or ring to log in @@ -1434,6 +1450,8 @@ Wrong card or ring selected in Tangem App Failed to create transaction from Dapp data. Code: %s We\'ve encountered unknown error. Error code: %d. If the problem persists — feel free to contact our support + Multiple transactions + You’ll need to tap your Tangem device a few times to complete this process. No opened WalletConnect sessions Ooops. No Sessions. Failed to pairing WalletConnect session: %1$s @@ -1445,32 +1463,34 @@ This card can\'t be used to establish WalletConnect session This network is not supported. Please select another network. Select network + We\'re processing the transaction + Sending your funds... WalletConnect Sessions Connect to dApps WalletConnect Connecting may take a few seconds From %s - Buy Tangem Wallet — physical cards that securely store your crypto offline. + Buy Tangem Wallet—a physical device that securely stores your private key offline. Hardware Wallet - A secure wallet is created on your phone in seconds. + Create a secure wallet on your phone in seconds. Mobile Wallet - What to choose? - Do you already have Tangem Wallet? - Scan Now - Choose how you want to create your wallet - Want to purchase a Tangem Wallet? - Buy Now - Recover an existing wallet stored in your Google Drive backup + What to pick + Using a Tangem Wallet already? + Scan now + Pick a wallet setup method + Ready to get a Tangem Wallet? + Buy now + Recover existing wallet via Google Drive backup Import from Google Drive Add existing wallet - Physical cards that securely store your crypto offline. + Physical devices that securely store your private key offline. Scan a Tangem Wallet - Your seed phrase to recover your wallet + Import an existing wallet with your recovery phrase. Import wallet - Import a recovery phrase + Enter recovery phrase You successfully backed up your wallet. Import wallet - Import Completed + Import completed Import wallet %s Market Price last 24h @@ -1480,13 +1500,13 @@ Get now with 10% off Access 13,000+ cryptocurrencies. Buy, sell, swap, and stake with a single tap.\nLink up to three cards for a backup. Discover Tangem Wallet - Secret code to protect this wallet. Used for login and signatures. - Set/Change Access Code - Change Access Code + This secret code protects your wallet and is used to log in and sign transactions. + Set/Change access code + Change access code Stay notified on wallet incoming transactions and Tangem updates. Push notifications may currently not work on Huawei devices. We\'re actively working on a solution and will release a fix in an upcoming update. Thank you for your understanding! Transaction Notifications - Set Access Code + Set access code Wallet settings Tangem Use %s or scan a card/ring to unlock access to your wallet @@ -1614,6 +1634,7 @@ We\'ve got some kind of problem All dApps disconnected Allow to spend + By approving, you allow dApp or Smart contract to use tokens in future transactions. Address Connect Loading @@ -1702,11 +1723,9 @@ Current APY My Funds Your %1$s is now deposited in Aave and earning interest. You hold a%2$s token, which represents your balance and grows over time. When you top up, funds go to Aave to earn interest, minus a transaction fee. - Active Earn Total earnings Transfers to Aave - Text explaining that your savings are located in the protocol Explore Aave Your %s is deposited in Aave This is the current supply fee on %s. The live cost will be shown on the Receive Screen. @@ -1715,7 +1734,6 @@ If network fees rise above maximum fee, the transaction won’t go through until they decrease. You can change this limit later. Maximum fee Fee policy - Write description here. In one, two or three lines will be awesome. Some token approve needed Every top-up of your account will be lended to Aave automatically. Your balance works automatically @@ -1724,8 +1742,8 @@ How it works? Aave is trusted by millions worldwide. Total lended value is $10.4B. Decentralized and self-custodial - By using service, you agree with provider - Earn %s% yearly + By using service, you agree with provider\n%1$s and %2$s + Earn %s%% yearly Aave • Variable Interest Rate Aave Avg %s @@ -1738,6 +1756,8 @@ Your %s will be supplied to Aave and will stay instantly available See fee policy Your next deposits will be automatically supplied to Aave. + Active + Paused Stop earning Turning off will withdraw your funds from Aave, return them to %s in your wallet, and stop earning rewards. The network fee will be deducted from the amount you withdraw. diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt index be5351a233..7b4e6dbb70 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt @@ -112,7 +112,7 @@ fun PrimaryButton( @Composable fun PrimaryButtonIconEnd( text: String, - @DrawableRes iconResId: Int, + @DrawableRes iconResId: Int?, onClick: () -> Unit, modifier: Modifier = Modifier, size: TangemButtonSize = TangemButtonSize.Default, @@ -122,7 +122,7 @@ fun PrimaryButtonIconEnd( TangemButton( modifier = modifier, text = text, - icon = TangemButtonIconPosition.End(iconResId), + icon = if (iconResId != null) TangemButtonIconPosition.End(iconResId) else TangemButtonIconPosition.None, onClick = onClick, colors = TangemButtonsDefaults.primaryButtonColors, enabled = enabled, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt index bf1fc3a926..b8955c8b80 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt @@ -30,7 +30,7 @@ import com.tangem.core.ui.components.fields.SimpleDialogTextField 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.DialogTestTags +import com.tangem.core.ui.test.BaseDialogTestTags import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -219,13 +219,14 @@ private fun TangemDialog( color = TangemTheme.colors.background.primary, ) .padding(vertical = TangemTheme.dimens.spacing24) - .testTag(DialogTestTags.DIALOG_CONTAINER), + .testTag(BaseDialogTestTags.CONTAINER), ) { if (title != null) { Text( modifier = Modifier .padding(horizontal = TangemTheme.dimens.spacing24) - .fillMaxWidth(), + .fillMaxWidth() + .testTag(BaseDialogTestTags.TITLE), text = title, style = when (type) { is DialogType.Message -> TangemTheme.typography.h2 @@ -262,7 +263,8 @@ private fun DialogContent(type: DialogType, modifier: Modifier = Modifier) { Text( modifier = Modifier .padding(horizontal = TangemTheme.dimens.spacing24) - .fillMaxWidth(), + .fillMaxWidth() + .testTag(BaseDialogTestTags.TEXT), text = type.message, style = TangemTheme.typography.body2, color = TangemTheme.colors.text.secondary, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/FullScreen.kt b/core/ui/src/main/java/com/tangem/core/ui/components/FullScreen.kt index 965c9c4cf0..fff3c42836 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/FullScreen.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/FullScreen.kt @@ -3,6 +3,7 @@ package com.tangem.core.ui.components import android.annotation.SuppressLint import android.content.Context import android.graphics.PixelFormat +import android.os.Build import android.view.KeyEvent import android.view.View import android.view.WindowManager @@ -100,8 +101,12 @@ private class FullScreenLayout( width = WindowManager.LayoutParams.MATCH_PARENT height = WindowManager.LayoutParams.MATCH_PARENT format = PixelFormat.TRANSLUCENT - flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or - WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + + if (focusable && Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { + softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + } else { + flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + } } fun show() { @@ -142,7 +147,7 @@ private class FullScreenLayout( fun dispose() { dismiss() - setViewTreeSavedStateRegistryOwner(null) + setViewTreeLifecycleOwner(null) setViewTreeSavedStateRegistryOwner(null) setViewTreeViewModelStoreOwner(null) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt index b1b8fa87b0..b49c5737ad 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt @@ -11,8 +11,10 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.BaseBottomSheetTestTags /** * [Show in Figma](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=281-248&mode=design&t=bXqehWPHyATKcZEW-4) @@ -38,7 +40,7 @@ fun SimpleSettingsRow( onItemsClick() } }, - ), + ).testTag(BaseBottomSheetTestTags.ACTION_TITLE), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, ) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt b/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt index 972f347106..de1f9aa454 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt @@ -15,10 +15,12 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.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.test.MarketsTestTags @Suppress("MagicNumber") @Composable @@ -56,7 +58,7 @@ fun TangemSwitch( onClick = { onCheckedChange(!checked) }, - ), + ).testTag(MarketsTestTags.ADD_TO_PORTFOLIO_SWITCH), ) { BoxWithConstraints( modifier = Modifier diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt new file mode 100644 index 0000000000..ab7844aaa5 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt @@ -0,0 +1,132 @@ +package com.tangem.core.ui.components.account + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +enum class AccountIconSize { + Default, Large, Medium, Small, ExtraSmall +} + +/** + * Displays a portfolio icon using a predefined vector resource. + * + * The background color is defined by [color], and the icon tint is constant white. + * The icon size and container shape are adapted based on the provided [size]. + * + * @param resId The vector drawable resource ID to display. + * @param color The background color of the icon container. + * @param size The size of the icon, defined by [AccountIconSize]. + */ +@Composable +fun AccountResIcon(@DrawableRes resId: Int, color: Color, size: AccountIconSize, modifier: Modifier = Modifier) { + val boxModifier = modifier.selectBoxModifier(size) + val iconSize = Modifier.selectIconSize(size) + Box( + contentAlignment = Alignment.Center, + modifier = boxModifier.background(color), + ) { + Icon( + modifier = iconSize, + tint = TangemTheme.colors.text.constantWhite, + imageVector = ImageVector.vectorResource(id = resId), + contentDescription = null, + ) + } +} + +/** + * Displays a portfolio icon using a single character. + * + * The background color is defined by [color], the text is uppercased, + * and its style is chosen based on the provided [size]. + * The container shape and size also adapt to [size]. + * + * @param char The character to display inside the icon. + * @param color The background color of the icon container. + * @param size The size of the icon, defined by [AccountIconSize]. + */ +@Composable +fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: Modifier = Modifier) { + val boxModifier = modifier.selectBoxModifier(size) + val textStyle = when (size) { + AccountIconSize.Default -> TangemTheme.typography.h3 + AccountIconSize.Large -> TangemTheme.typography.h1 + AccountIconSize.Medium -> TangemTheme.typography.subtitle1 + AccountIconSize.Small -> TangemTheme.typography.subtitle2 + AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1 + } + Box( + contentAlignment = Alignment.Center, + modifier = boxModifier.background(color), + ) { + Text( + text = char.uppercase(), + style = textStyle, + color = TangemTheme.colors.text.constantWhite, + ) + } +} + +private fun Modifier.selectIconSize(size: AccountIconSize): Modifier = when (size) { + AccountIconSize.Default -> this.size(20.dp) + AccountIconSize.Large -> this.size(40.dp) + AccountIconSize.Medium -> this.size(16.dp) + AccountIconSize.Small -> this.size(12.dp) + AccountIconSize.ExtraSmall -> this.size(8.dp) +} + +private fun Modifier.selectBoxModifier(size: AccountIconSize): Modifier = when (size) { + AccountIconSize.Default -> size(36.dp).clip(RoundedCornerShape(10.dp)) + AccountIconSize.Large -> size(88.dp).clip(RoundedCornerShape(24.dp)) + AccountIconSize.Medium -> size(28.dp).clip(RoundedCornerShape(8.dp)) + AccountIconSize.Small -> size(20.dp).clip(RoundedCornerShape(6.dp)) + AccountIconSize.ExtraSmall -> size(14.dp).clip(RoundedCornerShape(4.dp)) +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + Row( + modifier = Modifier.background(TangemTheme.colors.background.primary), + ) { + Sample() + } + } +} + +@Composable +private fun Sample() { + Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { + AccountResIcon(resId = R.drawable.ic_shirt_24, color = Color.Red, size = AccountIconSize.Default) + AccountResIcon(resId = R.drawable.ic_rounded_star_24, color = Color.Blue, size = AccountIconSize.Large) + AccountResIcon(resId = R.drawable.ic_user_24, color = Color.Magenta, size = AccountIconSize.Medium) + AccountResIcon(resId = R.drawable.ic_family_24, color = Color.DarkGray, size = AccountIconSize.Small) + AccountResIcon(resId = R.drawable.ic_wallet_24, color = Color.Green, size = AccountIconSize.ExtraSmall) + } + Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { + AccountCharIcon(char = 'D', color = Color.Red, size = AccountIconSize.Default) + AccountCharIcon(char = 'L', color = Color.Blue, size = AccountIconSize.Large) + AccountCharIcon(char = 'M', color = Color.Magenta, size = AccountIconSize.Medium) + AccountCharIcon(char = 'S', color = Color.DarkGray, size = AccountIconSize.Small) + AccountCharIcon(char = 'E', color = Color.Green, size = AccountIconSize.ExtraSmall) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/models/TopAppBarButtonUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/models/TopAppBarButtonUM.kt index aa06b614a0..3d1549e138 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/models/TopAppBarButtonUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/models/TopAppBarButtonUM.kt @@ -32,6 +32,12 @@ sealed class TopAppBarButtonUM( enabled = enabled, ) + fun Close(enabled: Boolean = true, onCloseClick: () -> Unit) = Icon( + iconRes = R.drawable.ic_close_24, + onClicked = onCloseClick, + enabled = enabled, + ) + fun Text(text: TextReference, onTextClicked: () -> Unit, enabled: Boolean = true) = Text( text = text, onClicked = onTextClicked, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt index dc68d70134..196a5f2012 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt @@ -49,6 +49,8 @@ inline fun TangemModalBottomSheet( config: TangemBottomSheetConfig, containerColor: Color = TangemTheme.colors.background.primary, skipPartiallyExpanded: Boolean = true, + dismissOnClickOutside: Boolean = true, + scrollableContent: Boolean = true, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit = {}, crossinline content: @Composable ColumnScope.(T) -> Unit, @@ -61,6 +63,7 @@ inline fun TangemModalBottomSheet( containerColor = containerColor, title = title, content = content, + scrollableContent = scrollableContent, skipPartiallyExpanded = skipPartiallyExpanded, ) } else { @@ -69,6 +72,8 @@ inline fun TangemModalBottomSheet( containerColor = containerColor, title = title, content = content, + dismissOnClickOutside = dismissOnClickOutside, + scrollableContent = scrollableContent, onBack = onBack, skipPartiallyExpanded = skipPartiallyExpanded, ) @@ -81,21 +86,38 @@ inline fun DefaultModalBottomSheet( config: TangemBottomSheetConfig, containerColor: Color, skipPartiallyExpanded: Boolean = true, + dismissOnClickOutside: Boolean = true, + scrollableContent: Boolean = true, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable (BoxScope.(T) -> Unit), crossinline content: @Composable (ColumnScope.(T) -> Unit), ) { var isVisible by remember { mutableStateOf(value = config.isShown) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = skipPartiallyExpanded, + confirmValueChange = { + if (!dismissOnClickOutside) { + it != SheetValue.Hidden // Ignore transitions to hidden (prevents dismiss on outside click/back press) + } else { + true + } + }, + ) if (isVisible && config.content is T) { BasicModalBottomSheet( config = config, sheetState = sheetState, - containerColor = containerColor, - title = title, onBack = onBack, - content = content, + bsContent = { + BsContent( + config = config, + containerColor = containerColor, + scrollableContent = scrollableContent, + title = title, + content = content, + ) + }, ) } @@ -114,6 +136,7 @@ inline fun PreviewModalBottomSheet( config: TangemBottomSheetConfig, containerColor: Color, skipPartiallyExpanded: Boolean = true, + scrollableContent: Boolean = true, crossinline title: @Composable (BoxScope.(T) -> Unit), crossinline content: @Composable (ColumnScope.(T) -> Unit), ) { @@ -125,49 +148,64 @@ inline fun PreviewModalBottomSheet( density = LocalDensity.current, ), onBack = null, - containerColor = containerColor, - title = title, - content = content, + bsContent = { + BsContent( + config = config, + containerColor = containerColor, + scrollableContent = scrollableContent, + title = title, + content = content, + ) + }, ) } +@Composable +inline fun BsContent( + config: TangemBottomSheetConfig, + containerColor: Color, + scrollableContent: Boolean = true, + crossinline title: @Composable (BoxScope.(T) -> Unit), + crossinline content: @Composable (ColumnScope.(T) -> Unit), +) { + val model = config.content as? T ?: return + + val maxHeight = LocalConfiguration.current.screenHeightDp * MODAL_SHEET_MAX_HEIGHT + + Column( + modifier = Modifier + .systemBarsPadding() + .padding(horizontal = 8.dp, vertical = 8.dp) + .clip(TangemTheme.shapes.roundedCornersLarge) + .background(containerColor) + .heightIn(max = maxHeight.dp) + .fillMaxWidth(), + ) { + Box(modifier = Modifier.fillMaxWidth()) { + title(model) + } + if (scrollableContent) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + content(model) + } + } else { + content(model) + } + } +} + @Suppress("LongParameterList") @OptIn(ExperimentalMaterial3Api::class) @Composable inline fun BasicModalBottomSheet( config: TangemBottomSheetConfig, sheetState: SheetState, - containerColor: Color, noinline onBack: (() -> Unit)? = null, - crossinline title: @Composable (BoxScope.(T) -> Unit), - crossinline content: @Composable (ColumnScope.(T) -> Unit), + noinline bsContent: @Composable ColumnScope.() -> Unit, modifier: Modifier = Modifier, ) { - val model = config.content as? T ?: return - - val bsContent: @Composable ColumnScope.() -> Unit = { - val maxHeight = LocalConfiguration.current.screenHeightDp * MODAL_SHEET_MAX_HEIGHT - - Column( - modifier = Modifier - .systemBarsPadding() - .padding(horizontal = 8.dp, vertical = 8.dp) - .clip(TangemTheme.shapes.roundedCornersLarge) - .background(containerColor) - .heightIn(max = maxHeight.dp) - .fillMaxWidth(), - ) { - Box(modifier = Modifier.fillMaxWidth()) { - title(model) - } - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - content(model) - } - } - } - if (onBack != null) { ModalBottomSheetWithBackHandling( modifier = modifier, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetTitle.kt index 336bec808f..c4f1b6ed48 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetTitle.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -20,6 +21,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.WalletConnectDetailsBottomSheetTestTags /** * Title component for [TangemModalBottomSheet] with [TangemIconButton] for buttons. @@ -54,7 +56,9 @@ fun TangemModalBottomSheetTitle( text = title.resolveReference(), style = TangemTheme.typography.subtitle1, color = TangemTheme.colors.text.primary1, - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag(WalletConnectDetailsBottomSheetTestTags.TITLE), ) } if (subtitle != null) { @@ -62,7 +66,9 @@ fun TangemModalBottomSheetTitle( text = subtitle.resolveReference(), style = TangemTheme.typography.caption1, color = TangemTheme.colors.text.tertiary, - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag(WalletConnectDetailsBottomSheetTestTags.DATE), ) } } @@ -72,7 +78,8 @@ fun TangemModalBottomSheetTitle( onClick = onEndClick, modifier = Modifier .padding(16.dp) - .align(Alignment.CenterEnd), + .align(Alignment.CenterEnd) + .testTag(WalletConnectDetailsBottomSheetTestTags.CLOSE_BUTTON), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt index 9964ac424d..3826cc503f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt @@ -1,6 +1,7 @@ package com.tangem.core.ui.components.bottomsheets.modal import android.content.res.Configuration +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -49,7 +50,7 @@ inline fun TangemModalBottomSheetWi noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit = {}, crossinline content: @Composable (T) -> Unit, - crossinline footer: @Composable (BoxScope.(T) -> Unit), + noinline footer: @Composable (BoxScope.(T) -> Unit)?, ) { val isAlwaysVisible = LocalBottomSheetAlwaysVisible.current @@ -84,7 +85,7 @@ inline fun DefaultModalBottomSheetW noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, - crossinline footer: @Composable (BoxScope.(T) -> Unit), + noinline footer: @Composable (BoxScope.(T) -> Unit)?, ) { var isVisible by remember { mutableStateOf(value = config.isShown) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded) @@ -118,7 +119,7 @@ inline fun PreviewModalBottomSheetW skipPartiallyExpanded: Boolean = true, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, - crossinline footer: @Composable BoxScope.(T) -> Unit, + noinline footer: @Composable (BoxScope.(T) -> Unit)?, ) { BasicModalBottomSheetWithFooter( config = config, @@ -145,7 +146,7 @@ inline fun BasicModalBottomSheetWit noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, - crossinline footer: @Composable (BoxScope.(T) -> Unit), + noinline footer: @Composable (BoxScope.(T) -> Unit)?, modifier: Modifier = Modifier, ) { val model = config.content as? T ?: return @@ -156,8 +157,13 @@ inline fun BasicModalBottomSheetWit val scrollState = rememberScrollState(initial = initial) val isKeyboardOpen by rememberIsKeyboardVisible() - val buttonHeight = TangemTheme.dimens.spacing80 - val contentBottomPadding = TangemTheme.dimens.spacing80 + val buttonHeight by animateDpAsState( + if (footer != null) { + 80.dp + } else { + 0.dp + }, + ) // Offset calculation for keyboard scroll adjustment: // 1) Button height (footer) // 2) Column content bottom padding @@ -202,7 +208,7 @@ inline fun BasicModalBottomSheetWit Column( modifier = Modifier .verticalScroll(state = scrollState) - .padding(bottom = contentBottomPadding), + .padding(bottom = buttonHeight), ) { content(model) } @@ -218,7 +224,9 @@ inline fun BasicModalBottomSheetWit .height(buttonHeight) .align(Alignment.BottomCenter), ) { - footer(model) + if (footer != null) { + footer(model) + } } } } 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 028be341c1..4c44d3992a 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 @@ -2,12 +2,13 @@ package com.tangem.core.ui.components.containers import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.res.TangemTheme @@ -17,14 +18,14 @@ import com.tangem.core.ui.res.TangemTheme * * @param modifier of component * @param footer text - * @param footerTopPadding padding between footer and field + * @param paddingValues padding between footer and field * @param content field content */ @Composable fun FooterContainer( modifier: Modifier = Modifier, footer: TextReference? = null, - footerTopPadding: Dp = TangemTheme.dimens.spacing8, + paddingValues: PaddingValues = PaddingValues(top = 8.dp), content: @Composable () -> Unit, ) { Column(modifier = modifier) { @@ -36,7 +37,7 @@ fun FooterContainer( style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, modifier = Modifier - .padding(top = footerTopPadding), + .padding(paddingValues), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt index 52d7c920a6..02e42068d6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt @@ -13,7 +13,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import com.tangem.core.ui.R +import com.tangem.core.ui.components.account.AccountCharIcon +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.account.AccountResIcon import com.tangem.core.ui.components.currency.DefaultCurrencyIcon +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme @Composable @@ -58,6 +62,18 @@ internal fun ContentIcon( background = icon.background, alpha = alpha, ) + is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon( + modifier = modifier, + resId = icon.resId, + color = icon.color, + size = AccountIconSize.Default, + ) + is CurrencyIconState.CryptoPortfolio.Letter -> AccountCharIcon( + modifier = modifier, + char = icon.char.resolveReference().first(), + color = icon.color, + size = AccountIconSize.Default, + ) CurrencyIconState.Loading, CurrencyIconState.Locked, is CurrencyIconState.Empty, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt index ff97530009..04ede1d5ba 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt @@ -48,6 +48,8 @@ fun CurrencyIcon( is CurrencyIconState.FiatIcon, is CurrencyIconState.CustomTokenIcon, is CurrencyIconState.TokenIcon, + is CurrencyIconState.CryptoPortfolio.Icon, + is CurrencyIconState.CryptoPortfolio.Letter, -> { ContentIconContainer( icon = state, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt index 5ebf4ad190..65107776ea 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt @@ -4,6 +4,7 @@ import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference /** * Represents the various states an icon can be in. @@ -86,6 +87,25 @@ sealed class CurrencyIconState { override val topBadgeIconResId: Int? = null } + @Immutable + sealed class CryptoPortfolio : CurrencyIconState() { + override val showCustomBadge: Boolean = false + override val topBadgeIconResId: Int? = null + abstract val color: Color + + data class Icon( + @DrawableRes val resId: Int, + override val color: Color, + override val isGrayscale: Boolean, + ) : CryptoPortfolio() + + data class Letter( + val char: TextReference, + override val color: Color, + override val isGrayscale: Boolean, + ) : CryptoPortfolio() + } + data object Loading : CurrencyIconState() { override val isGrayscale: Boolean = false override val showCustomBadge: Boolean = false @@ -125,6 +145,12 @@ sealed class CurrencyIconState { showCustomBadge = showCustomBadge, topBadgeIconResId = topBadgeIconResId, ) + is CryptoPortfolio.Icon -> copy( + isGrayscale = isGrayscale, + ) + is CryptoPortfolio.Letter -> copy( + isGrayscale = isGrayscale, + ) is Loading, is Locked, is Empty, 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 2219e2642d..3ec5ffc54c 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 @@ -24,7 +24,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags import com.tangem.core.ui.utils.* import java.math.BigDecimal import java.text.DecimalFormat @@ -106,7 +106,7 @@ fun AmountTextField( visualTransformation = visualTransformation, modifier = Modifier .background(backgroundColor) - .testTag(StakingSendScreenTestTags.INPUT_TEXT_FIELD), + .testTag(SendScreenTestTags.INPUT_TEXT_FIELD), ) } } 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 index 214da97109..a3226e0b46 100644 --- 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 @@ -25,7 +25,6 @@ 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 @@ -159,14 +158,17 @@ private fun CellDecoration( ) } } else { - Text( + Box( modifier = Modifier.sizeIn(minWidth = minWidth, minHeight = minHeight), - text = text, - style = TangemTheme.typography.h3, - color = color, - textAlign = TextAlign.Center, - lineHeight = 48.sp, - ) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = text, + style = TangemTheme.typography.h3, + color = color, + textAlign = TextAlign.Center, + ) + } } } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt index 2c45500ae0..94f6b64e78 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -39,7 +38,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.SelectCountryBottomSheetTestTags +import com.tangem.core.ui.test.BaseSearchBarTestTags @Composable fun SearchBar( @@ -47,10 +46,10 @@ fun SearchBar( modifier: Modifier = Modifier, colors: TextFieldColors = TangemSearchBarDefaults.defaultTextFieldColors, enabled: Boolean = true, + focusRequester: FocusRequester = remember { FocusRequester() }, ) { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val focusRequester = remember { FocusRequester() } val interactionSource = remember { MutableInteractionSource() } BasicTextField( @@ -65,7 +64,7 @@ fun SearchBar( } } .focusRequester(focusRequester) - .testTag(SelectCountryBottomSheetTestTags.SEARCH_BAR), + .testTag(BaseSearchBarTestTags.SEARCH_BAR), enabled = enabled, value = state.query, onValueChange = state.onQueryChange, @@ -97,10 +96,6 @@ fun SearchBar( ) }, ) - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } } @Suppress("LongParameterList") diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/grid/EnumeratedTwoColumnGrid.kt b/core/ui/src/main/java/com/tangem/core/ui/components/grid/EnumeratedTwoColumnGrid.kt new file mode 100644 index 0000000000..f3c88b8cde --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/grid/EnumeratedTwoColumnGrid.kt @@ -0,0 +1,104 @@ +package com.tangem.core.ui.components.grid + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * EnumeratedTwoColumnGrid component + * + * @param items component items + * @param modifier composable modifier + * + */ +@Composable +fun EnumeratedTwoColumnGrid(items: ImmutableList, modifier: Modifier = Modifier) { + VerticalGrid( + modifier = modifier, + items = items, + ) { item -> + Row( + modifier = Modifier.padding(all = TangemTheme.dimens.size8), + verticalAlignment = Alignment.CenterVertically, + ) { + if (LocalLayoutDirection.current == LayoutDirection.Ltr) { + Text( + modifier = Modifier.width(TangemTheme.dimens.size40), + text = "${item.index}.", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + Text( + text = item.mnemonic, + style = TangemTheme.typography.button, + color = TangemTheme.colors.text.primary1, + ) + } else { + Text( + text = item.mnemonic, + style = TangemTheme.typography.button, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier.width(TangemTheme.dimens.size40), + text = "${item.index}.", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } + } +} + +@Composable +private inline fun VerticalGrid( + items: ImmutableList, + modifier: Modifier = Modifier, + crossinline content: @Composable (T) -> Unit, +) { + val columnLength = items.size / 2 + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + repeat(2) { index -> + Column { + for (i in 0 until columnLength) { + val item = items[index * columnLength + i] + content(item) + } + } + } + } +} + +@Preview(widthDp = 360, heightDp = 640, showBackground = true) +@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + EnumeratedTwoColumnGrid( + items = List(24) { + EnumeratedTwoColumnGridItem( + index = it + 1, + mnemonic = "word${it + 1}", + ) + }.toImmutableList(), + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/grid/entity/EnumeratedTwoColumnGridItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/grid/entity/EnumeratedTwoColumnGridItem.kt new file mode 100644 index 0000000000..770855af97 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/grid/entity/EnumeratedTwoColumnGridItem.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.components.grid.entity + +data class EnumeratedTwoColumnGridItem( + val index: Int, + val mnemonic: String, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt index 259553da0b..759d32590b 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 @@ -17,6 +17,7 @@ import androidx.compose.ui.Alignment.Companion.CenterEnd import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -32,6 +33,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.SendAddressScreenTestTags import com.tangem.core.ui.utils.DEFAULT_ANIMATION_DURATION import kotlinx.coroutines.delay @@ -92,6 +94,7 @@ fun InputRowRecipient( text = it.resolveReference(), style = TangemTheme.typography.subtitle2, color = color, + modifier = Modifier.testTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD_TITLE), ) } Box( @@ -115,7 +118,8 @@ fun InputRowRecipient( modifier = Modifier .padding(start = TangemTheme.dimens.spacing12) .weight(1f) - .align(CenterVertically), + .align(CenterVertically) + .testTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD), ) CrossIcon( onClick = onPasteClick, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt b/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt index 7e95a24776..86dacb20d6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt @@ -2,19 +2,22 @@ package com.tangem.core.ui.components.label import android.content.res.Configuration import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +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.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference @@ -48,8 +51,18 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) { }, ) + val iconColor by animateColorAsState( + targetValue = when (state.style) { + LabelStyle.ACCENT -> TangemTheme.colors.icon.accent + LabelStyle.REGULAR -> TangemTheme.colors.icon.informative + LabelStyle.WARNING -> TangemTheme.colors.icon.warning + }, + ) + AnimatedContent(targetState = state.text) { text -> - Box( + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier .padding(horizontal = 4.dp) .background( @@ -63,6 +76,15 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) { style = TangemTheme.typography.caption1, color = textColor, ) + AnimatedVisibility(state.icon != null) { + val wrappedIcon = remember(this) { requireNotNull(state.icon) } + Icon( + imageVector = ImageVector.vectorResource(wrappedIcon), + tint = iconColor, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } } } } @@ -73,6 +95,7 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) { private fun LabelPreview() { TangemThemePreview { Column( + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(16.dp), ) { Label( @@ -81,24 +104,39 @@ private fun LabelPreview() { style = LabelStyle.REGULAR, ), ) - - Spacer(modifier = Modifier.height(8.dp)) - Label( state = LabelUM( text = TextReference.Str("Accent Label"), style = LabelStyle.ACCENT, ), ) - - Spacer(modifier = Modifier.height(8.dp)) - Label( state = LabelUM( text = TextReference.Str("Warning Label"), style = LabelStyle.WARNING, ), ) + Label( + state = LabelUM( + text = TextReference.Str("Regular Label"), + style = LabelStyle.REGULAR, + icon = R.drawable.ic_information_24, + ), + ) + Label( + state = LabelUM( + text = TextReference.Str("Accent Label"), + style = LabelStyle.ACCENT, + icon = R.drawable.ic_information_24, + ), + ) + Label( + state = LabelUM( + text = TextReference.Str("Warning Label"), + style = LabelStyle.WARNING, + icon = R.drawable.ic_information_24, + ), + ) } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt index 70b134bfe8..1be654c994 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt @@ -1,10 +1,13 @@ package com.tangem.core.ui.components.label.entity +import androidx.annotation.DrawableRes import com.tangem.core.ui.extensions.TextReference data class LabelUM( val text: TextReference, val style: LabelStyle, + @DrawableRes val icon: Int? = null, + val onIconClick: (() -> Unit)? = null, ) enum class LabelStyle { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt index 204f816c64..92785ec693 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -20,6 +21,7 @@ import com.tangem.core.ui.components.RectangleShimmer 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.MarketPriceBlockTestTags import com.tangem.utils.StringsSigns.DASH_SIGN /** @@ -45,7 +47,8 @@ fun MarketPriceBlock(state: MarketPriceBlockState, modifier: Modifier = Modifier .fillMaxWidth() .heightIn(min = TangemTheme.dimens.size72) .padding(all = TangemTheme.dimens.spacing12) - .onSizeChanged { rootWidth = it.width }, + .onSizeChanged { rootWidth = it.width } + .testTag(MarketPriceBlockTestTags.BLOCK), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), horizontalAlignment = Alignment.Start, ) { @@ -64,6 +67,7 @@ private fun Title(currencyName: String, modifier: Modifier = Modifier) { text = stringResourceSafe(id = R.string.wallet_marketplace_block_title, currencyName), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, + modifier = Modifier.testTag(MarketPriceBlockTestTags.TEXT), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt index 7b1baa0109..93f5aefdf0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.* import com.tangem.core.ui.components.buttons.common.TangemButtonSize @@ -63,8 +62,8 @@ fun Notification( NotificationConfig.IconTint.Unspecified -> null NotificationConfig.IconTint.Accent -> TangemTheme.colors.icon.accent NotificationConfig.IconTint.Attention -> TangemTheme.colors.icon.attention + NotificationConfig.IconTint.Warning -> TangemTheme.colors.icon.warning }, - iconSize: Dp = 20.dp, isEnabled: Boolean = true, ) { NotificationBaseContainer( @@ -78,7 +77,7 @@ fun Notification( MainContent( iconResId = config.iconResId, iconTint = iconTint, - iconSize = iconSize, + iconSize = config.iconSize, title = config.title, titleColor = titleColor, subtitle = config.subtitle, @@ -117,7 +116,9 @@ internal fun NotificationBaseContainer( ) { Box { Column( - modifier = Modifier.padding(all = TangemTheme.dimens.spacing12), + modifier = Modifier + .padding(all = TangemTheme.dimens.spacing12) + .testTag(NotificationTestTags.CONTAINER), verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12), ) { content() @@ -152,7 +153,8 @@ private fun MainContent( tint = iconTint, modifier = Modifier .size(size = iconSize) - .align(alignment = Alignment.Top), + .align(alignment = Alignment.Top) + .testTag(NotificationTestTags.ICON), ) SpacerW(width = TangemTheme.dimens.spacing10) @@ -220,7 +222,7 @@ internal fun TextsBlock( text = subtitleText, color = subtitleColor, style = TangemTheme.typography.caption2, - modifier = Modifier.testTag(NotificationTestTags.TEXT), + modifier = Modifier.testTag(NotificationTestTags.MESSAGE), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt index 4afe96144e..bd4d9dacef 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt @@ -1,6 +1,8 @@ package com.tangem.core.ui.components.notifications import androidx.annotation.DrawableRes +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.tangem.core.ui.extensions.TextReference /** @@ -26,6 +28,7 @@ data class NotificationConfig( val onCloseClick: (() -> Unit)? = null, val showArrowIcon: Boolean = onClick != null, val iconTint: IconTint = IconTint.Unspecified, + val iconSize: Dp = 20.dp, ) { sealed class ButtonsState { @@ -62,5 +65,6 @@ data class NotificationConfig( Unspecified, Accent, Attention, + Warning, } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt index 284885331e..9a4585eb59 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.Placeable @@ -309,7 +310,13 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier }, ) - fiatAmount?.placeRelative(x = layoutWidth - fiatAmount.width, y = verticalPadding) + fiatAmount?.placeRelative( + x = layoutWidth - fiatAmount.width, + y = when (state.subtitle2State) { + null -> (layoutHeight - fiatAmount.height).div(other = 2) + else -> verticalPadding + }, + ) priceChange?.placeRelative( x = icon.width, @@ -462,7 +469,7 @@ private fun calculateLayoutHeight( return max(firstColumnHeight, secondColumnHeight).coerceAtLeast(minLayoutHeight) } -@Preview(widthDp = 360) +@Preview(widthDp = 360, showBackground = true) @Composable private fun Preview_TokenItem_InLight(@PreviewParameter(TokenItemStateProvider::class) state: TokenItemState) { TangemThemePreview(isDark = false) { @@ -589,6 +596,8 @@ private class TokenItemStateProvider : CollectionPreviewParameterProvider { - GroupTitleItem(state, modifier) - } - is TokensListItemUM.Token -> { - TokenItem( - state = state.state, - isBalanceHidden = isBalanceHidden, - modifier = modifier, - ) - } + is TokensListItemUM.GroupTitle -> PortfolioTokensListItem(state, isBalanceHidden, modifier) + is TokensListItemUM.Token -> PortfolioTokensListItem(state, isBalanceHidden, modifier) + is TokensListItemUM.Portfolio -> PortfolioListItem(state, isBalanceHidden, modifier) is TokensListItemUM.SearchBar -> { SearchBar(state = state.searchBarUM, modifier = modifier.padding(all = 12.dp)) } @@ -46,4 +55,86 @@ fun TokenListItem(state: TokensListItemUM, isBalanceHidden: Boolean, modifier: M ) } } +} + +@Composable +fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { + if (state.isExpanded) { + ExpandedPortfolioHeader(state.state, modifier) + } else { + TokenItem( + state = state.state, + isBalanceHidden = isBalanceHidden, + modifier = modifier, + ) + } +} + +@Composable +fun PortfolioTokensListItem(state: PortfolioTokensListItemUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { + when (state) { + is TokensListItemUM.GroupTitle -> GroupTitleItem(state, modifier) + is TokensListItemUM.Token -> TokenItem( + state = state.state, + isBalanceHidden = isBalanceHidden, + modifier = modifier, + ) + } +} + +@Composable +private fun ExpandedPortfolioHeader(state: TokenItemState, modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clickable(onClick = { state.onItemClick?.invoke(state) }) + .padding( + vertical = TangemTheme.dimens.spacing4, + horizontal = TangemTheme.dimens.spacing12, + ), + ) { + when (val icon = state.iconState) { + is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon( + resId = icon.resId, + color = icon.color, + size = AccountIconSize.ExtraSmall, + ) + is CurrencyIconState.CryptoPortfolio.Letter -> AccountCharIcon( + char = icon.char.resolveReference().first(), + color = icon.color, + size = AccountIconSize.ExtraSmall, + ) + is CurrencyIconState.CoinIcon, + is CurrencyIconState.CustomTokenIcon, + is CurrencyIconState.Empty, + is CurrencyIconState.FiatIcon, + CurrencyIconState.Loading, + CurrencyIconState.Locked, + is CurrencyIconState.TokenIcon, + -> Unit + } + + SpacerW4() + + when (val titleState = state.titleState) { + TokenItemState.TitleState.Loading -> Unit + TokenItemState.TitleState.Locked -> Unit + is TokenItemState.TitleState.Content -> Text( + modifier = Modifier.weight(1f), + text = titleState.text.resolveReference(), + color = TangemTheme.colors.text.primary1, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = TangemTheme.typography.caption1, + ) + } + + Icon( + modifier = Modifier.size(TangemTheme.dimens.size16), + painter = painterResource(id = R.drawable.ic_minimize_24), + tint = TangemTheme.colors.icon.inactive, + contentDescription = null, + ) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt index 9b0e9235ed..24da6e9d28 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt @@ -29,16 +29,29 @@ sealed interface TokensListItemUM { * @property id id * @property text title value */ - data class GroupTitle(override val id: Any, val text: TextReference) : TokensListItemUM + data class GroupTitle(override val id: Any, val text: TextReference) : TokensListItemUM, PortfolioTokensListItemUM /** * Token item * * @property state token state */ - data class Token(val state: TokenItemState) : TokensListItemUM { + data class Token(val state: TokenItemState) : TokensListItemUM, PortfolioTokensListItemUM { + override val id: String = state.id + } + + data class Portfolio( + val state: TokenItemState, + val isExpanded: Boolean, + val tokens: List, + ) : TokensListItemUM { override val id: String = state.id } data class Text(override val id: Any, val text: TextReference) : TokensListItemUM +} + +sealed interface PortfolioTokensListItemUM { + /** Unique ID */ + val id: Any } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryGroupTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryGroupTitle.kt index e0298ceaed..a34ed1e6c2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryGroupTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryGroupTitle.kt @@ -23,7 +23,7 @@ import java.util.UUID * @param modifier modifier */ @Composable -internal fun TxHistoryGroupTitle(config: TxHistoryItemState.GroupTitle, modifier: Modifier = Modifier) { +fun TxHistoryGroupTitle(config: TxHistoryItemState.GroupTitle, modifier: Modifier = Modifier) { Box( modifier = modifier .background(TangemTheme.colors.background.primary) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryTitle.kt index f7cb3263ce..eec1924146 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryTitle.kt @@ -9,12 +9,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TransactionHistoryBlockTestTags /** * Transactions block title @@ -38,10 +40,13 @@ fun TxHistoryTitle(onExploreClick: () -> Unit, modifier: Modifier = Modifier) { text = stringResourceSafe(id = R.string.common_transactions), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, + modifier = Modifier.testTag(TransactionHistoryBlockTestTags.TITLE_TEXT), ) Row( - modifier = Modifier.clickable(onClick = onExploreClick), + modifier = Modifier + .clickable(onClick = onExploreClick) + .testTag(TransactionHistoryBlockTestTags.EXPLORER_ICON), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing2), ) { @@ -55,6 +60,7 @@ fun TxHistoryTitle(onExploreClick: () -> Unit, modifier: Modifier = Modifier) { text = stringResourceSafe(id = R.string.common_explorer), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, + modifier = Modifier.testTag(TransactionHistoryBlockTestTags.EXPLORER_TEXT), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableBottomSheetComponent.kt b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableBottomSheetComponent.kt index 2e3d83710f..6128a71c4a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableBottomSheetComponent.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableBottomSheetComponent.kt @@ -10,4 +10,13 @@ interface ComposableBottomSheetComponent { @Composable fun BottomSheet() +} + +fun getEmptyComposableBottomSheetComponent() = EmptyComposableBottomSheetComponent + +object EmptyComposableBottomSheetComponent : ComposableBottomSheetComponent { + override fun dismiss() {} + + @Composable + override fun BottomSheet() {} } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableModularContentComponent.kt b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableModularContentComponent.kt new file mode 100644 index 0000000000..15a24d2001 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableModularContentComponent.kt @@ -0,0 +1,37 @@ +package com.tangem.core.ui.decompose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier + +@Stable +interface ComposableModularContentComponent { + + @Composable + fun Title() + + @Composable + fun Content(modifier: Modifier) + + @Composable + fun Footer() +} + +fun getEmptyComposableModularContentComponent() = EmptyComposableModularContentComponent + +object EmptyComposableModularContentComponent : ComposableModularContentComponent { + @Composable + override fun Title() { + /* no-op */ + } + + @Composable + override fun Content(modifier: Modifier) { + /* no-op */ + } + + @Composable + override fun Footer() { + /* no-op */ + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BaseAmountBlockTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BaseAmountBlockTestTags.kt new file mode 100644 index 0000000000..f2f436d707 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BaseAmountBlockTestTags.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.test + +object BaseAmountBlockTestTags { + const val PRIMARY_AMOUNT = "STAKING_SEND_DETAILS_SCREEN_PRIMARY_AMOUNT" + const val SECONDARY_AMOUNT = "TAKING_SEND_DETAILS_SCREEN_SECONDARY_AMOUNT" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt new file mode 100644 index 0000000000..2604a7ad0e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui.test + +object BaseBottomSheetTestTags { + const val ACTION_TITLE = "BASE_BOTTOM_SHEET_ACTION_TITLE" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BaseDialogTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BaseDialogTestTags.kt new file mode 100644 index 0000000000..9ed0a4fbd6 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BaseDialogTestTags.kt @@ -0,0 +1,7 @@ +package com.tangem.core.ui.test + +object BaseDialogTestTags { + const val CONTAINER = "BASE_DIALOG_CONTAINER" + const val TITLE = "BASE_DIALOG_TITLE" + const val TEXT = "BASE_DIALOG_TEXT" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BaseSearchBarTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BaseSearchBarTestTags.kt new file mode 100644 index 0000000000..ad2b165837 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BaseSearchBarTestTags.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui.test + +object BaseSearchBarTestTags { + const val SEARCH_BAR = "BASE_SEARCH_BAR" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/DeviceSettingsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/DeviceSettingsScreenTestTags.kt new file mode 100644 index 0000000000..a995632d4f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/DeviceSettingsScreenTestTags.kt @@ -0,0 +1,8 @@ +package com.tangem.core.ui.test + +object DeviceSettingsScreenTestTags { + const val LAZY_LIST = "DEVICE_SETTINGS_SCREEN_LAZY_LIST" + const val IMAGE_BLOCK = "DEVICE_SETTINGS_SCREEN_IMAGE_BLOCK" + const val ITEM_TITLE = "DEVICE_SETTINGS_SCREEN_ITEM_TITLE" + const val ITEM_SUBTITLE = "DEVICE_SETTINGS_SCREEN_ITEM_SUBTITLE" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/DialogTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/DialogTestTags.kt deleted file mode 100644 index 0c5eae6e75..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/test/DialogTestTags.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.core.ui.test - -object DialogTestTags { - const val DIALOG_CONTAINER = "DIALOG_CONTAINER" -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt index dfef1326a7..cb1b1e1edf 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt @@ -5,8 +5,13 @@ object MainScreenTestTags { const val MORE_BUTTON = "MAIN_SCREEN_MORE_BUTTON" const val TOP_BAR = "MAIN_SCREEN_TOP_BAR" const val TOKEN_LIST_ITEM = "MAIN_SCREEN_TOKEN_LIST_ITEM" - const val WALLET_BALANCE = "MAIN_SCREEN_WALLET_BALANCE" const val WALLET_LIST_ITEM = "MAIN_SCREEN_WALLET_LIST_ITEM" const val ORGANIZE_TOKENS_BUTTON = "MAIN_SCREEN_ORGANIZE_TOKENS_BUTTON" const val MULTI_CURRENCY_ACTION_BUTTON = "MAIN_SCREEN_MULTI_CURRENCY_ACTION_BUTTON" + const val CARD_TITLE = "MAIN_SCREEN_CARD_TITLE" + const val CARD_IMAGE = "MAIN_SCREEN_CARD_IMAGE" + + const val WALLET_BALANCE = "MAIN_SCREEN_WALLET_BALANCE" + const val TOTAL_BALANCE_MENU_ITEM = "MAIN_SCREEN_TOTAL_BALANCE_MENU_ITEM" + const val TOTAL_BALANCE_CONTAINER = "MAIN_SCREEN_TOTAL_BALANCE_CONTAINER" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/MarketPriceBlockTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/MarketPriceBlockTestTags.kt new file mode 100644 index 0000000000..c271c61cc3 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/MarketPriceBlockTestTags.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.test + +object MarketPriceBlockTestTags { + const val BLOCK = "MAIN_SCREEN_MARKET_PRICE_BLOCK" + const val TEXT = "MAIN_SCREEN_MARKET_PRICE_TEXT" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/MarketsTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/MarketsTestTags.kt new file mode 100644 index 0000000000..0721a092cd --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/MarketsTestTags.kt @@ -0,0 +1,7 @@ +package com.tangem.core.ui.test + +object MarketsTestTags { + const val TOKENS_LIST = "MARKETS_TOKENS_LIST" + const val TOKENS_LIST_ITEM = "MARKETS_TOKENS_LIST_ITEM" + const val ADD_TO_PORTFOLIO_SWITCH = "MARKETS_ADD_TO_PORTFOLIO_SWITCH" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/NotificationTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/NotificationTestTags.kt index 15a63e2186..b02a22b08f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/NotificationTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/NotificationTestTags.kt @@ -1,6 +1,8 @@ package com.tangem.core.ui.test object NotificationTestTags { + const val CONTAINER = "NOTIFICATION_CONTAINER" const val TITLE = "NOTIFICATION_TITLE" - const val TEXT = "NOTIFICATION_TEXT" + const val MESSAGE = "NOTIFICATION_MESSAGE" + const val ICON = "NOTIFICATION_ICON" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/ResetCardScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/ResetCardScreenTestTags.kt new file mode 100644 index 0000000000..0f0409aba4 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/ResetCardScreenTestTags.kt @@ -0,0 +1,10 @@ +package com.tangem.core.ui.test + +object ResetCardScreenTestTags { + const val TITLE = "RESET_CARD_SCREEN_TITLE" + const val ATTENTION_IMAGE = "RESET_CARD_SCREEN_ATTENTION_IMAGE" + const val SUBTITLE = "RESET_CARD_SCREEN_SUBTITLE" + const val DESCRIPTION = "RESET_CARD_SCREEN_DESCRIPTION" + const val CHECKBOX = "RESET_CARD_SCREEN_CHECKBOX" + const val CHECKBOX_TEXT = "RESET_CARD_SCREEN_CHECKBOX_TEXT" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SelectCountryBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SelectCountryBottomSheetTestTags.kt index 6a2d859e19..acb5cfa60f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/SelectCountryBottomSheetTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SelectCountryBottomSheetTestTags.kt @@ -5,7 +5,6 @@ object SelectCountryBottomSheetTestTags { const val LAZY_LIST = "SELECT_COUNTRY_BOTTOM_SHEET_LAZY_LIST" const val COUNTRY_ITEM = "SELECT_COUNTRY_BOTTOM_SHEET_COUNTRY_ITEM" const val UNAVAILABLE_COUNTRY_ITEM = "SELECT_COUNTRY_BOTTOM_SHEET_UNAVAILABLE_COUNTRY_ITEM" - const val SEARCH_BAR = "SELECT_COUNTRY_BOTTOM_SHEET_SEARCH_BAR" const val COUNTRY_ICON = "SELECT_COUNTRY_BOTTOM_SHEET_COUNTRY_ICON" const val UNAVAILABLE_COUNTRY_ICON = "SELECT_COUNTRY_BOTTOM_SHEET_UNAVAILABLE_COUNTRY_ICON" const val COUNTRY_NAME = "SELECT_COUNTRY_BOTTOM_SHEET_COUNTRY_NAME" diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SendAddressScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SendAddressScreenTestTags.kt new file mode 100644 index 0000000000..ebe05bc895 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SendAddressScreenTestTags.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.test + +object SendAddressScreenTestTags { + const val ADDRESS_TEXT_FIELD_TITLE = "SEND_ADDRESS_TEXT_FIELD_TITLE" + const val ADDRESS_TEXT_FIELD = "SEND_ADDRESS_TEXT_FIELD" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt new file mode 100644 index 0000000000..bc025954ac --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt @@ -0,0 +1,16 @@ +package com.tangem.core.ui.test + +object SendScreenTestTags { + const val SCREEN_CONTAINER = "SEND_SCREEN_CONTAINER" + + const val AMOUNT_CONTAINER_TITLE = "SEND_SCREEN_AMOUNT_CONTAINER_TITLE" + const val AMOUNT_CONTAINER_TEXT = "SEND_SCREEN_AMOUNT_CONTAINER_TEXT" + const val INPUT_TEXT_FIELD = "SEND_SCREEN_INPUT_TEXT_FIELD" + const val SECONDARY_AMOUNT = "SEND_SCREEN_SECONDARY_AMOUNT" + + const val CURRENCY_BUTTON = "SEND_SCREEN_CURRENCY_BUTTON" + const val FIAT_ICON = "SEND_SCREEN_FIAT_ICON" + const val CURRENCY_ICON = "SEND_SCREEN_CURRENCY_ICON" + const val MAX_BUTTON = "END_SCREEN_MAX_BUTTON" + const val PREVIOUS_BUTTON = "SEND_SCREEN_PREVIOUS_BUTTON" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendDetailsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendDetailsScreenTestTags.kt index 138b27e35b..22cbf9c8a5 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendDetailsScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendDetailsScreenTestTags.kt @@ -1,10 +1,6 @@ package com.tangem.core.ui.test object StakingSendDetailsScreenTestTags { - - const val PRIMARY_AMOUNT = "STAKING_SEND_DETAILS_SCREEN_PRIMARY_AMOUNT" - const val SECONDARY_AMOUNT = "TAKING_SEND_DETAILS_SCREEN_SECONDARY_AMOUNT" - - const val VALIDATOR_BLOCK = "TAKING_SEND_DETAILS_SCREEN_VALIDATOR_BLOCK" - const val NETWORK_FEE_BLOCK = "TAKING_SEND_DETAILS_SCREEN_NETWORK_FEE_BLOCK" + const val VALIDATOR_BLOCK = "STAKING_SEND_DETAILS_SCREEN_VALIDATOR_BLOCK" + const val NETWORK_FEE_BLOCK = "STAKING_SEND_DETAILS_SCREEN_NETWORK_FEE_BLOCK" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendScreenTestTags.kt deleted file mode 100644 index 1dc36d3f79..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendScreenTestTags.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tangem.core.ui.test - -object StakingSendScreenTestTags { - const val SCREEN_CONTAINER = "STAKING_SEND_SCREEN_CONTAINER" - - const val AMOUNT_CONTAINER_TITLE = "STAKING_SEND_SCREEN_AMOUNT_CONTAINER_TITLE" - const val AMOUNT_CONTAINER_TEXT = "STAKING_SEND_SCREEN_AMOUNT_CONTAINER_TEXT" - const val INPUT_TEXT_FIELD = "STAKING_SEND_SCREEN_INPUT_TEXT_FIELD" - const val SECONDARY_AMOUNT = "STAKING_SEND_SCREEN_SECONDARY_AMOUNT" - - const val CURRENCY_BUTTON = "STAKING_SEND_SCREEN_CURRENCY_BUTTON" - const val FIAT_ICON = "STAKING_SEND_SCREEN_FIAT_ICON" - const val CURRENCY_ICON = "STAKING_SEND_SCREEN_CURRENCY_ICON" - const val MAX_BUTTON = "STAKING_SEND_SCREEN_MAX_BUTTON" - const val PREVIOUS_BUTTON = "STAKING_SEND_SCREEN_PREVIOUS_BUTTON" -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryBlockTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryBlockTestTags.kt new file mode 100644 index 0000000000..4a031ddd39 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryBlockTestTags.kt @@ -0,0 +1,7 @@ +package com.tangem.core.ui.test + +object TransactionHistoryBlockTestTags { + const val TITLE_TEXT = "TRANSACTION_HISTORY_TITLE_TEXT" + const val EXPLORER_TEXT = "TRANSACTION_HISTORY_EXPLORER_TEXT" + const val EXPLORER_ICON = "TRANSACTION_HISTORY_EXPLORER_ICON" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectBottomSheetTestTags.kt new file mode 100644 index 0000000000..8cdcb5f0a3 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectBottomSheetTestTags.kt @@ -0,0 +1,22 @@ +package com.tangem.core.ui.test + +object WalletConnectBottomSheetTestTags { + const val TITLE = "WALLET_CONNECT_BOTTOM_SHEET_TITLE" + const val APP_ICON = "WALLET_CONNECT_BOTTOM_SHEET_APP_ICON" + const val APP_NAME = "WALLET_CONNECT_BOTTOM_SHEET_APP_NAME" + const val APPROVE_ICON = "WALLET_CONNECT_BOTTOM_SHEET_APPROVE_ICON" + const val APP_URL = "WALLET_CONNECT_BOTTOM_SHEET_APP_URL" + + const val CONNECTION_REQUEST_ICON = "WALLET_CONNECT_BOTTOM_SHEET_CONNECTION_REQUEST_ICON" + const val CONNECTION_REQUEST_TEXT = "WALLET_CONNECT_BOTTOM_SHEET_CONNECTION_REQUEST_TEXT" + const val CONNECTION_REQUEST_CHEVRON = "WALLET_CONNECT_BOTTOM_SHEET_CONNECTION_REQUEST_CHEVRON" + + const val WALLET_ICON = "WALLET_CONNECT_BOTTOM_SHEET_WALLET_ICON" + const val WALLET_NAME_TITLE = "WALLET_CONNECT_BOTTOM_SHEET_WALLET_NAME_TITLE" + const val WALLET_NAME = "WALLET_CONNECT_BOTTOM_SHEET_WALLET_NAME" + + const val NETWORKS_ICON = "WALLET_CONNECT_BOTTOM_SHEET_NETWORKS_ICON" + const val NETWORKS_TITLE = "WALLET_CONNECT_BOTTOM_SHEET_NETWORKS_TITLE" + const val NETWORKS_SELECTOR_ICON = "WALLET_CONNECT_BOTTOM_SHEET_NETWORKS_SELECTOR_ICON" + const val NETWORKS_ICONS = "WALLET_CONNECT_BOTTOM_SHEET_NETWORKS_ICONS" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectDetailsBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectDetailsBottomSheetTestTags.kt new file mode 100644 index 0000000000..f5f7ad7776 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectDetailsBottomSheetTestTags.kt @@ -0,0 +1,16 @@ +package com.tangem.core.ui.test + +object WalletConnectDetailsBottomSheetTestTags { + const val TITLE = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_TITLE" + const val DATE = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_DATE" + const val CLOSE_BUTTON = "WALLET_CONNECT_DETAILS_BOTTOM_CLOSE_BUTTON" + const val DISCONNECT_BUTTON = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_DISCONNECT_BUTTON" + const val WALLET_ICON = "WALLET_CONNECT_DETAILS_BOTTOM_WALLET_ICON" + const val WALLET_TITLE = "WALLET_CONNECT_DETAILS_BOTTOM_WALLET" + const val WALLET_NAME = "WALLET_CONNECT_DETAILS_BOTTOM_WALLET_NAME" + const val NETWORKS_TITLE = "WALLET_CONNECT_DETAILS_BOTTOM_WALLET_NAME" + const val NETWORK_ITEM = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_NETWORK_ITEM" + const val NETWORK_ICON = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_NETWORK_ICON" + const val NETWORK_NAME = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_NETWORK_NAME" + const val NETWORK_SYMBOL = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_NETWORK_SYMBOL" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt new file mode 100644 index 0000000000..8280fc5021 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt @@ -0,0 +1,10 @@ +package com.tangem.core.ui.test + +object WalletConnectScreenTestTags { + const val MORE_BUTTON = "WALLET_CONNECT_SCREEN_MORE_BUTTON" + const val WALLET_NAME = "WALLET_CONNECT_SCREEN_WALLET_NAME" + const val APP_ICON = "WALLET_CONNECT_SCREEN_APP_ICON" + const val APP_NAME = "WALLET_CONNECT_SCREEN_APP_NAME" + const val APPROVE_ICON = "WALLET_CONNECT_SCREEN_APPROVE_ICON" + const val APP_URL = "WALLET_CONNECT_SCREEN_APP_URL" +} \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_aave_36.xml b/core/ui/src/main/res/drawable/ic_aave_36.xml new file mode 100644 index 0000000000..310afe5b24 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_aave_36.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_analytics_up_24.xml b/core/ui/src/main/res/drawable/ic_analytics_up_24.xml new file mode 100644 index 0000000000..be13c86516 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_analytics_up_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_best_rate_12.xml b/core/ui/src/main/res/drawable/ic_best_rate_12.xml new file mode 100644 index 0000000000..99e0e500c2 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_best_rate_12.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_best_rate_16.xml b/core/ui/src/main/res/drawable/ic_best_rate_16.xml new file mode 100644 index 0000000000..7e36b66936 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_best_rate_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_credit_card_add_22.xml b/core/ui/src/main/res/drawable/ic_credit_card_add_22.xml new file mode 100644 index 0000000000..560684e1a9 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_credit_card_add_22.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_fastest_16.xml b/core/ui/src/main/res/drawable/ic_fastest_16.xml new file mode 100644 index 0000000000..072444ff1d --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_fastest_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_flash_new_24.xml b/core/ui/src/main/res/drawable/ic_flash_new_24.xml new file mode 100644 index 0000000000..71ad60eda8 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_flash_new_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_hardware_backup_36.xml b/core/ui/src/main/res/drawable/ic_hardware_backup_36.xml new file mode 100644 index 0000000000..071683ab08 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_hardware_backup_36.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_knight_shield_24.xml b/core/ui/src/main/res/drawable/ic_knight_shield_24.xml new file mode 100644 index 0000000000..1a058ff367 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_knight_shield_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_minimize_24.xml b/core/ui/src/main/res/drawable/ic_minimize_24.xml new file mode 100644 index 0000000000..b3a32f2864 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_minimize_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_mobile_security_24.xml b/core/ui/src/main/res/drawable/ic_mobile_security_24.xml new file mode 100644 index 0000000000..bbc69392d5 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_mobile_security_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_protect_24.xml b/core/ui/src/main/res/drawable/ic_protect_24.xml new file mode 100644 index 0000000000..5ad478076f --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_protect_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_repeat_24.xml b/core/ui/src/main/res/drawable/ic_repeat_24.xml new file mode 100644 index 0000000000..deb5797a0b --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_repeat_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_security_check_22.xml b/core/ui/src/main/res/drawable/ic_security_check_22.xml new file mode 100644 index 0000000000..fd8956d8fa --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_security_check_22.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_security_check_24.xml b/core/ui/src/main/res/drawable/ic_security_check_24.xml new file mode 100644 index 0000000000..8a64f5924c --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_security_check_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_shopping_basket_22.xml b/core/ui/src/main/res/drawable/ic_shopping_basket_22.xml new file mode 100644 index 0000000000..c6cb19e2e7 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_shopping_basket_22.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_staking_mini_10.xml b/core/ui/src/main/res/drawable/ic_staking_mini_10.xml new file mode 100644 index 0000000000..69aab19b6c --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_staking_mini_10.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_tangem_24.xml b/core/ui/src/main/res/drawable/ic_tangem_24.xml index b4ccb9cc5a..cd606d5acf 100644 --- a/core/ui/src/main/res/drawable/ic_tangem_24.xml +++ b/core/ui/src/main/res/drawable/ic_tangem_24.xml @@ -6,11 +6,11 @@ android:viewportHeight="24"> + android:fillColor="@color/icon_primary1"/> + android:fillColor="@color/icon_primary1"/> + android:fillColor="@color/icon_primary1"/> diff --git a/core/ui/src/main/res/drawable/ic_tangem_64.xml b/core/ui/src/main/res/drawable/ic_tangem_64.xml new file mode 100644 index 0000000000..6c39da496c --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_tangem_64.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/core/ui/src/main/res/drawable/img_aave_22.xml b/core/ui/src/main/res/drawable/img_aave_22.xml new file mode 100644 index 0000000000..7ae7a56a34 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_aave_22.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/core/ui/src/main/res/drawable/img_notification_sepa.webp b/core/ui/src/main/res/drawable/img_notification_sepa.webp new file mode 100644 index 0000000000..f33044f9ec Binary files /dev/null and b/core/ui/src/main/res/drawable/img_notification_sepa.webp differ diff --git a/app/src/main/res/drawable/splash_logo.xml b/core/ui/src/main/res/drawable/splash_logo.xml similarity index 80% rename from app/src/main/res/drawable/splash_logo.xml rename to core/ui/src/main/res/drawable/splash_logo.xml index ef37dfda51..f95768bf95 100644 --- a/app/src/main/res/drawable/splash_logo.xml +++ b/core/ui/src/main/res/drawable/splash_logo.xml @@ -1,15 +1,15 @@ diff --git a/core/ui/src/main/res/values-night/colors.xml b/core/ui/src/main/res/values-night/colors.xml new file mode 100644 index 0000000000..6c7b1cabaa --- /dev/null +++ b/core/ui/src/main/res/values-night/colors.xml @@ -0,0 +1,4 @@ + + + #FFFFFFFF + \ No newline at end of file diff --git a/core/ui/src/main/res/values/colors.xml b/core/ui/src/main/res/values/colors.xml new file mode 100644 index 0000000000..8dd91f8060 --- /dev/null +++ b/core/ui/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FF1E1E1E + \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/buildConfig/AppConfigurationProvider.kt b/core/utils/src/main/java/com/tangem/utils/buildConfig/AppConfigurationProvider.kt new file mode 100644 index 0000000000..863864842e --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/buildConfig/AppConfigurationProvider.kt @@ -0,0 +1,6 @@ +package com.tangem.utils.buildConfig + +interface AppConfigurationProvider { + fun isDebug(): Boolean + fun isHuawei(): Boolean +} \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineExt.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineExt.kt index 901cf071d6..b57c8e0015 100644 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineExt.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineExt.kt @@ -1,6 +1,8 @@ package com.tangem.utils.coroutines import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -54,4 +56,25 @@ fun CoroutineScope.launchOnCancellation(block: suspend () -> Unit) { } } } +} + +@Suppress("LongParameterList", "MagicNumber") +inline fun combine6( + flow1: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R, +): Flow = combine(flow1, flow2, flow3, flow4, flow5, flow6) { arr -> + @Suppress("UNCHECKED_CAST") + transform( + arr[0] as T1, + arr[1] as T2, + arr[2] as T3, + arr[3] as T4, + arr[4] as T5, + arr[5] as T6, + ) } \ No newline at end of file diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index 464ba8486e..d5c297866a 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -9,29 +9,61 @@ android { namespace = "com.tangem.data.account" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { // region Project - Core + implementation(projects.core.datasource) + implementation(projects.core.configToggles) api(projects.core.utils) // endregion // region Project - Domain api(projects.domain.account) + api(projects.domain.card) api(projects.domain.models) // endregion - // Project - Data - implementation(projects.core.datasource) + // region Project - Data + implementation(projects.data.common) + // endregion + + // region Project - Libs + implementation(projects.libs.crypto) + implementation(projects.libs.blockchainSdk) + // endregion + + // region Tangem dependencies + implementation(tangemDeps.card.core) + implementation(tangemDeps.blockchain) // endregion // region DI - implementation(deps.hilt.core) + implementation(deps.hilt.android) kapt(deps.hilt.kapt) // endregion + // region AndroidX libraries + implementation(deps.androidx.datastore) + // endregion + // region Other Dependencies implementation(deps.arrow.core) implementation(deps.kotlin.coroutines) + implementation(deps.moshi) + implementation(deps.moshi.kotlin) implementation(deps.timber) // endregion + + // region Test + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(projects.common.test) + // endregion } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt new file mode 100644 index 0000000000..e8bcef8cce --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt @@ -0,0 +1,36 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.models.wallet.UserWalletId +import javax.inject.Inject + +/** + * Container for converter factories related to accounts. + * + * @property accountsListCF factory for creating an account list converter + * @property getWalletAccountsResponseCF factory for creating a wallet accounts response converter + * @property cryptoPortfolioCF factory for creating a crypto portfolio converter + * + * @constructor Creates an instance of the container with injected factories. + * +[REDACTED_AUTHOR] + */ +internal class AccountConverterFactoryContainer @Inject constructor( + val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory, + private val accountsListCF: AccountListConverter.Factory, + private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, + private val userWalletsStore: UserWalletsStore, +) { + + fun createAccountListConverter(userWalletId: UserWalletId): AccountListConverter { + val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) + + return accountsListCF.create(userWallet) + } + + fun createCryptoPortfolioConverter(userWalletId: UserWalletId): CryptoPortfolioConverter { + val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) + + return cryptoPortfolioCF.create(userWallet) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt new file mode 100644 index 0000000000..3d35e7327c --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt @@ -0,0 +1,26 @@ +package com.tangem.data.account.converter + +import arrow.core.getOrElse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId + +internal fun String.toAccountId(userWalletId: UserWalletId): AccountId { + return AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId).getOrElse { + error("Unable to create AccountId from value: $this. Cause: $it") + } +} + +internal fun WalletAccountDTO.toIcon(): CryptoPortfolioIcon { + return CryptoPortfolioIconConverter.convert( + value = CryptoPortfolioIconConverter.DataModel(icon = icon, color = iconColor), + ) +} + +internal fun Int.toDerivationIndex(): DerivationIndex { + return DerivationIndex(value = this).getOrElse { + error("Unable to create DerivationIndex from value: $this. Cause: $it") + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt new file mode 100644 index 0000000000..d4c993f59f --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt @@ -0,0 +1,46 @@ +package com.tangem.data.account.converter + +import arrow.core.getOrElse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.converter.Converter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Converts a [GetWalletAccountsResponse] to an [AccountList] and vice versa + * + * @property userWallet the user wallet associated with the account list + * @param cryptoPortfolioConverterFactory factory to create [CryptoPortfolioConverter] instances + * +[REDACTED_AUTHOR] + */ +internal class AccountListConverter @AssistedInject constructor( + @Assisted private val userWallet: UserWallet, + cryptoPortfolioConverterFactory: CryptoPortfolioConverter.Factory, +) : Converter { + + private val cryptoPortfolioConverter: CryptoPortfolioConverter by lazy { + cryptoPortfolioConverterFactory.create(userWallet) + } + + override fun convert(value: GetWalletAccountsResponse): AccountList { + return AccountList( + userWallet = userWallet, + accounts = value.accounts.map(cryptoPortfolioConverter::convert).toSet(), + totalAccounts = value.wallet.totalAccounts, + sortType = TokensSortTypeConverter.convert(value.wallet.sort), + groupType = TokensGroupTypeConverter.convert(value.wallet.group), + ) + .getOrElse { + error("Failed to convert GetWalletAccountsResponse to AccountList: $it") + } + } + + @AssistedFactory + interface Factory { + fun create(userWallet: UserWallet): AccountListConverter + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountNameConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountNameConverter.kt new file mode 100644 index 0000000000..d4d20d7364 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountNameConverter.kt @@ -0,0 +1,31 @@ +package com.tangem.data.account.converter + +import arrow.core.getOrElse +import com.tangem.domain.models.account.AccountName +import com.tangem.utils.converter.TwoWayConverter + +/** + * A converter for transforming [AccountName] domain models into their string representations. + * This is used to handle the conversion logic between the domain layer and other layers. + * +[REDACTED_AUTHOR] + */ +internal object AccountNameConverter : TwoWayConverter { + + override fun convert(value: AccountName): String? { + return when (value) { + is AccountName.Custom -> value.value + AccountName.DefaultMain -> null + } + } + + override fun convertBack(value: String?): AccountName { + return if (value == null) { + AccountName.DefaultMain + } else { + AccountName(value = value).getOrElse { + error("Unable to create AccountName from value: $value. Cause: $it") + } + } + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt new file mode 100644 index 0000000000..764ede3152 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.converter.Converter + +/** + * Converts a [WalletAccountDTO] to an [ArchivedAccount] + * + * @param userWalletId the ID of the user wallet associated with the account + * +[REDACTED_AUTHOR] + */ +internal class ArchivedAccountConverter( + private val userWalletId: UserWalletId, +) : Converter { + + override fun convert(value: WalletAccountDTO): ArchivedAccount { + return ArchivedAccount( + accountId = value.id.toAccountId(userWalletId = userWalletId), + name = AccountNameConverter.convertBack(value = value.name), + icon = value.toIcon(), + derivationIndex = value.derivationIndex.toDerivationIndex(), + tokensCount = value.totalTokens ?: error("Total tokens should not be null"), + networksCount = value.totalNetworks ?: error("Total networks should not be null"), + ) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt new file mode 100644 index 0000000000..0117b2b653 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt @@ -0,0 +1,61 @@ +package com.tangem.data.account.converter + +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.converter.TwoWayConverter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Converts a [WalletAccountDTO] to an [Account.CryptoPortfolio] and vise versa + * + * @property userWallet the user wallet associated with the account list + * @property responseCryptoCurrenciesFactory factory to create crypto currencies from response tokens + * +[REDACTED_AUTHOR] + */ +internal class CryptoPortfolioConverter @AssistedInject constructor( + @Assisted private val userWallet: UserWallet, + private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + private val userTokensResponseFactory: UserTokensResponseFactory, +) : TwoWayConverter { + + override fun convert(value: WalletAccountDTO): Account.CryptoPortfolio { + val tokens = value.tokens ?: error("Tokens should not be null") + + return Account.CryptoPortfolio( + accountId = value.id.toAccountId(userWallet.walletId), + accountName = AccountNameConverter.convertBack(value = value.name), + icon = value.toIcon(), + derivationIndex = value.derivationIndex.toDerivationIndex(), + cryptoCurrencies = if (tokens.isNotEmpty()) { + responseCryptoCurrenciesFactory.createCurrencies( + tokens = tokens, + userWallet = userWallet, + ).toSet() + } else { + emptySet() + }, + ) + } + + override fun convertBack(value: Account.CryptoPortfolio): WalletAccountDTO { + return WalletAccountDTO( + id = value.accountId.value, + name = AccountNameConverter.convert(value = value.accountName), + derivationIndex = value.derivationIndex.value, + icon = value.icon.value.name, + iconColor = value.icon.color.name, + tokens = value.cryptoCurrencies.map(userTokensResponseFactory::createResponseToken), + ) + } + + @AssistedFactory + interface Factory { + fun create(userWallet: UserWallet): CryptoPortfolioConverter + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioIconConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioIconConverter.kt new file mode 100644 index 0000000000..33b8b2ab20 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioIconConverter.kt @@ -0,0 +1,21 @@ +package com.tangem.data.account.converter + +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.utils.converter.Converter + +/** + * Converts a [CryptoPortfolioIconConverter.DataModel] to a [CryptoPortfolioIcon] + * +[REDACTED_AUTHOR] + */ +internal object CryptoPortfolioIconConverter : Converter { + + override fun convert(value: DataModel): CryptoPortfolioIcon { + return CryptoPortfolioIcon.ofCustomAccount( + value = CryptoPortfolioIcon.Icon.valueOf(value.icon), + color = CryptoPortfolioIcon.Color.valueOf(value.color), + ) + } + + data class DataModel(val icon: String, val color: String) +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt new file mode 100644 index 0000000000..3f741ba0b5 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt @@ -0,0 +1,42 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.converter.Converter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** +[REDACTED_AUTHOR] + */ +internal class GetWalletAccountsResponseConverter @AssistedInject constructor( + @Assisted private val userWallet: UserWallet, + cryptoPortfolioConverterFactory: CryptoPortfolioConverter.Factory, +) : Converter { + + private val cryptoPortfolioConverter: CryptoPortfolioConverter by lazy { + cryptoPortfolioConverterFactory.create(userWallet) + } + + override fun convert(value: AccountList): GetWalletAccountsResponse { + return GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = TokensGroupTypeConverter.convertBack(value.groupType), + sort = TokensSortTypeConverter.convertBack(value.sortType), + totalAccounts = value.totalAccounts, + ), + accounts = value.accounts + .filterIsInstance() + .map(cryptoPortfolioConverter::convertBack), + unassignedTokens = emptyList(), + ) + } + + @AssistedFactory + interface Factory { + fun create(userWallet: UserWallet): GetWalletAccountsResponseConverter + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt new file mode 100644 index 0000000000..07c76c7c24 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt @@ -0,0 +1,33 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.utils.converter.Converter + +/** + * Converts an [AccountList] to a [SaveWalletAccountsResponse] + * +[REDACTED_AUTHOR] + */ +internal object SaveWalletAccountsResponseConverter : Converter { + + override fun convert(value: AccountList): SaveWalletAccountsResponse { + return SaveWalletAccountsResponse( + accounts = value.accounts + .filterIsInstance() + .map(::toDTO), + ) + } + + private fun toDTO(account: Account.CryptoPortfolio): WalletAccountDTO { + return WalletAccountDTO( + id = account.accountId.value, + name = AccountNameConverter.convert(value = account.accountName), + derivationIndex = account.derivationIndex.value, + icon = account.icon.value.name, + iconColor = account.icon.color.name, + ) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensGroupTypeConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensGroupTypeConverter.kt new file mode 100644 index 0000000000..9a9b92af1b --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensGroupTypeConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.TokensGroupType +import com.tangem.utils.converter.TwoWayConverter + +/** + * Converts a [UserTokensResponse.GroupType] to a [TokensGroupType] and vice versa + * +[REDACTED_AUTHOR] + */ +internal object TokensGroupTypeConverter : TwoWayConverter { + + override fun convert(value: UserTokensResponse.GroupType): TokensGroupType { + return when (value) { + UserTokensResponse.GroupType.NETWORK -> TokensGroupType.NETWORK + UserTokensResponse.GroupType.NONE, + UserTokensResponse.GroupType.TOKEN, + -> TokensGroupType.NONE + } + } + + override fun convertBack(value: TokensGroupType): UserTokensResponse.GroupType { + return when (value) { + TokensGroupType.NONE -> UserTokensResponse.GroupType.NONE + TokensGroupType.NETWORK -> UserTokensResponse.GroupType.NETWORK + } + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensSortTypeConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensSortTypeConverter.kt new file mode 100644 index 0000000000..91b2b2e88a --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensSortTypeConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.TokensSortType +import com.tangem.utils.converter.TwoWayConverter + +/** + * Converts a [UserTokensResponse.SortType] to a [TokensSortType] and vice versa + * +[REDACTED_AUTHOR] + */ +internal object TokensSortTypeConverter : TwoWayConverter { + + override fun convert(value: UserTokensResponse.SortType): TokensSortType { + return when (value) { + UserTokensResponse.SortType.BALANCE -> TokensSortType.BALANCE + UserTokensResponse.SortType.MANUAL, + UserTokensResponse.SortType.MARKETCAP, + -> TokensSortType.NONE + } + } + + override fun convertBack(value: TokensSortType): UserTokensResponse.SortType { + return when (value) { + TokensSortType.NONE -> UserTokensResponse.SortType.MANUAL + TokensSortType.BALANCE -> UserTokensResponse.SortType.BALANCE + } + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index e05c402f68..df7c1a3919 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -1,9 +1,20 @@ package com.tangem.data.account.di +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.data.account.converter.AccountConverterFactoryContainer +import com.tangem.data.account.featuretoggle.DefaultAccountsFeatureToggles +import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher import com.tangem.data.account.repository.DefaultAccountsCRUDRepository -import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.store.ArchivedAccountsStoreFactory +import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.data.common.account.WalletAccountsSaver +import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -16,10 +27,38 @@ internal object AccountDataModule { @Provides @Singleton - fun provideAccountsCRUDRepository(userWalletsStore: UserWalletsStore): AccountsCRUDRepository { + fun provideAccountFeatureToggle(featureTogglesManager: FeatureTogglesManager): AccountsFeatureToggles { + return DefaultAccountsFeatureToggles(featureTogglesManager = featureTogglesManager) + } + + @Provides + @Singleton + fun provideAccountsCRUDRepository( + tangemTechApi: TangemTechApi, + walletAccountsSaver: WalletAccountsSaver, + accountsResponseStoreFactory: AccountsResponseStoreFactory, + userWalletsStore: UserWalletsStore, + eTagsStore: ETagsStore, + accountConverterFactoryContainer: AccountConverterFactoryContainer, + dispatchers: CoroutineDispatcherProvider, + ): AccountsCRUDRepository { return DefaultAccountsCRUDRepository( - runtimeStore = RuntimeSharedStore(), + tangemTechApi = tangemTechApi, + walletAccountsSaver = walletAccountsSaver, + accountsResponseStoreFactory = accountsResponseStoreFactory, + archivedAccountsStoreFactory = ArchivedAccountsStoreFactory, userWalletsStore = userWalletsStore, + eTagsStore = eTagsStore, + convertersContainer = accountConverterFactoryContainer, + dispatchers = dispatchers, ) } + + @Provides + @Singleton + fun provideWalletAccountsFetcher(impl: DefaultWalletAccountsFetcher): WalletAccountsFetcher = impl + + @Provides + @Singleton + fun provideWalletAccountsSaver(impl: DefaultWalletAccountsFetcher): WalletAccountsSaver = impl } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListFetcherModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListFetcherModule.kt new file mode 100644 index 0000000000..39484b01f6 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListFetcherModule.kt @@ -0,0 +1,24 @@ +package com.tangem.data.account.di + +import com.tangem.data.account.fetcher.DefaultMultiAccountListFetcher +import com.tangem.data.account.fetcher.DefaultSingleAccountListFetcher +import com.tangem.domain.account.fetcher.MultiAccountListFetcher +import com.tangem.domain.account.fetcher.SingleAccountListFetcher +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 AccountListFetcherModule { + + @Binds + @Singleton + fun bindSingleAccountListFetcher(impl: DefaultSingleAccountListFetcher): SingleAccountListFetcher + + @Binds + @Singleton + fun bindMultiAccountListFetcher(impl: DefaultMultiAccountListFetcher): MultiAccountListFetcher +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListProducerFactoryModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListProducerFactoryModule.kt new file mode 100644 index 0000000000..cfa81cfba4 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListProducerFactoryModule.kt @@ -0,0 +1,28 @@ +package com.tangem.data.account.di + +import com.tangem.data.account.producer.DefaultMultiAccountListProducer +import com.tangem.data.account.producer.DefaultSingleAccountListProducer +import com.tangem.domain.account.producer.MultiAccountListProducer +import com.tangem.domain.account.producer.SingleAccountListProducer +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 AccountListProducerFactoryModule { + + @Binds + @Singleton + fun bindSingleAccountListProducerFactory( + impl: DefaultSingleAccountListProducer.Factory, + ): SingleAccountListProducer.Factory + + @Binds + @Singleton + fun bindMultiAccountListProducerFactory( + impl: DefaultMultiAccountListProducer.Factory, + ): MultiAccountListProducer.Factory +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListSupplierModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListSupplierModule.kt new file mode 100644 index 0000000000..c60b4bd0bb --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListSupplierModule.kt @@ -0,0 +1,34 @@ +package com.tangem.data.account.di + +import com.tangem.domain.account.producer.MultiAccountListProducer +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.account.supplier.SingleAccountListSupplier +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 AccountListSupplierModule { + + @Provides + @Singleton + fun provideSingleAccountListSupplier(factory: SingleAccountListProducer.Factory): SingleAccountListSupplier { + return object : SingleAccountListSupplier( + factory = factory, + keyCreator = { "single_account_list_${it.userWalletId.stringValue}" }, + ) {} + } + + @Provides + @Singleton + fun provideMultiNetworkStatusSupplier(factory: MultiAccountListProducer.Factory): MultiAccountListSupplier { + return object : MultiAccountListSupplier( + factory = factory, + keyCreator = { "multi_networks_statuses" }, + ) {} + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/featuretoggle/DefaultAccountsFeatureToggles.kt b/data/account/src/main/kotlin/com/tangem/data/account/featuretoggle/DefaultAccountsFeatureToggles.kt new file mode 100644 index 0000000000..0a6accf03f --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/featuretoggle/DefaultAccountsFeatureToggles.kt @@ -0,0 +1,12 @@ +package com.tangem.data.account.featuretoggle + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles + +internal class DefaultAccountsFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : AccountsFeatureToggles { + + override val isFeatureEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "ACCOUNTS_FEATURE_ENABLED") +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultMultiAccountListFetcher.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultMultiAccountListFetcher.kt new file mode 100644 index 0000000000..ada7843d5c --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultMultiAccountListFetcher.kt @@ -0,0 +1,66 @@ +package com.tangem.data.account.fetcher + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.fetcher.MultiAccountListFetcher +import com.tangem.domain.account.fetcher.SingleAccountListFetcher +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap + +/** + * Implementation of [MultiAccountListFetcher] + * + * @property singleAccountListFetcher instance of [SingleAccountListFetcher] to fetch accounts for a single wallet + * @property userWalletsStore instance of [UserWalletsStore] to get all user wallets + * +[REDACTED_AUTHOR] + */ +internal class DefaultMultiAccountListFetcher( + private val singleAccountListFetcher: SingleAccountListFetcher, + private val userWalletsStore: UserWalletsStore, +) : MultiAccountListFetcher { + + override suspend fun invoke(params: MultiAccountListFetcher.Params): Either = either { + when (params) { + is MultiAccountListFetcher.Params.Set -> { + if (params.ids.isEmpty()) { + Timber.d("No wallet ids provided to fetch accounts.") + return@either + } + + val errors = ConcurrentHashMap() + + coroutineScope { + params.ids.forEach { userWalletId -> + launch { + singleAccountListFetcher( + params = SingleAccountListFetcher.Params(userWalletId), + ) + .onLeft { error -> errors[userWalletId] = error } + } + } + } + + if (errors.isNotEmpty()) { + val exception = IllegalStateException( + "Failed to fetch accounts for wallets:\n${errors.entries.joinToString(separator = "\n")}", + ) + + Timber.e(exception) + + raise(exception) + } + } + MultiAccountListFetcher.Params.All -> { + val userWalletsIds = userWalletsStore.userWalletsSync.map(UserWallet::walletId).toSet() + + invoke(params = MultiAccountListFetcher.Params.Set(ids = userWalletsIds)).bind() + } + } + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultSingleAccountListFetcher.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultSingleAccountListFetcher.kt new file mode 100644 index 0000000000..b10047e2ec --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultSingleAccountListFetcher.kt @@ -0,0 +1,21 @@ +package com.tangem.data.account.fetcher + +import arrow.core.Either +import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.domain.account.fetcher.SingleAccountListFetcher + +/** + * Implementation of [SingleAccountListFetcher] + * + * @property walletAccountsFetcher instance of [WalletAccountsFetcher] to fetch accounts for a single wallet + * +[REDACTED_AUTHOR] + */ +internal class DefaultSingleAccountListFetcher( + private val walletAccountsFetcher: WalletAccountsFetcher, +) : SingleAccountListFetcher { + + override suspend fun invoke(params: SingleAccountListFetcher.Params): Either = Either.catch { + walletAccountsFetcher.fetch(userWalletId = params.userWalletId) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt new file mode 100644 index 0000000000..17bc75d8ff --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt @@ -0,0 +1,166 @@ +package com.tangem.data.account.fetcher + +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.utils.assignTokens +import com.tangem.data.account.utils.toUserTokensResponse +import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.data.common.account.WalletAccountsSaver +import com.tangem.data.common.api.safeApiCall +import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.common.response.IF_NONE_MATCH_HEADER +import com.tangem.datasource.api.common.response.isNetworkError +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.utils.getSyncOrNull +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Default implementation of [WalletAccountsFetcher] and [WalletAccountsSaver] + * + * @property tangemTechApi API for network requests + * @property accountsResponseStoreFactory factory to create [AccountsResponseStore] + * @property userTokensSaver saves user tokens to the database + * @property fetchWalletAccountsErrorHandler handles errors during fetching wallet accounts + * @property eTagsStore store for ETags to manage caching + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +@Singleton +internal class DefaultWalletAccountsFetcher @Inject constructor( + private val tangemTechApi: TangemTechApi, + private val accountsResponseStoreFactory: AccountsResponseStoreFactory, + private val userTokensSaver: UserTokensSaver, + private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler, + private val eTagsStore: ETagsStore, + private val dispatchers: CoroutineDispatcherProvider, +) : WalletAccountsFetcher, WalletAccountsSaver { + + override suspend fun fetch(userWalletId: UserWalletId) { + val savedAccountsResponse = getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull() + val accountsResponse = fetchWalletAccounts(userWalletId, savedAccountsResponse) + val unassignedTokens = accountsResponse?.unassignedTokens + + if (!unassignedTokens.isNullOrEmpty()) { + assignTokens(userWalletId, accountsResponse) + } + } + + override suspend fun pushAndStore(userWalletId: UserWalletId, response: GetWalletAccountsResponse) { + push(userWalletId = userWalletId, accounts = response.accounts) + store(userWalletId = userWalletId, response = response) + } + + override suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) { + val store = getAccountsResponseStore(userWalletId = userWalletId) + + store.updateData { response } + } + + override suspend fun push(userWalletId: UserWalletId, accounts: List) { + push(userWalletId = userWalletId, body = SaveWalletAccountsResponse(accounts = accounts)) + } + + override suspend fun push(userWalletId: UserWalletId, body: SaveWalletAccountsResponse) { + safeApiCall( + call = { + var eTag = getETag(userWalletId) + + if (eTag == null) { + fetch(userWalletId) + + eTag = getETag(userWalletId) ?: error("ETag is null after fetch") + } + + val apiResponse = withContext(dispatchers.io) { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = body, + ) + } + + saveETag(userWalletId, apiResponse) + + apiResponse.bind() + }, + onError = { error -> + if (error.isNetworkError(code = Code.PRECONDITION_FAILED)) { + throw error + } + }, + ) + } + + private suspend fun fetchWalletAccounts( + userWalletId: UserWalletId, + savedAccountsResponse: GetWalletAccountsResponse?, + ): GetWalletAccountsResponse? { + return safeApiCall( + call = { + val apiResponse = withContext(dispatchers.io) { + tangemTechApi.getWalletAccounts( + walletId = userWalletId.stringValue, + eTag = getETag(userWalletId), + ) + } + + saveETag(userWalletId, apiResponse) + + val responseBody = apiResponse.bind() + store(userWalletId = userWalletId, response = responseBody) + + responseBody + }, + onError = { + // pushWalletAccounts and storeWalletAccounts help to avoid cyclic dependency + fetchWalletAccountsErrorHandler.handle( + error = it, + userWalletId = userWalletId, + savedAccountsResponse = savedAccountsResponse, + pushWalletAccounts = ::push, + storeWalletAccounts = ::store, + ) + + null + }, + ) + } + + private suspend fun assignTokens(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) { + val accountsResponseWithTokens = accountsResponse.assignTokens(userWalletId) + + pushAndStore(userWalletId = userWalletId, response = accountsResponseWithTokens) + + userTokensSaver.push( + userWalletId = userWalletId, + response = accountsResponseWithTokens.toUserTokensResponse(), + ) + } + + private suspend fun getETag(userWalletId: UserWalletId): String? { + return eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + } + + private suspend fun saveETag(userWalletId: UserWalletId, apiResponse: ApiResponse<*>) { + val eTag = apiResponse.headers[IF_NONE_MATCH_HEADER]?.firstOrNull() + + if (eTag != null) { + eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = eTag) + } + } + + private fun getAccountsResponseStore(userWalletId: UserWalletId): AccountsResponseStore { + return accountsResponseStoreFactory.create(userWalletId = userWalletId) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt new file mode 100644 index 0000000000..2acc302f24 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -0,0 +1,130 @@ +package com.tangem.data.account.fetcher + +import com.tangem.data.account.converter.CryptoPortfolioConverter +import com.tangem.data.account.utils.assignTokens +import com.tangem.data.account.utils.toUserTokensResponse +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.common.response.isNetworkError +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.local.token.UserTokensResponseStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import timber.log.Timber +import javax.inject.Inject + +/** + * Handles errors that occur during the fetching of wallet accounts + * + * @property userTokensSaver saves user tokens to the storage + * @property userWalletsStore provides access to user wallet data + * @property userTokensResponseStore provides access to user token responses. + * @property cryptoPortfolioCF factory for converting crypto portfolios + * @property userTokensResponseFactory factory for creating user token responses + * @property cardCryptoCurrencyFactory factory for creating default cryptocurrencies for multi-currency wallets + * + * @see DefaultWalletAccountsFetcher + * +[REDACTED_AUTHOR] + */ +internal class FetchWalletAccountsErrorHandler @Inject constructor( + private val userTokensSaver: UserTokensSaver, + private val userWalletsStore: UserWalletsStore, + private val userTokensResponseStore: UserTokensResponseStore, + private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, + private val userTokensResponseFactory: UserTokensResponseFactory, + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, +) { + + /** + * Handles the error that occurred during the fetching of wallet accounts. + * [pushWalletAccounts] and [storeWalletAccounts] are functions that passed as parameters to avoid + * cyclic dependencies. + * + * @param error the error that occurred + * @param userWalletId the ID of the user wallet + * @param savedAccountsResponse the previously saved wallet accounts response, if available + * @param pushWalletAccounts function to push wallet accounts to the server + * @param storeWalletAccounts function to store wallet accounts locally + */ + suspend fun handle( + error: ApiResponseError, + userWalletId: UserWalletId, + savedAccountsResponse: GetWalletAccountsResponse?, + pushWalletAccounts: suspend (userWalletId: UserWalletId, accounts: List) -> Unit, + storeWalletAccounts: suspend (userWalletId: UserWalletId, response: GetWalletAccountsResponse) -> Unit, + ) { + val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED) + if (isResponseUpToDate) { + Timber.e("ETag is up to date, no need to update accounts for wallet: $userWalletId") + return + } + + val (accountDTOs, userTokensResponse) = if (savedAccountsResponse == null) { + val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) + + createDefaultAccountDTOs(userWallet) to getFromLegacyStore(userWalletId).orDefault(userWallet) + } else { + savedAccountsResponse.accounts to savedAccountsResponse.toUserTokensResponse() + } + + val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND) + if (isNotFoundError) { + pushWalletAccounts(userWalletId, accountDTOs) + userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse) + } + + val response = savedAccountsResponse.orDefault(userWalletId, accountDTOs, userTokensResponse) + storeWalletAccounts(userWalletId, response) + } + + private fun createDefaultAccountDTOs(userWallet: UserWallet): List { + val accounts = AccountList.empty(userWallet).accounts + .filterIsInstance() + + val converter = cryptoPortfolioCF.create(userWallet = userWallet) + + return converter.convertListBack(input = accounts) + } + + private suspend fun getFromLegacyStore(userWalletId: UserWalletId): UserTokensResponse? { + return userTokensResponseStore.getSyncOrNull(userWalletId) + .also { userTokensResponseStore.clear(userWalletId) } + } + + private fun UserTokensResponse?.orDefault(userWallet: UserWallet): UserTokensResponse { + if (this != null) return this + + return userTokensResponseFactory.createUserTokensResponse( + currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet = userWallet), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } + + private fun GetWalletAccountsResponse?.orDefault( + userWalletId: UserWalletId, + accountDTOs: List, + userTokensResponse: UserTokensResponse, + ): GetWalletAccountsResponse { + if (this != null) return this + + return GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = userTokensResponse.group, + sort = userTokensResponse.sort, + totalAccounts = accountDTOs.size, + ), + accounts = accountDTOs.assignTokens(userWalletId = userWalletId, tokens = userTokensResponse.tokens), + unassignedTokens = emptyList(), + ) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt new file mode 100644 index 0000000000..da4e0e7853 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt @@ -0,0 +1,53 @@ +package com.tangem.data.account.producer + +import arrow.core.Option +import arrow.core.some +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.MultiAccountListProducer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* + +/** + * Default implementation of [MultiAccountListProducer]. + * Produces a list of [AccountList]s for all user wallets. + * + * @property params params + * @property userWalletsStore store that provides user wallets + * @property walletAccountListFlowFactory builder to create flows of [AccountList] for each wallet + * @property dispatchers coroutine dispatchers provider + * +[REDACTED_AUTHOR] + */ +internal class DefaultMultiAccountListProducer @AssistedInject constructor( + @Assisted val params: Unit, + private val userWalletsStore: UserWalletsStore, + private val walletAccountListFlowFactory: WalletAccountListFlowFactory, + private val dispatchers: CoroutineDispatcherProvider, +) : MultiAccountListProducer { + + override val fallback: Option> = emptyList().some() + + @OptIn(ExperimentalCoroutinesApi::class) + override fun produce(): Flow> { + return userWalletsStore.userWallets + .distinctUntilChanged() + .flatMapLatest { userWallets -> + combine( + flows = userWallets.map(walletAccountListFlowFactory::create), + transform = ::listOf, + ) + } + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : MultiAccountListProducer.Factory { + override fun create(params: Unit): DefaultMultiAccountListProducer + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt new file mode 100644 index 0000000000..43d0745ea2 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt @@ -0,0 +1,52 @@ +package com.tangem.data.account.producer + +import arrow.core.Option +import arrow.core.none +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull + +/** + * Default implementation of [SingleAccountListProducer]. + * Produces a list of [AccountList] for a specific user wallet. + * + * @property params params containing the user wallet ID + * @property userWalletsStore store that provides user wallets + * @property walletAccountListFlowFactory builder to create flows of [AccountList] for each wallet + * @property dispatchers coroutine dispatchers provider + * +[REDACTED_AUTHOR] + */ +internal class DefaultSingleAccountListProducer @AssistedInject constructor( + @Assisted val params: SingleAccountListProducer.Params, + private val userWalletsStore: UserWalletsStore, + private val walletAccountListFlowFactory: WalletAccountListFlowFactory, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleAccountListProducer { + + override val fallback: Option = none() + + @OptIn(ExperimentalCoroutinesApi::class) + override fun produce(): Flow { + return userWalletsStore.userWallets + .mapNotNull { userWallets -> + userWallets.firstOrNull { it.walletId == params.userWalletId } + } + .flatMapLatest(walletAccountListFlowFactory::create) + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : SingleAccountListProducer.Factory { + override fun create(params: SingleAccountListProducer.Params): DefaultSingleAccountListProducer + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt new file mode 100644 index 0000000000..1f8f47d3c9 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt @@ -0,0 +1,58 @@ +package com.tangem.data.account.producer + +import com.tangem.data.account.converter.AccountListConverter +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.models.wallet.requireColdWallet +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +/** + * Factory that creates a flow of [AccountList] for a specific [UserWallet] + * + * @property accountsResponseStoreFactory factory to create [AccountsResponseStore] + * @property accountListConverterFactory factory to create [AccountListConverter] + * @property cardCryptoCurrencyFactory factory to create supported crypto currencies for a card + * +[REDACTED_AUTHOR] + */ +internal class WalletAccountListFlowFactory @Inject constructor( + private val accountsResponseStoreFactory: AccountsResponseStoreFactory, + private val accountListConverterFactory: AccountListConverter.Factory, + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, +) { + + fun create(userWallet: UserWallet): Flow { + return if (userWallet.isMultiCurrency) { + createForMultiWallet(userWallet) + } else { + flowOf(createForSingleWallet(userWallet)) + } + } + + private fun createForMultiWallet(userWallet: UserWallet): Flow { + val converter by lazy { accountListConverterFactory.create(userWallet) } + + return accountsResponseStoreFactory.create(userWallet.walletId).data + .filterNotNull() + .distinctUntilChanged() + .map(converter::convert) + } + + private fun createForSingleWallet(userWallet: UserWallet): AccountList { + val isSingleWalletWithToken = userWallet.requireColdWallet().cardTypesResolver.isSingleWalletWithToken() + + val currencies = if (isSingleWalletWithToken) { + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = userWallet).toSet() + } else { + cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet = userWallet).let(::setOf) + } + + return AccountList.empty(userWallet = userWallet, cryptoCurrencies = currencies) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt index a9fba30f57..a9ceb3026e 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt @@ -1,90 +1,149 @@ package com.tangem.data.account.repository import arrow.core.Option -import arrow.core.Option.Companion.catch -import arrow.core.none import arrow.core.raise.option -import com.tangem.datasource.local.datastore.RuntimeSharedStore +import arrow.core.toOption +import com.tangem.data.account.converter.AccountConverterFactoryContainer +import com.tangem.data.account.converter.ArchivedAccountConverter +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.store.ArchivedAccountsStore +import com.tangem.data.account.store.ArchivedAccountsStoreFactory +import com.tangem.data.common.account.WalletAccountsSaver +import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.datasource.utils.getSyncOrNull import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.account.repository.AccountsCRUDRepository -import com.tangem.domain.models.account.* +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.extensions.addOrReplace +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext /** [REDACTED_AUTHOR] */ -// TODO: [REDACTED_JIRA] +@Suppress("LongParameterList") internal class DefaultAccountsCRUDRepository( - private val runtimeStore: RuntimeSharedStore>, + private val tangemTechApi: TangemTechApi, + private val walletAccountsSaver: WalletAccountsSaver, + private val accountsResponseStoreFactory: AccountsResponseStoreFactory, + private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory, private val userWalletsStore: UserWalletsStore, + private val eTagsStore: ETagsStore, + private val convertersContainer: AccountConverterFactoryContainer, + private val dispatchers: CoroutineDispatcherProvider, ) : AccountsCRUDRepository { - override suspend fun getAccounts(userWalletId: UserWalletId): Option = catch { - runtimeStore.getSyncOrNull() - ?.firstOrNull { it.userWallet.walletId == userWalletId } - ?: return none() + override suspend fun getAccountListSync(userWalletId: UserWalletId): Option = option { + val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId) + + ensureNotNull(accountListResponse) + + val converter = convertersContainer.createAccountListConverter(userWalletId = userWalletId) + converter.convert(value = accountListResponse) } - override suspend fun getAccount(accountId: AccountId): Option = catch { - runtimeStore.getSyncOrNull().orEmpty() - .flatMap { it.accounts } - .firstOrNull { it.accountId == accountId } as? Account.CryptoPortfolio - ?: return none() + override suspend fun getAccountSync(accountId: AccountId): Option = option { + val userWalletId = accountId.userWalletId + + val accountResponse = getAccountsResponseSync(userWalletId = userWalletId) + ?.accounts?.firstOrNull { it.id == accountId.value } + + ensureNotNull(accountResponse) + + val converter = convertersContainer.createCryptoPortfolioConverter(userWalletId = userWalletId) + converter.convert(value = accountResponse) } - override suspend fun getArchivedAccount(accountId: AccountId): Option = option { - createMockArchivedAccount(userWalletId = accountId.userWalletId) + override suspend fun getArchivedAccountSync(accountId: AccountId): Option { + val store = getArchivedAccountsStore(userWalletId = accountId.userWalletId) + + return store.getSyncOrNull() + ?.firstOrNull { it.accountId == accountId } + .toOption() } - override suspend fun getArchivedAccountsSync(userWalletId: UserWalletId): Option> = option { - listOf( - createMockArchivedAccount(userWalletId), - ) + override suspend fun getArchivedAccountListSync(userWalletId: UserWalletId): Option> { + val store = getArchivedAccountsStore(userWalletId = userWalletId) + + return store.getSyncOrNull().toOption() } override fun getArchivedAccounts(userWalletId: UserWalletId): Flow> { - return flow { - getArchivedAccountsSync(userWalletId).getOrNull().orEmpty() - } + val store = getArchivedAccountsStore(userWalletId = userWalletId) + + return store.get() } - override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) = Unit + override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) { + val response = withContext(dispatchers.io) { + tangemTechApi.getWalletArchivedAccounts( + walletId = userWalletId.stringValue, + eTag = getETag(userWalletId), + ).getOrThrow() + } + + val store = getArchivedAccountsStore(userWalletId = userWalletId) + val converter = ArchivedAccountConverter(userWalletId = userWalletId) + + val archivedAccounts = converter.convertList(input = response.accounts) + + store.store(value = archivedAccounts) + } override suspend fun saveAccounts(accountList: AccountList) { - runtimeStore.update(emptyList()) { - it.addOrReplace(accountList) { it.userWallet.walletId == accountList.userWallet.walletId } - } + val userWalletId = accountList.userWallet.walletId + + val converter = convertersContainer.getWalletAccountsResponseCF.create(userWallet = accountList.userWallet) + val accountsResponse = converter.convert(value = accountList) + + walletAccountsSaver.pushAndStore(userWalletId = userWalletId, response = accountsResponse) } - override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Int { - val activeAccountsCount = runtimeStore.getSyncOrNull()?.size ?: 1 + override suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option = option { + val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId) - return activeAccountsCount + 1 + ensureNotNull(accountListResponse) + + return accountListResponse.wallet.totalAccounts.toOption() + } + + override fun getTotalAccountsCount(userWalletId: UserWalletId): Flow> { + return getAccountsResponseStore(userWalletId = userWalletId).data + .map { it?.wallet?.totalAccounts.toOption() } } override fun getUserWallet(userWalletId: UserWalletId): UserWallet { return userWalletsStore.getSyncStrict(userWalletId) } - private fun createMockArchivedAccount(userWalletId: UserWalletId): ArchivedAccount { - val derivationIndex = DerivationIndex(value = 1000).getOrNull()!! + override fun getUserWallets(): Flow> = userWalletsStore.userWallets - return ArchivedAccount( - accountId = AccountId.forCryptoPortfolio( - userWalletId = userWalletId, - derivationIndex = derivationIndex, - ), - name = AccountName("Archived Account").getOrNull()!!, - icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), - derivationIndex = derivationIndex, - tokensCount = 2, - networksCount = 1, - ) + override fun getUserWalletsSync(): List = userWalletsStore.userWalletsSync + + private suspend fun getETag(userWalletId: UserWalletId): String? { + return eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + } + + private suspend fun getAccountsResponseSync(userWalletId: UserWalletId): GetWalletAccountsResponse? { + val store = getAccountsResponseStore(userWalletId = userWalletId) + return store.getSyncOrNull() + } + + private fun getAccountsResponseStore(userWalletId: UserWalletId): AccountsResponseStore { + return accountsResponseStoreFactory.create(userWalletId = userWalletId) + } + + private fun getArchivedAccountsStore(userWalletId: UserWalletId): ArchivedAccountsStore { + return archivedAccountsStoreFactory.create(userWalletId) } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt new file mode 100644 index 0000000000..a8ffea4d37 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt @@ -0,0 +1,66 @@ +package com.tangem.data.account.store + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +typealias AccountsResponseStore = DataStore + +/** + * Factory class for creating and managing instances of [AccountsResponseStore]. + * This class is responsible for creating a [DataStore] for each unique [UserWalletId]. + * + * @property context application context used to access the file system + * @property moshi moshi instance for JSON serialization and deserialization + * @property dispatchers coroutine dispatcher provider + * +[REDACTED_AUTHOR] + */ +internal class AccountsResponseStoreFactory @Inject constructor( + @ApplicationContext private val context: Context, + @NetworkMoshi private val moshi: Moshi, + private val dispatchers: CoroutineDispatcherProvider, +) { + + @OptIn(ExperimentalStdlibApi::class) + private val adapter by lazy { moshi.adapter() } + + private val createdDataStores = ConcurrentHashMap() + + /** + * Creates or retrieves an [AccountsResponseStore] for the given [UserWalletId]. + * + * @param userWalletId the unique identifier of the user's wallet + */ + fun create(userWalletId: UserWalletId): AccountsResponseStore { + return createdDataStores.computeIfAbsent(userWalletId) { + DataStoreFactory.create( + serializer = MoshiDataStoreSerializer(defaultValue = null, adapter = adapter), + produceFile = { context.dataStoreFile(fileName = "wallet_accounts_${userWalletId.stringValue}") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ) + } + } + + @VisibleForTesting + fun getAllStores(): Map = createdDataStores.toMap() + + @VisibleForTesting + fun clearStores() { + createdDataStores.clear() + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt new file mode 100644 index 0000000000..cbbe1ca0b9 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt @@ -0,0 +1,68 @@ +package com.tangem.data.account.store + +import androidx.annotation.VisibleForTesting +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.domain.account.models.ArchivedAccount +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlin.time.Duration.Companion.seconds + +/** + * Store for managing archived accounts with support for data expiration + * + * @property runtimeStore the underlying runtime shared store for storing the list of archived accounts + * +[REDACTED_AUTHOR] + */ +internal class ArchivedAccountsStore( + private val runtimeStore: RuntimeStateStore?>, +) { + + private var timestamp: Long? = null + + /** Retrieves a flow of archived accounts, filtering out null values */ + fun get(): Flow> { + return runtimeStore.get() + .map { + if (isDataExpired()) null else it + } + .filterNotNull() + } + + /** Retrieves the list of archived accounts synchronously, or null if the data is expired */ + suspend fun getSyncOrNull(): List? { + if (isDataExpired()) return null + + return runtimeStore.getSyncOrNull() + } + + /** Stores the provided list of archived accounts [value] */ + suspend fun store(value: List) { + timestamp = System.currentTimeMillis() + + runtimeStore.store(value) + } + + private fun isDataExpired(): Boolean { + val currentTime = System.currentTimeMillis() + val storedTime = timestamp ?: return true + + return currentTime - storedTime >= EXPIRATION_DURATION_MS + } + + @VisibleForTesting + fun setTimestamp(time: Long) { + timestamp = time + } + + @VisibleForTesting + fun clear() { + timestamp = null + runtimeStore.clear() + } + + private companion object Companion { + val EXPIRATION_DURATION_MS = 120.seconds.inWholeMicroseconds + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt new file mode 100644 index 0000000000..96e522270b --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt @@ -0,0 +1,37 @@ +package com.tangem.data.account.store + +import androidx.annotation.VisibleForTesting +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.domain.models.wallet.UserWalletId +import java.util.concurrent.ConcurrentHashMap + +/** + * Factory for creating and managing instances of [ArchivedAccountsStore]. + + * and reused for each unique [UserWalletId]. + * +[REDACTED_AUTHOR] + */ +internal object ArchivedAccountsStoreFactory { + + private val createdRuntimeStores = ConcurrentHashMap() + + /** + * Creates or retrieves an existing instance of [ArchivedAccountsStore] for the given [userWalletId]. + * + * @param userWalletId the unique identifier for the user wallet + */ + fun create(userWalletId: UserWalletId): ArchivedAccountsStore { + return createdRuntimeStores.computeIfAbsent(userWalletId) { + ArchivedAccountsStore(runtimeStore = RuntimeStateStore(defaultValue = null)) + } + } + + @VisibleForTesting + fun getAllStores(): Map = createdRuntimeStores.toMap() + + @VisibleForTesting + fun clearStores() { + createdRuntimeStores.clear() + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt b/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt new file mode 100644 index 0000000000..caace442d6 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt @@ -0,0 +1,57 @@ +package com.tangem.data.account.utils + +import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.wallet.UserWalletId + +/** Flattens the tokens from all wallet accounts into a single list */ +internal fun GetWalletAccountsResponse.flattenTokens(): List { + return accounts.flatMap { it.tokens.orEmpty() } +} + +/** Converts the [GetWalletAccountsResponse] into a [UserTokensResponse] */ +internal fun GetWalletAccountsResponse.toUserTokensResponse(): UserTokensResponse { + return UserTokensResponse( + group = wallet.group, + sort = wallet.sort, + tokens = flattenTokens(), + ) +} + +/** + * Assigns tokens from a [UserTokensResponse] to the wallet accounts in the [GetWalletAccountsResponse] + * + * @param userWalletId the ID of the user wallet + * + * @return a new [GetWalletAccountsResponse]` with tokens assigned to the wallet accounts + */ +internal fun GetWalletAccountsResponse.assignTokens(userWalletId: UserWalletId): GetWalletAccountsResponse { + return copy( + accounts = accounts.assignTokens(userWalletId = userWalletId, tokens = unassignedTokens), + unassignedTokens = emptyList(), + ) +} + +/** + * Assigns tokens from a [UserTokensResponse] to a list of wallet accounts + * + * @param userWalletId the ID of the user wallet + * @param tokens tokens to be assigned + * + * @return a new list of [WalletAccountDTO] with tokens assigned to each account + */ +internal fun List.assignTokens( + userWalletId: UserWalletId, + tokens: List, +): List { + val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens) + .groupBy { it.accountId } + + return map { accountDTO -> + accountDTO.copy( + tokens = enrichedTokens[accountDTO.id].orEmpty(), + ) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt b/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt new file mode 100644 index 0000000000..880b20520e --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt @@ -0,0 +1,87 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId + +internal fun createWalletAccountDTO( + userWalletId: UserWalletId, + accountId: String? = null, + accountName: String? = null, + icon: String? = null, + iconColor: String? = null, + derivationIndex: Int? = null, + tokens: List? = emptyList(), +): WalletAccountDTO { + val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId) + + return WalletAccountDTO( + id = accountId ?: mainAccount.accountId.value, + name = accountName ?: (mainAccount.accountName as? AccountName.Custom)?.value, + derivationIndex = derivationIndex ?: mainAccount.derivationIndex.value, + icon = icon ?: mainAccount.icon.value.name, + iconColor = iconColor ?: mainAccount.icon.color.name, + tokens = tokens, + ) +} + +internal fun createCryptoPortfolio(userWalletId: UserWalletId): Account.CryptoPortfolio { + return Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId) +} + +internal fun createGetWalletAccountsResponse( + userWalletId: UserWalletId, + groupType: UserTokensResponse.GroupType = UserTokensResponse.GroupType.NETWORK, + sortType: UserTokensResponse.SortType = UserTokensResponse.SortType.BALANCE, + accountId: String? = null, + accountName: String? = null, + icon: String? = null, + iconColor: String? = null, + derivationIndex: Int? = null, + tokens: List? = emptyList(), + unassignedTokens: List = emptyList(), +): GetWalletAccountsResponse { + return GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 0, + group = groupType, + sort = sortType, + totalAccounts = 1, + ), + accounts = buildList { + createWalletAccountDTO( + userWalletId = userWalletId, + accountId = accountId, + accountName = accountName, + icon = icon, + iconColor = iconColor, + derivationIndex = derivationIndex, + tokens = tokens, + ) + .let(::add) + }, + unassignedTokens = unassignedTokens, + ) +} + +internal fun createAccountList( + userWallet: UserWallet, + sortType: TokensSortType = TokensSortType.BALANCE, + groupType: TokensGroupType = TokensGroupType.NETWORK, +): AccountList { + return AccountList( + userWallet = userWallet, + accounts = setOf(createCryptoPortfolio(userWallet.walletId)), + totalAccounts = 1, + sortType = sortType, + groupType = groupType, + ) + .getOrNull()!! +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt new file mode 100644 index 0000000000..8b32adb5e5 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt @@ -0,0 +1,159 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AccountListConverterTest { + + private val userWallet = mockk { + every { walletId } returns UserWalletId("011") + } + private val cryptoPortfolioConverterFactory = mockk() + private val cryptoPortfolioConverter = mockk() + private val converter = AccountListConverter(userWallet, cryptoPortfolioConverterFactory) + + @BeforeAll + fun setupAll() { + every { cryptoPortfolioConverterFactory.create(userWallet) } returns cryptoPortfolioConverter + } + + @BeforeEach + fun setupEach() { + clearMocks(cryptoPortfolioConverter) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @Test + fun `cryptoPortfolioConverter throws exception`() { + // Arrange + val dto = createGetWalletAccountsResponse(userWallet.walletId) + val exception = IllegalStateException("Test exception") + + every { cryptoPortfolioConverter.convert(any()) } throws exception + + // Act + val actual = runCatching { converter.convert(dto) }.exceptionOrNull()!! + + // Asset + val expected = exception + Truth.assertThat(actual).isInstanceOf(expected::class.java) + Truth.assertThat(actual.message).isEqualTo(expected.message) + } + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Arrange + if (model.expected.isSuccess) { + model.value.accounts.forEach { dto -> + val account = model.expected.getOrNull()!!.accounts + .firstOrNull { it.accountId.value == dto.id } as? Account.CryptoPortfolio + + every { cryptoPortfolioConverter.convert(dto) } returns account!! + } + } + + // Act + val actual = runCatching { converter.convert(model.value) } + + // Asset + actual + .onSuccess { + val expected = model.expected.getOrNull() + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull() ?: throw it + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + ConvertModel( + value = createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.BALANCE, + groupType = UserTokensResponse.GroupType.NETWORK, + ), + expected = Result.success( + createAccountList( + userWallet = userWallet, + sortType = TokensSortType.BALANCE, + groupType = TokensGroupType.NETWORK, + ), + ), + ), + ConvertModel( + value = createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.MANUAL, + groupType = UserTokensResponse.GroupType.TOKEN, + ), + expected = Result.success( + createAccountList( + userWallet = userWallet, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ), + ), + ), + ConvertModel( + value = createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.MARKETCAP, + groupType = UserTokensResponse.GroupType.NONE, + ), + expected = Result.success( + createAccountList( + userWallet = userWallet, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ), + ), + ), + ConvertModel( + value = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 0, + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + totalAccounts = 1, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ), + expected = Result.failure( + IllegalStateException( + "Failed to convert GetWalletAccountsResponse to AccountList: EmptyAccountsList: " + + "The accounts list cannot be empty", + ), + ), + ), + ) + } + } + + data class ConvertModel( + val value: GetWalletAccountsResponse, + val expected: Result, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/AccountNameConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/AccountNameConverterTest.kt new file mode 100644 index 0000000000..0c22242e2a --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/AccountNameConverterTest.kt @@ -0,0 +1,65 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.domain.models.account.AccountName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class AccountNameConverterTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Act + val actual = AccountNameConverter.convert(value = model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels(): List { + return listOf( + ConvertModel( + value = AccountName.Custom("MyAccount").getOrNull()!!, + expected = "MyAccount", + ), + ConvertModel(value = AccountName.DefaultMain, expected = null), + ) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @ParameterizedTest + @ProvideTestModels + fun convertBack(model: ConvertBackModel) { + // Act + val actual = AccountNameConverter.convertBack(value = model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels(): List { + return listOf( + ConvertBackModel(value = "MyAccount", expected = AccountName.Custom("MyAccount").getOrNull()!!), + ConvertBackModel(value = null, expected = AccountName.DefaultMain), + ) + } + } + + data class ConvertModel(val value: AccountName, val expected: String?) + + data class ConvertBackModel(val value: String?, val expected: AccountName) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt new file mode 100644 index 0000000000..bf08d5b7d9 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt @@ -0,0 +1,150 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ArchivedAccountConverterTest { + + private val userWalletId = UserWalletId("011") + private val converter = ArchivedAccountConverter(userWalletId = userWalletId) + + @ParameterizedTest + @ProvideTestModels + fun convert(model: TestModel) { + // Act + val actual = runCatching { converter.convert(value = model.value) } + + // Assert + actual + .onSuccess { + val expected = model.expected.getOrNull()!! + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull()!! + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + TestModel( + value = createDTO(), + expected = Result.success(createDomain()), + ), + TestModel( + value = createDTO(accountId = "123"), + expected = Result.failure( + IllegalStateException( + "Unable to create AccountId from value: 123. Cause: ${AccountId.Error.InvalidFormat}", + ), + ), + ), + TestModel( + value = createDTO(name = null), + expected = Result.success( + createDomain().copy(name = AccountName.DefaultMain), + ), + ), + TestModel( + value = createDTO(name = ""), + expected = Result.failure( + IllegalStateException( + "Unable to create AccountName from value: . Cause: ${AccountName.Error.Empty}", + ), + ), + ), + TestModel( + value = createDTO(icon = "INVALID_ICON"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON", + ), + ), + ), + TestModel( + value = createDTO(iconColor = "INVALID_COLOR"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR", + ), + ), + ), + TestModel( + value = createDTO(derivationIndex = -1), + expected = Result.failure( + IllegalStateException( + "Unable to create DerivationIndex from value: -1. " + + "Cause: NegativeDerivationIndex: Derivation index cannot be negative: -1", + ), + ), + ), + TestModel( + value = createDTO(totalTokens = null), + expected = Result.failure( + IllegalStateException("Total tokens should not be null"), + ), + ), + TestModel( + value = createDTO(totalNetworks = null), + expected = Result.failure( + IllegalStateException("Total networks should not be null"), + ), + ), + ) + } + + private fun createDTO( + accountId: String = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027", + name: String? = "Test Account", + icon: String = "Letter", + iconColor: String = "Azure", + derivationIndex: Int = 0, + totalTokens: Int? = 1, + totalNetworks: Int? = 1, + ): WalletAccountDTO { + return WalletAccountDTO( + id = accountId, + name = name, + derivationIndex = derivationIndex, + icon = icon, + iconColor = iconColor, + tokens = null, + totalTokens = totalTokens, + totalNetworks = totalNetworks, + ) + } + + private fun createDomain(): ArchivedAccount { + return ArchivedAccount( + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex(0).getOrNull()!!), + name = AccountName("Test Account").getOrNull()!!, + derivationIndex = 0.toDerivationIndex(), + icon = CryptoPortfolioIcon.ofCustomAccount( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.Azure, + ), + tokensCount = 1, + networksCount = 1, + ) + } + + data class TestModel( + val value: WalletAccountDTO, + val expected: Result, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt new file mode 100644 index 0000000000..7e3e41a243 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt @@ -0,0 +1,163 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CryptoPortfolioConverterTest { + + private val userWallet = mockk { + every { walletId } returns UserWalletId("011") + } + + private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk() + private val userTokensResponseFactory: UserTokensResponseFactory = mockk() + private val converter = CryptoPortfolioConverter( + userWallet = userWallet, + responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, + userTokensResponseFactory = userTokensResponseFactory, + ) + + @BeforeEach + fun setupEach() { + clearMocks(responseCryptoCurrenciesFactory, userTokensResponseFactory) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Act + val actual = runCatching { converter.convert(model.value) } + + // Asset + actual + .onSuccess { + val expected = model.expected.getOrNull() + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull() ?: throw it + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId), + expected = Result.success(createCryptoPortfolio(userWalletId = userWallet.walletId)), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountId = "123"), + expected = Result.failure( + IllegalStateException( + "Unable to create AccountId from value: 123. Cause: ${AccountId.Error.InvalidFormat}", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountName = null), + expected = Result.success( + createCryptoPortfolio(userWalletId = userWallet.walletId).copy( + accountName = AccountName.DefaultMain, + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountName = ""), + expected = Result.failure( + IllegalStateException( + "Unable to create AccountName from value: . Cause: ${AccountName.Error.Empty}", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, icon = "INVALID_ICON"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, iconColor = "INVALID_COLOR"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, derivationIndex = -1), + expected = Result.failure( + IllegalStateException( + "Unable to create DerivationIndex from value: -1. " + + "Cause: NegativeDerivationIndex: Derivation index cannot be negative: -1", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, tokens = null), + expected = Result.failure( + IllegalStateException("Tokens should not be null"), + ), + ), + ) + } + } + + data class ConvertModel( + val value: WalletAccountDTO, + val expected: Result, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @ParameterizedTest + @ProvideTestModels + fun convertBack(model: ConvertBackModel) { + // Act + val actual = converter.convertBack(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels(): List { + return listOf( + ConvertBackModel( + value = createCryptoPortfolio(userWalletId = userWallet.walletId), + expected = createWalletAccountDTO(userWalletId = userWallet.walletId), + ), + ) + } + } + + data class ConvertBackModel( + val value: Account.CryptoPortfolio, + val expected: WalletAccountDTO, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt new file mode 100644 index 0000000000..c6563f9fc8 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt @@ -0,0 +1,66 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.data.account.converter.CryptoPortfolioIconConverter.DataModel +import com.tangem.domain.models.account.CryptoPortfolioIcon +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class CryptoPortfolioIconConverterTest { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: TestModel) { + // Act + val actual = runCatching { CryptoPortfolioIconConverter.convert(model.value) } + + // Assert + actual + .onSuccess { + val expected = model.expected.getOrNull()!! + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull()!! + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + TestModel( + value = DataModel(icon = "Letter", color = "Azure"), + expected = Result.success( + CryptoPortfolioIcon.ofCustomAccount( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.Azure, + ), + ), + ), + TestModel( + value = DataModel(icon = "INVALID_ICON", color = "Azure"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON", + ), + ), + ), + TestModel( + value = DataModel(icon = "Letter", color = "INVALID_COLOR"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR", + ), + ), + ), + ) + } + + data class TestModel( + val value: DataModel, + val expected: Result, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt new file mode 100644 index 0000000000..766544b58a --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt @@ -0,0 +1,129 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetWalletAccountsResponseConverterTest { + + private val userWallet = mockk { + every { walletId } returns UserWalletId("011") + } + private val cryptoPortfolioConverterFactory = mockk() + private val cryptoPortfolioConverter = mockk() + private val converter = GetWalletAccountsResponseConverter( + userWallet = userWallet, + cryptoPortfolioConverterFactory = cryptoPortfolioConverterFactory, + ) + + @BeforeAll + fun setupAll() { + every { cryptoPortfolioConverterFactory.create(userWallet) } returns cryptoPortfolioConverter + } + + @BeforeEach + fun setupEach() { + clearMocks(cryptoPortfolioConverter) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @Test + fun `cryptoPortfolioConverter throws exception`() { + // Arrange + val domain = createAccountList(userWallet = userWallet) + val exception = IllegalStateException("Test exception") + + every { cryptoPortfolioConverter.convertBack(any()) } throws exception + + // Act + val actual = runCatching { converter.convert(domain) }.exceptionOrNull()!! + + // Asset + val expected = exception + Truth.assertThat(actual).isInstanceOf(expected::class.java) + Truth.assertThat(actual.message).isEqualTo(expected.message) + } + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Arrange + if (model.expected.isSuccess) { + model.value.accounts.forEach { domain -> + val dto = model.expected.getOrNull()!!.accounts.firstOrNull { it.id == domain.accountId.value } + + every { cryptoPortfolioConverter.convertBack(domain as Account.CryptoPortfolio) } returns dto!! + } + } + + // Act + val actual = runCatching { converter.convert(model.value) } + + // Asset + actual + .onSuccess { + val expected = model.expected.getOrNull() + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull() ?: throw it + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + ConvertModel( + value = createAccountList( + userWallet = userWallet, + sortType = TokensSortType.BALANCE, + groupType = TokensGroupType.NETWORK, + ), + expected = Result.success( + createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.BALANCE, + groupType = UserTokensResponse.GroupType.NETWORK, + ), + ), + ), + ConvertModel( + value = createAccountList( + userWallet = userWallet, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ), + expected = Result.success( + createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.MANUAL, + groupType = UserTokensResponse.GroupType.NONE, + ), + ), + ), + ) + } + } + + data class ConvertModel( + val value: AccountList, + val expected: Result, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt new file mode 100644 index 0000000000..6d5ebee814 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt @@ -0,0 +1,51 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SaveWalletAccountsResponseConverterTest { + + @Test + fun convert() { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns UserWalletId("011") + } + + val accountList = AccountList( + userWallet = userWallet, + accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)), + totalAccounts = 1, + ) + .getOrNull()!! + + // Act + val actual = SaveWalletAccountsResponseConverter.convert(value = accountList) + + // Assert + val expected = SaveWalletAccountsResponse( + accounts = listOf( + WalletAccountDTO( + id = accountList.mainAccount.accountId.value, + name = (accountList.mainAccount.accountName as? AccountName.Custom)?.value, + derivationIndex = accountList.mainAccount.derivationIndex.value, + icon = accountList.mainAccount.icon.value.name, + iconColor = accountList.mainAccount.icon.color.name, + ), + ), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt new file mode 100644 index 0000000000..62ba8b6f30 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt @@ -0,0 +1,85 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.TokensGroupType +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TokensGroupTypeConverterTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Act + val actual = TokensGroupTypeConverter.convert(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels(): List { + return listOf( + ConvertModel( + value = UserTokensResponse.GroupType.NETWORK, + expected = TokensGroupType.NETWORK, + ), + ConvertModel( + value = UserTokensResponse.GroupType.NONE, + expected = TokensGroupType.NONE, + ), + ConvertModel( + value = UserTokensResponse.GroupType.TOKEN, + expected = TokensGroupType.NONE, + ), + ) + } + } + + data class ConvertModel( + val value: UserTokensResponse.GroupType, + val expected: TokensGroupType, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @ParameterizedTest + @ProvideTestModels + fun convertBack(model: ConvertBackModel) { + // Act + val actual = TokensGroupTypeConverter.convertBack(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels(): List { + return listOf( + ConvertBackModel( + value = TokensGroupType.NETWORK, + expected = UserTokensResponse.GroupType.NETWORK, + ), + ConvertBackModel( + value = TokensGroupType.NONE, + expected = UserTokensResponse.GroupType.NONE, + ), + ) + } + } + + data class ConvertBackModel( + val value: TokensGroupType, + val expected: UserTokensResponse.GroupType, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt new file mode 100644 index 0000000000..e5476f5c49 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt @@ -0,0 +1,81 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.TokensSortType +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TokensSortTypeConverterTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Act + val actual = TokensSortTypeConverter.convert(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + fun provideTestModels() = listOf( + ConvertModel( + value = UserTokensResponse.SortType.BALANCE, + expected = TokensSortType.BALANCE, + ), + ConvertModel( + value = UserTokensResponse.SortType.MANUAL, + expected = TokensSortType.NONE, + ), + ConvertModel( + value = UserTokensResponse.SortType.MARKETCAP, + expected = TokensSortType.NONE, + ), + ) + } + + data class ConvertModel( + val value: UserTokensResponse.SortType, + val expected: TokensSortType, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @ParameterizedTest + @ProvideTestModels + fun convertBack(model: ConvertBackModel) { + // Act + val actual = TokensSortTypeConverter.convertBack(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + fun provideTestModels() = listOf( + ConvertBackModel( + value = TokensSortType.BALANCE, + expected = UserTokensResponse.SortType.BALANCE, + ), + ConvertBackModel( + value = TokensSortType.NONE, + expected = UserTokensResponse.SortType.MANUAL, + ), + ) + } + + data class ConvertBackModel( + val value: TokensSortType, + val expected: UserTokensResponse.SortType, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultMultiAccountListFetcherTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultMultiAccountListFetcherTest.kt new file mode 100644 index 0000000000..0ded63586a --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultMultiAccountListFetcherTest.kt @@ -0,0 +1,153 @@ +package com.tangem.data.account.fetcher + +import arrow.core.left +import arrow.core.right +import com.tangem.common.test.utils.assertEitherLeft +import com.tangem.common.test.utils.assertEitherRight +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.fetcher.MultiAccountListFetcher +import com.tangem.domain.account.fetcher.SingleAccountListFetcher +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultMultiAccountListFetcherTest { + + private val singleAccountListFetcher: SingleAccountListFetcher = mockk() + private val userWalletsStore: UserWalletsStore = mockk(relaxUnitFun = true) + + private val fetcher = DefaultMultiAccountListFetcher(singleAccountListFetcher, userWalletsStore) + + private val userWalletId1 = UserWalletId("011") + private val userWalletId2 = UserWalletId("012") + + private val userWalletIds = setOf(userWalletId1, userWalletId2) + private val userWallets = userWalletIds.map { + mockk { + every { this@mockk.walletId } returns it + } + } + + @AfterEach + fun tearDown() { + clearMocks(singleAccountListFetcher, userWalletsStore) + } + + @Test + fun `invoke returns Right when fetch succeeds for Set`() = runTest { + // Arrange + val params = MultiAccountListFetcher.Params.Set(ids = userWalletIds) + + userWalletIds.onEach { + coEvery { + singleAccountListFetcher(params = SingleAccountListFetcher.Params(it)) + } returns Unit.right() + } + + // Act + val actual = fetcher.invoke(params) + + // Assert + assertEitherRight(actual) + + coVerify(ordering = Ordering.SEQUENCE) { + singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId1)) + singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId2)) + } + } + + @Test + fun `invoke returns Left when fetch throws exception for Set`() = runTest { + // Arrange + val params = MultiAccountListFetcher.Params.Set(ids = userWalletIds) + + val exception = Exception("Fetch failed") + coEvery { + singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId1)) + } returns exception.left() + + coEvery { + singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId2)) + } returns Unit.right() + + // Act + val actual = fetcher.invoke(params) + + // Assert + val expected = IllegalStateException( + """ + Failed to fetch accounts for wallets: + UserWalletId(011...011)=java.lang.Exception: Fetch failed + """.trimIndent(), + ) + assertEitherLeft(actual, expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId1)) + singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId2)) + } + } + + @Test + fun `invoke returns Right when fetch succeeds for All`() = runTest { + // Arrange + val params = MultiAccountListFetcher.Params.All + + every { userWalletsStore.userWalletsSync } returns listOf(userWallets.first()) + + coEvery { + singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId1)) + } returns Unit.right() + + // Act + val actual = fetcher.invoke(params) + + // Assert + assertEitherRight(actual) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWalletsSync + singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId1)) + } + } + + @Test + fun `invoke returns Left when fetch throws exception for All`() = runTest { + // Arrange + val params = MultiAccountListFetcher.Params.All + + every { userWalletsStore.userWalletsSync } returns userWallets + + val exception = Exception("Fetch failed") + coEvery { + singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId1)) + } returns exception.left() + + coEvery { + singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId2)) + } returns Unit.right() + + // Act + val actual = fetcher.invoke(params) + + // Assert + val expected = IllegalStateException( + """ + Failed to fetch accounts for wallets: + UserWalletId(011...011)=java.lang.Exception: Fetch failed + """.trimIndent(), + ) + assertEitherLeft(actual, expected) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWalletsSync + singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId1)) + singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId2)) + } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultSingleAccountListFetcherTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultSingleAccountListFetcherTest.kt new file mode 100644 index 0000000000..a7d7628c9f --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultSingleAccountListFetcherTest.kt @@ -0,0 +1,62 @@ +package com.tangem.data.account.fetcher + +import com.tangem.common.test.utils.assertEitherLeft +import com.tangem.common.test.utils.assertEitherRight +import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.domain.account.fetcher.SingleAccountListFetcher +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultSingleAccountListFetcherTest { + + private val walletAccountsFetcher: WalletAccountsFetcher = mockk(relaxUnitFun = true) + private val fetcher = DefaultSingleAccountListFetcher(walletAccountsFetcher) + + private val userWalletId = UserWalletId("011") + + @AfterEach + fun tearDown() { + clearMocks(walletAccountsFetcher) + } + + @Test + fun `invoke returns Right when fetch succeeds`() = runTest { + // Arrange + val params = SingleAccountListFetcher.Params(userWalletId = userWalletId) + + // Act + val actual = fetcher.invoke(params) + + // Assert + assertEitherRight(actual) + + coVerify(ordering = Ordering.SEQUENCE) { + walletAccountsFetcher.fetch(userWalletId) + } + } + + @Test + fun `invoke returns Left when fetch throws exception`() = runTest { + // Arrange + val params = SingleAccountListFetcher.Params(userWalletId = userWalletId) + + val exception = Exception("Fetch failed") + coEvery { walletAccountsFetcher.fetch(userWalletId) } throws exception + + // Act + val actual = fetcher.invoke(params) + + // Assert + val expected = exception + assertEitherLeft(actual, expected) + + coVerify(ordering = Ordering.SEQUENCE) { + walletAccountsFetcher.fetch(userWalletId) + } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt new file mode 100644 index 0000000000..d90aa7727c --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt @@ -0,0 +1,460 @@ +package com.tangem.data.account.fetcher + +import com.google.common.truth.Truth +import com.tangem.data.account.converter.createGetWalletAccountsResponse +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.IF_NONE_MATCH_HEADER +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultWalletAccountsFetcherTest { + + private val tangemTechApi: TangemTechApi = mockk() + + private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() + private val accountsResponseStore: AccountsResponseStore = mockk() + private val accountsResponseStoreFlow = MutableStateFlow(value = null) + + private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) + private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler = mockk(relaxUnitFun = true) + private val eTagsStore: ETagsStore = mockk(relaxUnitFun = true) + + private val fetcher: DefaultWalletAccountsFetcher = DefaultWalletAccountsFetcher( + tangemTechApi = tangemTechApi, + accountsResponseStoreFactory = accountsResponseStoreFactory, + userTokensSaver = userTokensSaver, + fetchWalletAccountsErrorHandler = fetchWalletAccountsErrorHandler, + eTagsStore = eTagsStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("011") + private val eTag = "etag" + + @BeforeAll + fun setUp() { + every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore + every { accountsResponseStore.data } returns accountsResponseStoreFlow + + coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag + } + + @AfterEach + fun tearDown() { + clearMocks( + tangemTechApi, + userTokensSaver, + fetchWalletAccountsErrorHandler, + ) + + accountsResponseStoreFlow.value = null + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Fetch { + + @Test + fun `fetch should call assignTokens when unassignedTokens are not empty`() = runTest { + // Arrange + val savedAccountsResponse = null + val unassignedToken = createToken(accountId = null) + + val accountsResponse = createGetWalletAccountsResponse( + userWalletId = userWalletId, + unassignedTokens = listOf(unassignedToken), + ) + val newETag = "newEtag" + val apiResponse = ApiResponse.Success( + data = accountsResponse, + headers = mapOf(IF_NONE_MATCH_HEADER to listOf(newETag)), + ) + + accountsResponseStoreFlow.value = savedAccountsResponse + + val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" + val updatedAccountsResponse = accountsResponse.copy( + accounts = accountsResponse.accounts.map { + it.copy(tokens = listOf(unassignedToken.copy(accountId = accountId))) + }, + unassignedTokens = emptyList(), + ) + + coEvery { + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + } returns apiResponse + + coEvery { accountsResponseStore.updateData(any()) } returns accountsResponse + + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts), + ) + } returns ApiResponse.Success(data = Unit) + + // Act + fetcher.fetch(userWalletId) + + // Assert + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.data + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = newETag) + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.updateData(any()) + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts), + ) + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.updateData(any()) + } + + coVerify(inverse = true) { + fetchWalletAccountsErrorHandler.handle( + error = any(), + userWalletId = any(), + savedAccountsResponse = any(), + pushWalletAccounts = any(), + storeWalletAccounts = any(), + ) + } + } + + @Test + fun `fetch should not call assignTokens when unassignedTokens are empty`() = runTest { + // Arrange + val savedAccountsResponse = null + val accountsResponse = createGetWalletAccountsResponse( + userWalletId = userWalletId, + unassignedTokens = emptyList(), + ) + val newETag = "newEtag" + val apiResponse = ApiResponse.Success( + data = accountsResponse, + headers = mapOf(IF_NONE_MATCH_HEADER to listOf(newETag)), + ) + + accountsResponseStoreFlow.value = savedAccountsResponse + + coEvery { + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + } returns apiResponse + + coEvery { accountsResponseStore.updateData(any()) } returns accountsResponse + + // Act + fetcher.fetch(userWalletId) + + // Assert + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.data + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = newETag) + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.updateData(any()) + } + + coVerify(inverse = true) { + fetchWalletAccountsErrorHandler.handle( + error = any(), + userWalletId = any(), + savedAccountsResponse = any(), + pushWalletAccounts = any(), + storeWalletAccounts = any(), + ) + + tangemTechApi.saveWalletAccounts(walletId = any(), eTag = any(), body = any()) + userTokensSaver.push(any(), any()) + } + } + + @Test + fun `fetch should call error handler when getWalletAccounts returns error`() = runTest { + // Arrange + val savedAccountsResponse = null + val apiError = ApiResponse.Error(ApiResponseError.NetworkException) + + accountsResponseStoreFlow.value = savedAccountsResponse + + coEvery { + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + } returns apiError as ApiResponse + + // Act + fetcher.fetch(userWalletId) + + // Assert + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.data + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + + fetchWalletAccountsErrorHandler.handle( + error = apiError.cause, + userWalletId = userWalletId, + savedAccountsResponse = null, + pushWalletAccounts = any(), + storeWalletAccounts = any(), + ) + } + + coVerify(inverse = true) { + eTagsStore.store(userWalletId = any(), key = any(), value = any()) + tangemTechApi.saveWalletAccounts(walletId = any(), eTag = any(), body = any()) + userTokensSaver.push(userWalletId = any(), response = any()) + } + } + + @Test + fun `push should throw error when saveWalletAccounts returns PRECONDITION_FAILED`() = runTest { + // Arrange + val savedAccountsResponse = null + val unassignedToken = createToken(accountId = null) + + val accountsResponse = createGetWalletAccountsResponse( + userWalletId = userWalletId, + unassignedTokens = listOf(unassignedToken), + ) + + val newETag = "newEtag" + val apiResponse = ApiResponse.Success( + data = accountsResponse, + headers = mapOf(IF_NONE_MATCH_HEADER to listOf(newETag)), + ) + + val apiError = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.PRECONDITION_FAILED, + message = null, + errorBody = null, + ) + val saveApiResponse = ApiResponse.Error(apiError) + + accountsResponseStoreFlow.value = savedAccountsResponse + + val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" + val updatedAccountsResponse = accountsResponse.copy( + accounts = accountsResponse.accounts.map { + it.copy(tokens = listOf(unassignedToken.copy(accountId = accountId))) + }, + unassignedTokens = emptyList(), + ) + + coEvery { + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + } returns apiResponse + + coEvery { accountsResponseStore.updateData(any()) } returns accountsResponse + + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts), + ) + } returns saveApiResponse as ApiResponse + + // Act + val actual = runCatching { fetcher.fetch(userWalletId) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isEqualTo(apiError) + + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.data + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = newETag) + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.updateData(any()) + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts), + ) + } + + coVerify(inverse = true) { + fetchWalletAccountsErrorHandler.handle( + error = any(), + userWalletId = any(), + savedAccountsResponse = any(), + pushWalletAccounts = any(), + storeWalletAccounts = any(), + ) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Store { + + @Test + fun `store should update data in AccountsResponseStore`() = runTest { + // Arrange + val response = createGetWalletAccountsResponse(userWalletId = userWalletId) + coEvery { accountsResponseStore.updateData(any()) } returns response + + // Act + fetcher.store(userWalletId = userWalletId, response = response) + + // Assert + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.updateData(any()) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Push { + + @Test + fun `push should call saveWalletAccounts with correct params`() = runTest { + // Arrange + val accounts = listOf( + mockk(), + ) + val response = SaveWalletAccountsResponse(accounts) + + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = response, + ) + } returns ApiResponse.Success(data = Unit) + + // Act + fetcher.push(userWalletId, response) + + // Assert + coVerify { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = response, + ) + } + } + + @Test + fun `push should throw error when saveWalletAccounts returns PRECONDITION_FAILED`() = runTest { + // Arrange + val accounts = listOf( + mockk(), + ) + val response = SaveWalletAccountsResponse(accounts) + val apiError = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.PRECONDITION_FAILED, + message = null, + errorBody = null, + ) + val saveApiResponse = ApiResponse.Error(apiError) + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = response, + ) + } returns saveApiResponse as ApiResponse + + // Act + val actual = runCatching { fetcher.push(userWalletId, response) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isEqualTo(apiError) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class PushAndStore { + + @Test + fun `pushAndStore should call push and store with correct params`() = runTest { + // Arrange + listOf( + mockk(), + ) + val response = createGetWalletAccountsResponse(userWalletId) + + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(accounts = response.accounts), + ) + } returns ApiResponse.Success(data = Unit) + + coEvery { accountsResponseStore.updateData(any()) } returns mockk() + + // Act + fetcher.pushAndStore(userWalletId, response) + + // Assert + coVerifyOrder { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(accounts = response.accounts), + ) + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.updateData(any()) + } + } + } + + private fun createToken( + networkId: String = "ethereum", + derivationPath: String = "m/44'/60'/0'/0/0", + list: List = emptyList(), + name: String = "Ethereum", + symbol: String = "ETH", + decimals: Int = 18, + contractAddress: String? = null, + id: String? = null, + accountId: String? = null, + ) = UserTokensResponse.Token( + id = id, + accountId = accountId, + networkId = networkId, + derivationPath = derivationPath, + name = name, + symbol = symbol, + decimals = decimals, + contractAddress = contractAddress, + addresses = list, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt new file mode 100644 index 0000000000..532b33975b --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt @@ -0,0 +1,239 @@ +package com.tangem.data.account.fetcher + +import com.tangem.data.account.converter.CryptoPortfolioConverter +import com.tangem.data.account.utils.toUserTokensResponse +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.local.token.UserTokensResponseStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FetchWalletAccountsErrorHandlerTest { + + private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) + private val userWalletsStore: UserWalletsStore = mockk() + private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true) + private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory = mockk() + private val cryptoPortfolioConverter = mockk() + private val userTokensResponseFactory: UserTokensResponseFactory = mockk() + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() + + private val handler = FetchWalletAccountsErrorHandler( + userTokensSaver = userTokensSaver, + userWalletsStore = userWalletsStore, + userTokensResponseStore = userTokensResponseStore, + cryptoPortfolioCF = cryptoPortfolioCF, + userTokensResponseFactory = userTokensResponseFactory, + cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + ) + + private val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + @BeforeEach + fun setupEach() { + clearMocks( + userTokensSaver, + userWalletsStore, + userTokensResponseStore, + cryptoPortfolioCF, + cryptoPortfolioConverter, + cardCryptoCurrencyFactory, + ) + } + + @Test + fun `does not update accounts when response is up to date`() = runTest { + // Arrange + val error = ApiResponseError.HttpException( + code = Code.NOT_MODIFIED, + message = "Not Modified", + errorBody = null, + ) + + val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk() + val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk() + + // Act + handler.handle( + error = error, + userWalletId = userWalletId, + savedAccountsResponse = null, + pushWalletAccounts = pushWalletAccounts, + storeWalletAccounts = storeWalletAccounts, + ) + + // Assert + coVerify(inverse = true) { + userWalletsStore.getSyncStrict(key = any()) + userTokensResponseStore.getSyncOrNull(userWalletId = any()) + userTokensResponseFactory.createUserTokensResponse(any(), any(), any()) + cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any()) + cryptoPortfolioCF.create(any()) + cryptoPortfolioConverter.convertListBack(any()) + pushWalletAccounts(any(), any()) + userTokensSaver.push(userWalletId = any(), response = any()) + storeWalletAccounts(any(), any()) + } + } + + @Test + fun `pushes and stores accounts when NOT_FOUND error occurs`() = runTest { + // Arrange + val error = ApiResponseError.HttpException( + code = Code.NOT_FOUND, + message = "Not Found", + errorBody = null, + ) + + val accountDTO = WalletAccountDTO( + id = "nibh", + name = "Michael Dotson", + derivationIndex = 7135, + icon = "consectetuer", + iconColor = "ferri", + tokens = listOf(), + totalTokens = 7738, + totalNetworks = 3348, + ) + + val savedAccountsResponse = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 1, + ), + accounts = listOf(accountDTO), + unassignedTokens = emptyList(), + ) + + val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk(relaxed = true) + val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true) + + // Act + handler.handle( + error = error, + userWalletId = userWalletId, + savedAccountsResponse = savedAccountsResponse, + pushWalletAccounts = pushWalletAccounts, + storeWalletAccounts = storeWalletAccounts, + ) + + // Assert + coVerify { + pushWalletAccounts(userWalletId, listOf(accountDTO)) + userTokensSaver.push(userWalletId, response = savedAccountsResponse.toUserTokensResponse()) + storeWalletAccounts(userWalletId, savedAccountsResponse) + } + + coVerify(inverse = true) { + userWalletsStore.getSyncStrict(key = any()) + userTokensResponseStore.getSyncOrNull(userWalletId = any()) + userTokensResponseFactory.createUserTokensResponse(any(), any(), any()) + cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any()) + cryptoPortfolioCF.create(any()) + cryptoPortfolioConverter.convertListBack(any()) + } + } + + @Test + fun `uses default accounts when savedAccountsResponse is null`() = runTest { + // Arrange + val error = ApiResponseError.TimeoutException + + val accounts = AccountList.empty(userWallet).accounts + .filterIsInstance() + + val accountDTO = WalletAccountDTO( + id = "nibh", + name = "Michael Dotson", + derivationIndex = 7135, + icon = "consectetuer", + iconColor = "ferri", + tokens = listOf(), + totalTokens = 7738, + totalNetworks = 3348, + ) + + val savedAccountsResponse = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 1, + ), + accounts = listOf(accountDTO), + unassignedTokens = emptyList(), + ) + + val userTokensResponse = savedAccountsResponse.toUserTokensResponse() + + every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet + every { cryptoPortfolioCF.create(userWallet) } returns cryptoPortfolioConverter + every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountDTO) + coEvery { userTokensResponseStore.getSyncOrNull(userWalletId) } returns null + every { + userTokensResponseFactory.createUserTokensResponse( + currencies = emptyList(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } returns userTokensResponse + every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList() + + val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk(relaxed = true) + val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true) + + // Act + handler.handle( + error = error, + userWalletId = userWalletId, + savedAccountsResponse = null, + pushWalletAccounts = pushWalletAccounts, + storeWalletAccounts = storeWalletAccounts, + ) + + // Assert + coVerify { + userWalletsStore.getSyncStrict(userWalletId) + cryptoPortfolioCF.create(userWallet) + cryptoPortfolioConverter.convertListBack(accounts) + userTokensResponseStore.getSyncOrNull(userWalletId) + userTokensResponseFactory.createUserTokensResponse( + currencies = emptyList(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) + storeWalletAccounts(userWalletId, any()) + } + + coVerify(inverse = true) { + pushWalletAccounts(any(), any()) + userTokensSaver.push(userWalletId = any(), response = any()) + } + } + + private companion object { + + val userWalletId = UserWalletId("011") + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt new file mode 100644 index 0000000000..3ca26b0198 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt @@ -0,0 +1,222 @@ +package com.tangem.data.account.producer + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultMultiAccountListProducerTest { + + private val userWalletsStore: UserWalletsStore = mockk() + private val walletAccountListFlowFactory: WalletAccountListFlowFactory = mockk() + + private val producer = DefaultMultiAccountListProducer( + params = Unit, + userWalletsStore = userWalletsStore, + walletAccountListFlowFactory = walletAccountListFlowFactory, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("011") + private val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + @AfterEach + fun tearDownEach() { + clearMocks(userWalletsStore, walletAccountListFlowFactory) + } + + @Test + fun produce() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = listOf(accountList) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow will updated if factoryFlow is updated`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE) + val factoryFlow = MutableStateFlow(null) + + every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + + // Act (first emission) + factoryFlow.value = accountList + val firstEmission = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(firstEmission).containsExactly(listOf(accountList)) + + // Act (second emission) + factoryFlow.value = updatedAccountList + val secondEmission = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(secondEmission).containsExactly(listOf(updatedAccountList)) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is filtered the same response`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + val factoryFlow = MutableStateFlow(null) + + every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + + // Act (first emission) + factoryFlow.value = accountList + val firstEmission = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(firstEmission).containsExactly(listOf(accountList)) + + // Act (second emission) - the same status + factoryFlow.value = accountList + val secondEmission = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(secondEmission).containsExactly(listOf(accountList)) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow returns empty list if factory throws exception`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val exception = RuntimeException("Converter error") + every { walletAccountListFlowFactory.create(userWallet) } throws exception + + // Act + val actual = producer.produceWithFallback().let(::getEmittedValues) + + // Assert + val expected = emptyList() + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is empty if userWalletsFlow returns empty flow`() = runTest { + // Arrange + val userWalletsFlow = emptyFlow>() + every { userWalletsStore.userWallets } returns userWalletsFlow + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(exactly = 1) { userWalletsStore.userWallets } + coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) } + } + + @Test + fun `flow is empty if factory returns empty flow`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + every { walletAccountListFlowFactory.create(userWallet) } returns emptyFlow() + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is empty if one of factoryFlow is empty`() = runTest { + // Arrange + val userWalletId2 = UserWalletId("012") + val userWallet2 = mockk { + every { this@mockk.walletId } returns userWalletId2 + } + + val userWalletsFlow = MutableStateFlow(listOf(userWallet, userWallet2)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) + every { walletAccountListFlowFactory.create(userWallet2) } returns emptyFlow() + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWallet2) + } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt new file mode 100644 index 0000000000..846e46bfea --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt @@ -0,0 +1,198 @@ +package com.tangem.data.account.producer + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultSingleAccountListProducerTest { + + private val userWalletsStore: UserWalletsStore = mockk() + private val walletAccountListFlowFactory: WalletAccountListFlowFactory = mockk() + + private val userWalletId = UserWalletId("011") + private val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + private val producer = DefaultSingleAccountListProducer( + params = SingleAccountListProducer.Params(userWalletId = userWalletId), + userWalletsStore = userWalletsStore, + walletAccountListFlowFactory = walletAccountListFlowFactory, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @AfterEach + fun tearDownEach() { + clearMocks(userWalletsStore, walletAccountListFlowFactory) + } + + @Test + fun produce() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = accountList + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow will updated if factoryFlow is updated`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE) + val factoryFlow = MutableStateFlow(null) + + every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + + // Act (first emission) + factoryFlow.value = accountList + val firstEmission = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(firstEmission).containsExactly(accountList) + + // Act (second emission) + factoryFlow.value = updatedAccountList + val secondEmission = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(secondEmission).containsExactly(updatedAccountList) + + coVerifyOrder { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is filtered the same response`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + val factoryFlow = MutableStateFlow(null) + + every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + + // Act (first emission) + factoryFlow.value = accountList + val firstEmission = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(firstEmission).containsExactly(accountList) + + // Act (second emission) - the same status + factoryFlow.value = accountList + val secondEmission = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(secondEmission).containsExactly(accountList) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is empty if factory throws exception`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val exception = RuntimeException("Converter error") + every { walletAccountListFlowFactory.create(userWallet) } throws exception + + // Act + val actual = producer.produceWithFallback().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is empty if userWalletsFlow returns empty flow`() = runTest { + // Arrange + val userWalletsFlow = emptyFlow>() + every { userWalletsStore.userWallets } returns userWalletsFlow + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(exactly = 1) { userWalletsStore.userWallets } + coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) } + } + + @Test + fun `flow is empty if userWalletsFlow doesn't contains userWalletId from params`() = runTest { + // Arrange + val unknownId = UserWalletId("012") + val unknownWallet = mockk { + every { this@mockk.walletId } returns unknownId + } + + val userWalletsFlow = MutableStateFlow(listOf(unknownWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + } + + coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt new file mode 100644 index 0000000000..c94ef86564 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt @@ -0,0 +1,148 @@ +package com.tangem.data.account.producer + +import com.google.common.truth.Truth +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.account.converter.AccountListConverter +import com.tangem.data.account.converter.createGetWalletAccountsResponse +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class WalletAccountListFlowFactoryTest { + + private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() + private val accountsResponseStore: AccountsResponseStore = mockk() + private val accountsResponseStoreFlow = MutableStateFlow(value = null) + + private val accountListConverterFactory: AccountListConverter.Factory = mockk() + private val accountListConverter: AccountListConverter = mockk() + + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() + + private val factory = WalletAccountListFlowFactory( + accountsResponseStoreFactory = accountsResponseStoreFactory, + accountListConverterFactory = accountListConverterFactory, + cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + ) + + private val userWalletId = UserWalletId("011") + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + + @AfterEach + fun tearDownEach() { + clearMocks(accountListConverter) + + accountsResponseStoreFlow.value = null + } + + @Test + fun `create for multi wallet`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns true + } + + val accountsResponse = createGetWalletAccountsResponse(userWalletId) + every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore + every { accountsResponseStore.data } returns accountsResponseStoreFlow + accountsResponseStoreFlow.value = accountsResponse + + val accountList = AccountList.empty(userWallet) + every { accountListConverterFactory.create(userWallet) } returns accountListConverter + every { accountListConverter.convert(accountsResponse) } returns accountList + + // Act + val actual = factory.create(userWallet).let(::getEmittedValues) + + // Assert + val expected = accountList + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + accountListConverterFactory.create(userWallet) + accountListConverter.convert(accountsResponse) + } + + coVerify(inverse = true) { + cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(any()) + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(any()) + } + } + + @Test + fun `create for single wallet`() = runTest { + val userWallet = MockUserWalletFactory.create().copy(isMultiCurrency = false) + + val currency = cryptoCurrencyFactory.ethereum + every { cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet) } returns currency + + // Act + val actual = factory.create(userWallet).let(::getEmittedValues) + + // Assert + val expected = AccountList.empty(userWallet = userWallet, cryptoCurrencies = setOf(currency)) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet) + } + + coVerify(inverse = true) { + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = any()) + accountsResponseStoreFactory.create(any()) + accountsResponseStore.data + accountListConverterFactory.create(any()) + accountListConverter.convert(any()) + } + } + + @Test + fun `flow is created for single wallet with token`() = runTest { + val nodl = MockUserWalletFactory.createSingleWalletWithToken() + + val currencies = cryptoCurrencyFactory.ethereumAndStellar.toSet() + every { + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = nodl) + } returns currencies.toList() + + // Act + val actual = factory.create(nodl).let(::getEmittedValues) + + // Assert + val expected = AccountList.empty(userWallet = nodl, cryptoCurrencies = currencies) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = nodl) + } + + coVerify(inverse = true) { + cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(any()) + accountsResponseStoreFactory.create(any()) + accountsResponseStore.data + accountListConverterFactory.create(any()) + accountListConverter.convert(any()) + } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt new file mode 100644 index 0000000000..1e7c97fc64 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt @@ -0,0 +1,741 @@ +package com.tangem.data.account.repository + +import arrow.core.None +import arrow.core.toOption +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.account.converter.* +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.store.ArchivedAccountsStore +import com.tangem.data.account.store.ArchivedAccountsStoreFactory +import com.tangem.data.common.account.WalletAccountsSaver +import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletArchivedAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.models.account.Account.CryptoPortfolio +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* +import kotlin.time.Duration.Companion.minutes + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultAccountsCRUDRepositoryTest { + + private val tangemTechApi: TangemTechApi = mockk() + private val walletAccountsSaver: WalletAccountsSaver = mockk(relaxUnitFun = true) + + private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() + private val accountsResponseStore: AccountsResponseStore = mockk() + private val accountsResponseStoreFlow = MutableStateFlow(value = null) + + private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory = mockk() + private val archivedAccountsInnerStore = RuntimeStateStore?>(defaultValue = null) + private val archivedAccountsStore = ArchivedAccountsStore(runtimeStore = archivedAccountsInnerStore) + + private val userWalletsStore: UserWalletsStore = mockk() + private val eTagsStore: ETagsStore = mockk() + + private val convertersContainer: AccountConverterFactoryContainer = mockk() + private val accountListConverter: AccountListConverter = mockk() + private val cryptoPortfolioConverter: CryptoPortfolioConverter = mockk() + + private val repository = DefaultAccountsCRUDRepository( + tangemTechApi = tangemTechApi, + walletAccountsSaver = walletAccountsSaver, + accountsResponseStoreFactory = accountsResponseStoreFactory, + archivedAccountsStoreFactory = archivedAccountsStoreFactory, + userWalletsStore = userWalletsStore, + eTagsStore = eTagsStore, + convertersContainer = convertersContainer, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("011") + + @BeforeAll + fun setup() { + every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore + every { accountsResponseStore.data } returns accountsResponseStoreFlow + + every { convertersContainer.createAccountListConverter(userWalletId) } returns accountListConverter + every { convertersContainer.createCryptoPortfolioConverter(userWalletId) } returns cryptoPortfolioConverter + } + + @BeforeEach + fun setupEach() { + every { archivedAccountsStoreFactory.create(userWalletId) } returns archivedAccountsStore + } + + @AfterEach + fun tearDownEach() { + accountsResponseStoreFlow.value = null + archivedAccountsInnerStore.clear() + + clearMocks( + tangemTechApi, + archivedAccountsStoreFactory, + accountListConverter, + cryptoPortfolioConverter, + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetAccountListSync { + + @Test + fun `getAccounts should return None when account list response is null`() = runTest { + // Arrange + accountsResponseStoreFlow.value = null + + // Act + val actual = repository.getAccountListSync(userWalletId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + verify(inverse = true) { accountListConverter.convert(value = any()) } + } + + @Test + fun `getAccounts should return AccountList when account list response is not null`() = runTest { + // Arrange + val response = mockk() + val accountList = mockk() + + accountsResponseStoreFlow.value = response + + every { accountListConverter.convert(response) } returns accountList + + // Act + val actual = repository.getAccountListSync(userWalletId) + + // Assert + val expected = accountList.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + convertersContainer.createAccountListConverter(userWalletId = userWalletId) + accountListConverter.convert(response) + } + } + + @Test + fun `getAccounts should throw exception if converter throws exception`() = runTest { + // Arrange + val response = mockk() + mockk() + + accountsResponseStoreFlow.value = response + + val exception = Exception("Test error") + + every { accountListConverter.convert(response) } throws exception + + // Act + val actual = runCatching { repository.getAccountListSync(userWalletId) }.exceptionOrNull()!! + + // Assert + val expected = exception + Truth.assertThat(actual).isSameInstanceAs(expected) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + convertersContainer.createAccountListConverter(userWalletId = userWalletId) + accountListConverter.convert(response) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetAccountSync { + + private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main) + + @Test + fun `getAccount should return None when account response is null`() = runTest { + // Arrange + val response = null + + accountsResponseStoreFlow.value = response + + // Act + val actual = repository.getAccountSync(accountId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + verify(inverse = true) { convertersContainer.createCryptoPortfolioConverter(userWalletId = any()) } + } + + @Test + fun `getAccount should return None when accountDto is not found`() = runTest { + // Arrange + val response = mockk { + every { this@mockk.accounts } returns emptyList() + } + + accountsResponseStoreFlow.value = response + + // Act + val actual = repository.getAccountSync(accountId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + verify(inverse = true) { convertersContainer.createCryptoPortfolioConverter(userWalletId = any()) } + } + + @Test + fun `getAccount should return Account_CryptoPortfolio when account response is not null`() = runTest { + // Arrange + val accountDTO = mockk { + every { this@mockk.id } returns accountId.value + } + + val response = mockk { + every { this@mockk.accounts } returns listOf(accountDTO) + } + + accountsResponseStoreFlow.value = response + + val cryptoPortfolio = mockk() + + every { cryptoPortfolioConverter.convert(accountDTO) } returns cryptoPortfolio + + // Act + val actual = repository.getAccountSync(accountId) + + // Assert + val expected = cryptoPortfolio.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + convertersContainer.createCryptoPortfolioConverter(userWalletId) + cryptoPortfolioConverter.convert(accountDTO) + } + } + + @Test + fun `getAccount should throw exception if converter throws exception`() = runTest { + // Arrange + val accountDTO = mockk { + every { this@mockk.id } returns accountId.value + } + + val response = mockk { + every { this@mockk.accounts } returns listOf(accountDTO) + } + + accountsResponseStoreFlow.value = response + + val exception = Exception("Test error") + + every { cryptoPortfolioConverter.convert(accountDTO) } throws exception + + // Act + val actual = runCatching { repository.getAccountSync(accountId) }.exceptionOrNull()!! + + // Assert + val expected = exception + Truth.assertThat(actual).isSameInstanceAs(expected) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + convertersContainer.createCryptoPortfolioConverter(userWalletId) + cryptoPortfolioConverter.convert(accountDTO) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetArchivedAccountSync { + + private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main) + + @Test + fun `getArchivedAccount should return None when archived accounts are null`() = runTest { + // Arrange + archivedAccountsInnerStore.store(value = null) + + // Act + val actual = repository.getArchivedAccountSync(accountId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccount should return None when archived account not found`() = runTest { + // Arrange + archivedAccountsInnerStore.store(value = listOf()) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds) + + // Act + val actual = repository.getArchivedAccountSync(accountId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccount should return ArchivedAccount when found`() = runTest { + // Arrange + val archivedAccount = ArchivedAccount( + accountId = accountId, + name = AccountName("Archived Account").getOrNull()!!, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), + derivationIndex = DerivationIndex.Main, + tokensCount = 0, + networksCount = 0, + ) + + archivedAccountsInnerStore.store(value = listOf(archivedAccount)) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds) + + // Act + val actual = repository.getArchivedAccountSync(accountId) + + // Assert + val expected = archivedAccount.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccount should throws exception when store throws exception`() = runTest { + // Arrange + val exception = Exception("Test error") + + coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception + + // Act + val actual = runCatching { repository.getArchivedAccountSync(accountId) }.exceptionOrNull()!! + + // Assert + val expected = exception + Truth.assertThat(actual).isSameInstanceAs(expected) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + + coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetArchivedAccountListSync { + + @Test + fun `getArchivedAccountListSync should return None when archived accounts are null`() = runTest { + // Arrange + archivedAccountsInnerStore.store(value = null) + + // Act + val actual = repository.getArchivedAccountListSync(userWalletId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccountListSync should return Option with list when archived accounts exist`() = runTest { + // Arrange + val archivedAccount1 = mockk() + val archivedAccount2 = mockk() + val archivedAccounts = listOf(archivedAccount1, archivedAccount2) + archivedAccountsInnerStore.store(value = archivedAccounts) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds) + + // Act + val actual = repository.getArchivedAccountListSync(userWalletId) + + // Assert + val expected = archivedAccounts.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccountListSync should throw exception when store throws exception`() = runTest { + // Arrange + val exception = Exception("Test error") + coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception + + // Act + val actual = runCatching { repository.getArchivedAccountListSync(userWalletId) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isSameInstanceAs(exception) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetArchivedAccounts { + + @Test + fun `getArchivedAccounts should emit empty list when no archived accounts`() = runTest { + // Arrange + archivedAccountsInnerStore.store(value = null) + + val archivedAccountsFlow = repository.getArchivedAccounts(userWalletId) + + // Act + val actual = getEmittedValues(archivedAccountsFlow) + + // Assert + Truth.assertThat(actual).isEmpty() + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.get() + } + } + + @Test + fun `getArchivedAccounts should emit list of archived accounts when present`() = runTest { + // Arrange + val archivedAccount1 = mockk() + val archivedAccount2 = mockk() + val archivedAccounts = listOf(archivedAccount1, archivedAccount2) + + archivedAccountsInnerStore.store(value = archivedAccounts) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds) + + val archivedAccountsFlow = repository.getArchivedAccounts(userWalletId) + + // Act + val actual = getEmittedValues(archivedAccountsFlow) + + // Assert + val expected = listOf(archivedAccounts) + Truth.assertThat(actual).containsExactlyElementsIn(expected) + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.get() + } + } + + @Test + fun `getArchivedAccounts should throw exception when store throws exception`() = runTest { + // Arrange + val exception = Exception("Test error") + coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception + + // Act + val actual = runCatching { repository.getArchivedAccounts(userWalletId) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isSameInstanceAs(exception) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class FetchArchivedAccounts { + + private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main) + + @Test + fun `fetchArchivedAccounts should store archived accounts in store`() = runTest { + // Arrange + val accountDTO = WalletAccountDTO( + id = accountId.value, + name = "Archived Account", + derivationIndex = 0, + icon = CryptoPortfolioIcon.Icon.Wallet.name, + iconColor = CryptoPortfolioIcon.Color.DullLavender.name, + totalNetworks = 0, + totalTokens = 0, + ) + + val eTag = "etag123" + val apiResponse = mockk { + every { this@mockk.accounts } returns listOf(accountDTO) + } + + val archivedAccount = ArchivedAccountConverter(userWalletId).convert(accountDTO) + + coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag + + coEvery { + tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) + } returns ApiResponse.Success(apiResponse) + + // Act + repository.fetchArchivedAccounts(userWalletId) + val actual = archivedAccountsStore.getSyncOrNull() + + // Assert + Truth.assertThat(actual).containsExactly(archivedAccount) + + coVerifyOrder { + eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) + tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) + archivedAccountsStoreFactory.create(userWalletId) + } + } + + @Test + fun `fetchArchivedAccounts should throw exception if API returns error`() = runTest { + // Arrange + val eTag = "etag123" + val exception = Exception("API error") + + coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag + coEvery { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) } throws exception + + // Act + val actual = runCatching { repository.fetchArchivedAccounts(userWalletId) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isSameInstanceAs(exception) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + Truth.assertThat(archivedAccountsStore.getSyncOrNull()).isNull() + + coVerifyOrder { + eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) + tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class SaveAccounts { + + @Test + fun `saveAccounts should call API and update store`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + val accountList = AccountList.empty(userWallet = userWallet) + + val accountsResponse = mockk() + accountsResponseStoreFlow.value = accountsResponse + + val converter = mockk { + every { this@mockk.convert(accountList) } returns accountsResponse + } + + every { + convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet) + } returns converter + + // Act + repository.saveAccounts(accountList) + + // Assert + Truth.assertThat(accountsResponseStoreFlow.value).isEqualTo(accountsResponse) + + coVerifyOrder { + convertersContainer.getWalletAccountsResponseCF.create(userWallet) + converter.convert(accountList) + walletAccountsSaver.pushAndStore(userWalletId, accountsResponse) + } + } + + @Test + fun `saveAccounts if API request is failed`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + val accountList = AccountList.empty(userWallet = userWallet) + + val accountsResponse = mockk() + accountsResponseStoreFlow.value = accountsResponse + + val converter = mockk { + every { this@mockk.convert(accountList) } returns accountsResponse + } + + every { + convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet) + } returns converter + + val exception = Exception("Test error") + + coEvery { walletAccountsSaver.pushAndStore(userWalletId, accountsResponse) } throws exception + + // Act + val actual = runCatching { repository.saveAccounts(accountList) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { + convertersContainer.getWalletAccountsResponseCF.create(userWallet) + converter.convert(accountList) + walletAccountsSaver.pushAndStore(userWalletId, accountsResponse) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetTotalAccountsCountSync { + + @Test + fun `getTotalAccountsCountSync returns None if account list response is null`() = runTest { + // Arrange + accountsResponseStoreFlow.value = null + + // Act + val actual = repository.getTotalAccountsCountSync(userWalletId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + } + + @Test + fun `getTotalAccountsCountSync returns Some with totalAccounts when response is valid`() = runTest { + // Arrange + val totalAccounts = 5 + val response = mockk { + every { this@mockk.wallet.totalAccounts } returns totalAccounts + } + + accountsResponseStoreFlow.value = response + + // Act + val actual = repository.getTotalAccountsCountSync(userWalletId) + + // Assert + val expected = totalAccounts.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetTotalAccountsCount { + + @Test + fun `getTotalAccountsCount emits 0 when account list response is null`() = runTest { + // Arrange + accountsResponseStoreFlow.value = null + + // Act + val flow = repository.getTotalAccountsCount(userWalletId) + val actual = getEmittedValues(flow) + + // Assert + Truth.assertThat(actual).containsExactly(None) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + } + + @Test + fun `getTotalAccountsCount emits correct value when response is valid`() = runTest { + // Arrange + val totalAccounts = 7 + val response = mockk { + every { this@mockk.wallet.totalAccounts } returns totalAccounts + } + + accountsResponseStoreFlow.value = response + + // Act + val flow = repository.getTotalAccountsCount(userWalletId) + val actual = getEmittedValues(flow) + + // Assert + Truth.assertThat(actual).containsExactly(totalAccounts.toOption()) + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/store/AccountsResponseStoreFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/store/AccountsResponseStoreFactoryTest.kt new file mode 100644 index 0000000000..6c39f7181c --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/store/AccountsResponseStoreFactoryTest.kt @@ -0,0 +1,91 @@ +package com.tangem.data.account.store + +import android.content.Context +import com.google.common.truth.Truth +import com.squareup.moshi.Moshi +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.mockk +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AccountsResponseStoreFactoryTest { + + private val context: Context = mockk() + private val moshi: Moshi = Moshi.Builder().build() + private val factory: AccountsResponseStoreFactory = AccountsResponseStoreFactory( + context = context, + moshi = moshi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @AfterEach + fun setup() { + clearMocks(context) + factory.clearStores() + } + + @Test + fun `creates new data store for unique userWalletId`() { + // Arrange + val userWalletId = UserWalletId("011") + val createdStore = factory.create(userWalletId = userWalletId) + + // Actual + val actual = factory.getAllStores() + + // Assert + Truth.assertThat(actual).containsExactly(userWalletId, createdStore) + } + + @Test + fun `reuses existing data store for same userWalletId`() { + val userWalletId = UserWalletId("011") + + // Arrange (first creation) + val firstStore = factory.create(userWalletId = userWalletId) + + // Act (first creation) + val actual1 = factory.getAllStores() + + // Assert (first creation) + Truth.assertThat(actual1).containsExactly(userWalletId, firstStore) + + // Arrange (second creation) + val secondStore = factory.create(userWalletId = userWalletId) + + // Act (second creation) + val actual2 = factory.getAllStores() + + // Assert (second creation) + Truth.assertThat(actual2).containsExactly(userWalletId, secondStore) + Truth.assertThat(firstStore).isSameInstanceAs(secondStore) + } + + @Test + fun `creates separate data stores for different userWalletIds`() { + // Arrange (first creation) + val firstWalletId = UserWalletId("011") + val firstStore = factory.create(userWalletId = firstWalletId) + + // Act (first creation) + val actual1 = factory.getAllStores() + + // Assert (first creation) + Truth.assertThat(actual1).containsExactly(firstWalletId, firstStore) + + // Arrange (second creation) + val secondWalletId = UserWalletId("011") + val secondStore = factory.create(userWalletId = secondWalletId) + + // Act (second creation) + val actual2 = factory.getAllStores() + + // Assert (second creation) + val expected = mapOf(firstWalletId to firstStore, secondWalletId to secondStore) + Truth.assertThat(actual2).containsExactlyEntriesIn(expected) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt new file mode 100644 index 0000000000..a7358ba43d --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt @@ -0,0 +1,79 @@ +package com.tangem.data.account.store + +import com.google.common.truth.Truth +import com.tangem.domain.models.wallet.UserWalletId +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ArchivedAccountsStoreFactoryTest { + + private val factory = ArchivedAccountsStoreFactory + + @AfterEach + fun tearDownEach() { + factory.clearStores() + } + + @Test + fun `creates new store for unique userWalletId`() { + // Arrange + val userWalletId = UserWalletId("001") + val createdStore = factory.create(userWalletId) + + // Act + val actual = factory.getAllStores() + + // Assert + Truth.assertThat(actual).containsExactly(userWalletId, createdStore) + } + + @Test + fun `reuses existing data store for same userWalletId`() { + val userWalletId = UserWalletId("011") + + // Arrange (first creation) + val firstStore = factory.create(userWalletId = userWalletId) + + // Act (first creation) + val actual1 = factory.getAllStores() + + // Assert (first creation) + Truth.assertThat(actual1).containsExactly(userWalletId, firstStore) + + // Arrange (second creation) + val secondStore = factory.create(userWalletId = userWalletId) + + // Act (second creation) + val actual2 = factory.getAllStores() + + // Assert (second creation) + Truth.assertThat(actual2).containsExactly(userWalletId, secondStore) + Truth.assertThat(firstStore).isSameInstanceAs(secondStore) + } + + @Test + fun `creates separate data stores for different userWalletIds`() { + // Arrange (first creation) + val firstWalletId = UserWalletId("011") + val firstStore = factory.create(userWalletId = firstWalletId) + + // Act (first creation) + val actual1 = factory.getAllStores() + + // Assert (first creation) + Truth.assertThat(actual1).containsExactly(firstWalletId, firstStore) + + // Arrange (second creation) + val secondWalletId = UserWalletId("011") + val secondStore = factory.create(userWalletId = secondWalletId) + + // Act (second creation) + val actual2 = factory.getAllStores() + + // Assert (second creation) + val expected = mapOf(firstWalletId to firstStore, secondWalletId to secondStore) + Truth.assertThat(actual2).containsExactlyEntriesIn(expected) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt new file mode 100644 index 0000000000..70e6ddb2db --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt @@ -0,0 +1,139 @@ +package com.tangem.data.account.store + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import kotlin.time.Duration.Companion.seconds + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ArchivedAccountsStoreTest { + + private val runtimeStore: RuntimeStateStore?> = RuntimeStateStore(defaultValue = null) + private val archivedAccountsStore: ArchivedAccountsStore = ArchivedAccountsStore(runtimeStore = runtimeStore) + + @AfterEach + fun tearDown() { + archivedAccountsStore.clear() + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Get { + + @Test + fun `get returns empty flow`() = runTest { + // Act + val actual = getEmittedValues(archivedAccountsStore.get()) + + // Assert + Truth.assertThat(actual).isEmpty() // nothing emmited + } + + @Test + fun `get returns flow with not expired data`() = runTest { + // Arrange + val archivedAccount = createArchivedAccount() + archivedAccountsStore.store(value = listOf(archivedAccount)) + + // Act + val actual = getEmittedValues(archivedAccountsStore.get()) + + // Assert + val expected = listOf(archivedAccount) + Truth.assertThat(actual).containsExactly(expected) + } + + @Test + fun `get returns flow with expired data`() = runTest { + // Arrange + archivedAccountsStore.store(value = listOf(createArchivedAccount())) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds) + + // Act + val actual = getEmittedValues(archivedAccountsStore.get()) + + // Assert + Truth.assertThat(actual).isEmpty() // nothing emmited + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetSyncOrnNull { + + @Test + fun `getSyncOrNull returns null`() = runTest { + // Act + val actual = archivedAccountsStore.getSyncOrNull() + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun `get returns flow with not expired data`() = runTest { + // Arrange + val archivedAccount = createArchivedAccount() + archivedAccountsStore.store(value = listOf(archivedAccount)) + + // Act + val actual = archivedAccountsStore.getSyncOrNull() + + // Assert + val expected = archivedAccount + Truth.assertThat(actual).containsExactly(expected) + } + + @Test + fun `get returns flow with expired data`() = runTest { + // Arrange + archivedAccountsStore.store(value = listOf(createArchivedAccount())) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds) + + // Act + val actual = archivedAccountsStore.getSyncOrNull() + + // Assert + Truth.assertThat(actual).isNull() + } + } + + @Test + fun store() = runTest { + // Arrange + val archivedAccount = createArchivedAccount() + + // Act + archivedAccountsStore.store(value = listOf(archivedAccount)) + val actual = runtimeStore.getSyncOrNull() + + // Assert + val expected = archivedAccount + Truth.assertThat(actual).containsExactly(expected) + } + + private fun createArchivedAccount(): ArchivedAccount { + return ArchivedAccount( + accountId = AccountId.forCryptoPortfolio( + userWalletId = UserWalletId("011"), + derivationIndex = DerivationIndex.Main, + ), + name = AccountName("Archived Account").getOrNull()!!, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), + derivationIndex = DerivationIndex.Main, + tokensCount = 2, + networksCount = 1, + ) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt b/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt new file mode 100644 index 0000000000..8f9089305d --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt @@ -0,0 +1,324 @@ +package com.tangem.data.account.utils + +import com.google.common.truth.Truth +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetWalletAccountsResponseExtTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class FlattenTokens { + + @Test + fun `flattenTokens returns empty list when accounts are empty`() { + // Arrange + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 0, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.flattenTokens() + + // Assert + Truth.assertThat(actual).isEmpty() + } + + @Test + fun `flattenTokens returns empty list when single account has empty tokens`() { + // Arrange + val account = createWalletAccountDTO(derivationIndex = 0) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 1, + ), + accounts = listOf(account), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.flattenTokens() + + // Assert + Truth.assertThat(actual).isEmpty() + } + + @Test + fun `flattenTokens returns all tokens from multiple accounts`() { + // Arrange + val token1 = createUserToken(id = "0") + val token2 = createUserToken(id = "1") + val token3 = createUserToken(id = "2") + + val account1 = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1, token2)) + val account2 = createWalletAccountDTO(derivationIndex = 1, tokens = listOf(token3)) + val account3 = createWalletAccountDTO(derivationIndex = 2) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 2, + ), + accounts = listOf(account1, account2, account3), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.flattenTokens() + + // Assert + Truth.assertThat(actual).containsExactly(token1, token2, token3) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ToUserTokensResponse { + + @Test + fun `toUserTokensResponse returns correct UserTokensResponse for empty accounts and unassignedTokens`() { + // Arrange + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 0, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.toUserTokensResponse() + + // Assert + val expected = UserTokensResponse( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + tokens = emptyList(), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `toUserTokensResponse includes tokens from accounts and unassignedTokens`() { + // Arrange + val token1 = createUserToken(id = "0") + val token2 = createUserToken(id = "1") + val account = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1)) + val unassignedToken = token2 + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + totalAccounts = 1, + ), + accounts = listOf(account), + unassignedTokens = listOf(unassignedToken), + ) + + // Act + val actual = response.toUserTokensResponse() + + // Assert + val expected = UserTokensResponse( + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + tokens = listOf(token1), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetWalletAccountsResponseAssignTokens { + + @Test + fun `assignTokens correctly assigns tokens to accounts`() { + // Arrange + val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" + val token1 = createUserToken(id = "0", accountId = null) + val token2 = createUserToken(id = "1", accountId = null) + val account1 = createWalletAccountDTO(derivationIndex = 0) + val account2 = createWalletAccountDTO(derivationIndex = 1) + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 2, + ), + accounts = listOf(account1, account2), + unassignedTokens = listOf(token1, token2), + ) + + // Act + val actual = response.assignTokens(userWalletId) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = response.wallet, + accounts = listOf( + account1.copy( + tokens = listOf( + token1.copy(accountId = accountId), + token2.copy(accountId = accountId), + ), + ), + account2, + ), + unassignedTokens = emptyList(), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `assignTokens does not change accounts if there are no unassignedTokens`() { + // Arrange + val account = createWalletAccountDTO(derivationIndex = 0) + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 1, + ), + accounts = listOf(account), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.assignTokens(userWalletId) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = response.wallet, + accounts = listOf(account), + unassignedTokens = emptyList(), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class WalletAccountDTOListAssignTokens { + + @Test + fun `assignTokens correctly assigns tokens to accounts`() { + // Arrange + val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" + val token1 = createUserToken(id = "0", accountId = null) + val token2 = createUserToken(id = "1", accountId = null) + val account1 = createWalletAccountDTO(derivationIndex = 0) + val account2 = createWalletAccountDTO(derivationIndex = 1) + + // Act + val actual = listOf(account1, account2).assignTokens( + userWalletId = userWalletId, + tokens = listOf(token1, token2), + ) + + // Assert + val expected = listOf( + account1.copy( + tokens = listOf( + token1.copy(accountId = accountId), + token2.copy(accountId = accountId), + ), + ), + account2, + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `assignTokens does not change accounts if there are no unassignedTokens`() { + // Arrange + val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" + val token1 = createUserToken(id = "0", accountId = accountId) + val account1 = createWalletAccountDTO(derivationIndex = 0) + + // Act + val actual = listOf(account1).assignTokens( + userWalletId = userWalletId, + tokens = listOf(token1), + ) + + // Assert + val expected = listOf( + account1.copy( + tokens = listOf( + token1.copy(accountId = accountId), + ), + ), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + } + + private fun createWalletAccountDTO(derivationIndex: Int, tokens: List = emptyList()) = + WalletAccountDTO( + id = AccountId.forCryptoPortfolio( + userWalletId = userWalletId, + derivationIndex = DerivationIndex(derivationIndex).getOrNull()!!, + ).value, + name = "Name #$derivationIndex", + derivationIndex = derivationIndex, + icon = "icon", + iconColor = "color", + tokens = tokens, + totalTokens = tokens.size, + totalNetworks = 1, + ) + + private fun createUserToken(id: String, accountId: String? = "account_id") = UserTokensResponse.Token( + id = id, + accountId = accountId, + networkId = "ethereum", + derivationPath = "m/44'/60'/0'/0/0", + name = "Token", + symbol = "T", + contractAddress = "0x$id", + decimals = 18, + ) + + private companion object { + + val userWalletId = UserWalletId("011") + } +} \ No newline at end of file diff --git a/data/common/build.gradle.kts b/data/common/build.gradle.kts index 6402820dd1..9994bc2f46 100644 --- a/data/common/build.gradle.kts +++ b/data/common/build.gradle.kts @@ -19,19 +19,20 @@ dependencies { implementation(projects.core.utils) /* Domain */ + implementation(projects.domain.account) implementation(projects.domain.demo) implementation(projects.domain.legacy) implementation(projects.domain.card) implementation(projects.domain.models) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) - implementation(projects.domain.notifications.toggles) implementation(projects.domain.networks) implementation(projects.domain.wallets) /* Libs - SDK */ implementation(tangemDeps.blockchain) implementation(tangemDeps.card.core) + implementation(projects.libs.crypto) implementation(projects.libs.blockchainSdk) /* DI */ diff --git a/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsFetcher.kt b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsFetcher.kt new file mode 100644 index 0000000000..ba81c5db5a --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsFetcher.kt @@ -0,0 +1,15 @@ +package com.tangem.data.common.account + +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Component for fetching wallet accounts + * +[REDACTED_AUTHOR] + */ +interface WalletAccountsFetcher { + + /** Fetch wallet accounts by [userWalletId] */ + @Throws + suspend fun fetch(userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt new file mode 100644 index 0000000000..f4b684613b --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt @@ -0,0 +1,29 @@ +package com.tangem.data.common.account + +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Saver for wallet accounts + * +[REDACTED_AUTHOR] + */ +interface WalletAccountsSaver { + + /** Push and store wallet accounts [response] by [userWalletId] */ + @Throws + suspend fun pushAndStore(userWalletId: UserWalletId, response: GetWalletAccountsResponse) + + /** Store wallet accounts [response] by [userWalletId] */ + suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) + + /** Push wallet accounts [body] by [userWalletId] */ + @Throws + suspend fun push(userWalletId: UserWalletId, body: SaveWalletAccountsResponse) + + /** Push wallet accounts [accounts] by [userWalletId] */ + @Throws + suspend fun push(userWalletId: UserWalletId, accounts: List) +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt new file mode 100644 index 0000000000..5c8a9b2dc1 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt @@ -0,0 +1,40 @@ +package com.tangem.data.common.cache.etag + +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.getSyncOrNull +import com.tangem.datasource.local.preferences.utils.store +import com.tangem.domain.models.wallet.UserWalletId +import timber.log.Timber + +/** + * Default implementation of the [ETagsStore] interface for managing ETag values + * + * @property appPreferencesStore the preferences store used for saving and retrieving ETag values + */ +internal class DefaultETagsStore( + private val appPreferencesStore: AppPreferencesStore, +) : ETagsStore { + + override suspend fun getSyncOrNull(userWalletId: UserWalletId, key: ETagsStore.Key): String? { + val key = getAccountsETagKey(userWalletId = userWalletId, key = key) + + return appPreferencesStore.getSyncOrNull(key = key) + } + + override suspend fun store(userWalletId: UserWalletId, key: ETagsStore.Key, value: String) { + if (value.isBlank()) { + Timber.e("ETag value is blank, not storing it. userWalletId: $userWalletId, key: $key") + return + } + + val key = getAccountsETagKey(userWalletId = userWalletId, key = key) + + appPreferencesStore.store(key = key, value = value) + } + + private fun getAccountsETagKey(userWalletId: UserWalletId, key: ETagsStore.Key): Preferences.Key { + return stringPreferencesKey(name = "etag_${key}_${userWalletId.stringValue}") + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt new file mode 100644 index 0000000000..430e9a0290 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt @@ -0,0 +1,34 @@ +package com.tangem.data.common.cache.etag + +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Interface for working with ETag (Entity Tag), which is used for data caching and validation. + * +[REDACTED_AUTHOR] + */ +interface ETagsStore { + + /** + * Retrieves the stored ETag value for the specified wallet and key + * + * @param userWalletId identifier of the user wallet + * @param key the key for which to get the ETag value + */ + suspend fun getSyncOrNull(userWalletId: UserWalletId, key: Key): String? + + /** + * Stores the ETag value for the specified wallet and key + * + * @param userWalletId identifier of the user wallet + * @param key the key for which to get the ETag value + */ + suspend fun store(userWalletId: UserWalletId, key: Key, value: String) + + /** Enumeration of possible keys for storing ETag values */ + enum class Key { + WalletAccounts, + UserTokens, + ; + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt new file mode 100644 index 0000000000..3594f6f1a0 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt @@ -0,0 +1,107 @@ +package com.tangem.data.common.currency + +import arrow.core.getOrElse +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.lib.crypto.derivation.AccountNodeRecognizer +import timber.log.Timber + +/** + * Enriches the [UserTokensResponse] with accountId values for tokens + * +[REDACTED_AUTHOR] + */ +object UserTokensResponseAccountIdEnricher { + + /** + * Enriches the tokens in the given [UserTokensResponse] with accountId values + * + * @param userWalletId the ID of the user wallet + * @param response the [UserTokensResponse] containing tokens to be enriched + */ + operator fun invoke(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse { + val enrichedTokens = invoke(userWalletId = userWalletId, tokens = response.tokens) + + return response.copy(tokens = enrichedTokens) + } + + /** + * Enriches the given list of tokens with accountId values + * + * @param userWalletId the ID of the user wallet + * @param tokens the list of tokens to be enriched + */ + operator fun invoke( + userWalletId: UserWalletId, + tokens: List, + ): List { + val hasUnassignedTokens = tokens.any { it.accountId == null } + if (!hasUnassignedTokens) return tokens + + val enrichedTokens = tokens + .filter { it.accountId == null } + .groupByAccountIndex() + .mapKeysToAccountId(userWalletId) + .mapToEnrichedTokens() + + if (enrichedTokens.isEmpty()) return tokens + + val enrichedTokenMap = enrichedTokens.associateBy { it } + return tokens.map { token -> + enrichedTokenMap[token] ?: token + } + } + + private fun List.groupByAccountIndex(): Map> { + return this + .groupBy { savedToken -> + val derivationPathValue = savedToken.derivationPath + if (derivationPathValue == null) { + Timber.e("Token $savedToken has no derivation path") + return@groupBy null + } + + val blockchain = Blockchain.fromNetworkId(networkId = savedToken.networkId) + if (blockchain == null) { + Timber.e("Token $savedToken has unknown networkId") + return@groupBy null + } + + val accountNodeRecognizer = AccountNodeRecognizer(blockchain) + val accountIndex = accountNodeRecognizer.recognize(derivationPathValue) + if (accountIndex == null) { + Timber.e("Token $savedToken has unrecognized derivation path") + return@groupBy null + } + + accountIndex + } + } + + private fun Map>.mapKeysToAccountId( + userWalletId: UserWalletId, + ): Map> { + return mapKeys { (accountIndex, _) -> + if (accountIndex == null) return@mapKeys null + + val derivationIndex = DerivationIndex.invoke(value = accountIndex.toInt()).getOrElse { + Timber.e("Failed to parse derivation index from account index: $accountIndex") + return@mapKeys null + } + + AccountId.forCryptoPortfolio(userWalletId, derivationIndex) + } + } + + private fun Map>.mapToEnrichedTokens(): List { + return flatMap { (accountId, tokens) -> + if (accountId == null) return@flatMap emptyList() + + tokens.map { it.copy(accountId = accountId.value) } + } + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricher.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricher.kt index 96f45165c5..2b155a84fc 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricher.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricher.kt @@ -5,7 +5,6 @@ import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.multi.MultiNetworkStatusProducer import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.first @@ -15,17 +14,12 @@ import javax.inject.Inject import kotlin.time.Duration.Companion.seconds class UserTokensResponseAddressesEnricher @Inject constructor( - private val notificationsFeatureToggles: NotificationsFeatureToggles, private val walletsRepository: WalletsRepository, private val dispatchers: CoroutineDispatcherProvider, private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, ) { suspend operator fun invoke(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse { - if (!notificationsFeatureToggles.isNotificationsEnabled) { - return response - } - val isNotificationsEnabled = walletsRepository.isNotificationsEnabled(userWalletId) return withContext(dispatchers.default) { diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt index 4c2623149b..84b75233ac 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt @@ -2,8 +2,9 @@ package com.tangem.data.common.currency import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.domain.models.currency.CryptoCurrency +import javax.inject.Inject -class UserTokensResponseFactory { +class UserTokensResponseFactory @Inject constructor() { fun createUserTokensResponse( currencies: List, diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt index 95c9010707..e0bfb846d8 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt @@ -5,60 +5,85 @@ import com.tangem.data.common.tokens.UserTokensBackwardCompatibility import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext -import timber.log.Timber class UserTokensSaver( private val tangemTechApi: TangemTechApi, private val userTokensResponseStore: UserTokensResponseStore, private val dispatchers: CoroutineDispatcherProvider, - private val userTokensResponseAddressesEnricher: UserTokensResponseAddressesEnricher, + private val addressesEnricher: UserTokensResponseAddressesEnricher, + private val accountsFeatureToggles: AccountsFeatureToggles, ) { private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility() - suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true) = - withContext(dispatchers.io) { - val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response) - val enrichedUserTokensResponse = if (useEnricher) { - userTokensResponseAddressesEnricher( - userWalletId = userWalletId, - response = compatibleUserTokensResponse, - ) - } else { - compatibleUserTokensResponse - } - - userTokensResponseStore.store(userWalletId = userWalletId, response = enrichedUserTokensResponse) - } - suspend fun storeAndPush(userWalletId: UserWalletId, response: UserTokensResponse) { - val enrichedUserTokensResponse = userTokensResponseAddressesEnricher( - userWalletId = userWalletId, - response = response, - ) - store(userWalletId, enrichedUserTokensResponse, false) - push(userWalletId, enrichedUserTokensResponse, false) + withContext(dispatchers.default) { + val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = true) + + store(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false) + push(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false) + } } + suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true) = + withContext(dispatchers.default) { + val updatedResponse = response + .applyCompatibility() + .enrichIf(userWalletId = userWalletId, condition = useEnricher) + + userTokensResponseStore.store(userWalletId = userWalletId, response = updatedResponse) + } + suspend fun push( userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true, onFailSend: () -> Unit = {}, - ) = withContext(dispatchers.io) { - val enrichedUserTokensResponse = if (useEnricher) { - userTokensResponseAddressesEnricher( - userWalletId = userWalletId, - response = response, + ) { + withContext(dispatchers.default) { + val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher) + + safeApiCall( + call = { + withContext(dispatchers.io) { + tangemTechApi.saveUserTokens(userId = userWalletId.stringValue, userTokens = enrichedResponse) + .bind() + } + }, + onError = { onFailSend() }, ) - } else { - response - } - safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedUserTokensResponse).bind() }) { - Timber.e(it, "Unable to push user tokens for: ${userWalletId.stringValue}") - onFailSend() } } + + private fun UserTokensResponse.applyCompatibility(): UserTokensResponse { + return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this) + } + + private suspend fun UserTokensResponse.enrichIf( + userWalletId: UserWalletId, + condition: Boolean, + ): UserTokensResponse { + if (!condition) return this + + return this + .enrichByAddress(userWalletId = userWalletId) + .let { + if (accountsFeatureToggles.isFeatureEnabled) { + it.enrichByAccountId(userWalletId = userWalletId) + } else { + it + } + } + } + + private suspend fun UserTokensResponse.enrichByAddress(userWalletId: UserWalletId): UserTokensResponse { + return addressesEnricher(userWalletId = userWalletId, response = this) + } + + private fun UserTokensResponse.enrichByAccountId(userWalletId: UserWalletId): UserTokensResponse { + return UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, response = this) + } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt index 2e6266a009..79af422911 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt @@ -1,15 +1,18 @@ package com.tangem.data.common.di import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.cache.etag.DefaultETagsStore +import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.* 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 import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -42,13 +45,11 @@ internal object DataCommonModule { @Provides @Singleton fun provideUserTokensEncricher( - notificationsFeatureToggles: NotificationsFeatureToggles, walletsRepository: WalletsRepository, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, dispatchers: CoroutineDispatcherProvider, ): UserTokensResponseAddressesEnricher { return UserTokensResponseAddressesEnricher( - notificationsFeatureToggles = notificationsFeatureToggles, walletsRepository = walletsRepository, multiNetworkStatusSupplier = multiNetworkStatusSupplier, dispatchers = dispatchers, @@ -61,13 +62,15 @@ internal object DataCommonModule { tangemTechApi: TangemTechApi, userTokensResponseStore: UserTokensResponseStore, dispatchers: CoroutineDispatcherProvider, - enricher: UserTokensResponseAddressesEnricher, + addressesEnricher: UserTokensResponseAddressesEnricher, + accountsFeatureToggles: AccountsFeatureToggles, ): UserTokensSaver { return UserTokensSaver( tangemTechApi = tangemTechApi, userTokensResponseStore = userTokensResponseStore, dispatchers = dispatchers, - userTokensResponseAddressesEnricher = enricher, + addressesEnricher = addressesEnricher, + accountsFeatureToggles = accountsFeatureToggles, ) } @@ -76,4 +79,10 @@ internal object DataCommonModule { fun provideQuotesFetcher(tangemTechApi: TangemTechApi, dispatchers: CoroutineDispatcherProvider): QuotesFetcher { return DefaultQuotesFetcher(tangemTechApi = tangemTechApi, dispatchers = dispatchers) } + + @Provides + @Singleton + fun provideETagsStore(appPreferencesStore: AppPreferencesStore): ETagsStore { + return DefaultETagsStore(appPreferencesStore = appPreferencesStore) + } } \ 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 index ca867be571..91f3f11426 100644 --- 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 @@ -7,6 +7,7 @@ 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.DefaultQuotesFetcher.Companion.tenSecInMillis import com.tangem.data.common.quote.QuotesFetcher.Error import com.tangem.data.common.quote.QuotesFetcher.Field import com.tangem.data.common.quote.utils.combine @@ -18,6 +19,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.joda.time.DateTime +import timber.log.Timber import java.util.concurrent.ConcurrentHashMap import kotlin.time.Duration.Companion.seconds @@ -181,11 +183,24 @@ internal class DefaultQuotesFetcher( fields = fields.combine(), ) .bind() + .addSkippedIfHas(currenciesIds) }, onError = { raise(Error.ApiOperationError(it)) }, ) } + private fun QuotesResponse.addSkippedIfHas(ids: Set): QuotesResponse { + val skippedIds = ids.filterNot(quotes.keys::contains).ifEmpty { + return this + } + + Timber.d("Some quotes are missing from the server response: $skippedIds") + + val emptyQuotes = skippedIds.associateWith { QuotesResponse.Quote.EMPTY } + + return copy(quotes = quotes + emptyQuotes) + } + private fun saveQuotes(fiatCurrencyId: String, response: QuotesResponse) { val newQuotes = response.quotes.mapTo(destination = hashSetOf()) { (currencyId, quote) -> QuoteMetadata( diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt index b5b8ad9269..ceb7c2fc1c 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt @@ -7,13 +7,14 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.card.WalletData import com.tangem.common.test.domain.card.MockScanResponseFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.domain.wallet.MockUserWalletFactory import com.tangem.common.test.utils.ProvideTestModels import com.tangem.data.common.network.NetworkFactory import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.card.configs.GenericCardConfig import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.card.configs.GenericCardConfig import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -159,7 +160,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest { model: CreateTestModel.SingleWalletWithToken, ) = runTest { // Arrange - val userWallet = createSingleWalletWithToken() + val userWallet = MockUserWalletFactory.createSingleWalletWithToken() coEvery { userWalletsStore.getSyncStrict(key = userWallet.walletId) } returns userWallet @@ -284,7 +285,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest { expected = Result.failure(IllegalArgumentException("It isn't multi-currency wallet")), ), CreateCurrenciesForMultiWalletModel( - multiWallet = createSingleWalletWithToken(), + multiWallet = MockUserWalletFactory.createSingleWalletWithToken(), userTokensResponse = null, expected = Result.failure(IllegalArgumentException("It isn't multi-currency wallet")), ), @@ -456,7 +457,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest { expected = Result.failure(IllegalArgumentException("Coin for the single currency card cannot be null")), ), CreateForSingleWalletWithTokenModel( - singleWalletWithToken = createSingleWalletWithToken(), + singleWalletWithToken = MockUserWalletFactory.createSingleWalletWithToken(), isPrimaryTokenExpected = true, expected = Result.success(listOf(ethereum)), ), @@ -522,33 +523,8 @@ internal class DefaultCardCryptoCurrencyFactoryTest { ) } - private fun createSingleWalletWithToken(): UserWallet.Cold { - return UserWallet.Cold( - name = "NODL", - walletId = UserWalletId("011"), - cardsInWallet = setOf(), - isMultiCurrency = false, - scanResponse = MockScanResponseFactory.create( - cardConfig = GenericCardConfig(maxWalletCount = 2), - derivedKeys = emptyMap(), - ).copy( - productType = ProductType.Note, - walletData = WalletData( - blockchain = "ETH", - token = WalletData.Token( - name = "Ethereum", - symbol = "ETH", - contractAddress = "0x", - decimals = 8, - ), - ), - ), - hasBackupError = false, - ) - } - private fun createPrimaryToken(blockchain: Blockchain): CryptoCurrency.Token { - val userWallet = createSingleWalletWithToken() + val userWallet = MockUserWalletFactory.createSingleWalletWithToken() return CryptoCurrencyFactory(excludedBlockchains = ExcludedBlockchains()).createToken( sdkToken = userWallet.scanResponse.cardTypesResolver.getPrimaryToken()!!, diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt new file mode 100644 index 0000000000..bd7c253d03 --- /dev/null +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt @@ -0,0 +1,151 @@ +package com.tangem.data.common.currency + +import com.google.common.truth.Truth +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class UserTokensResponseAccountIdEnricherTest { + + private val userWalletId = UserWalletId("011") + private val mockCryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val userTokensResponseFactory = UserTokensResponseFactory() + + @Test + fun `enriches tokens with missing account ids`() { + // Arrange + val response = mockCryptoCurrencyFactory.ethereumAndStellar + .mapIndexed { index, currency -> + currency.toResponseToken( + accountId = null, + derivationPath = "m/44'/60'/$index'/0/0", + ) + } + .toResponse() + + // Act + val actual = UserTokensResponseAccountIdEnricher(userWalletId, response) + + // Assert + val expected = response.tokens + .mapIndexed { index, currency -> + currency.enrichWithAccountId(accountIndex = index) + } + .toResponse() + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `does not modify tokens with existing account ids`() { + // Arrange + val response = mockCryptoCurrencyFactory.ethereumAndStellar + .mapIndexed { index, currency -> + currency.toResponseToken(derivationPath = "m/44'/60'/$index'/0/0") + .enrichWithAccountId(accountIndex = index) + } + .toResponse() + + // Act + val actual = UserTokensResponseAccountIdEnricher(userWalletId, response) + + // Assert + val expected = response + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `skips tokens with invalid derivation paths`() { + // Arrange + val validDerivationPath = "m/44'/60'/0'/0/0" + val invalidDerivationPath = "invalid/path" + + val tokenWithInvalidPath = mockCryptoCurrencyFactory.ethereum.toResponseToken( + accountId = null, + derivationPath = invalidDerivationPath, + ) + + val tokenWithValidPath = mockCryptoCurrencyFactory.stellar.toResponseToken( + accountId = null, + derivationPath = validDerivationPath, + ) + + val response = listOf(tokenWithInvalidPath, tokenWithValidPath).toResponse() + + // Act + val actual = UserTokensResponseAccountIdEnricher(userWalletId, response) + + // Assert + val expected = listOf( + tokenWithInvalidPath, + tokenWithValidPath.enrichWithAccountId(accountIndex = 0), + ).toResponse() + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `skips tokens with unknown network id`() { + // Arrange + val unknownNetworkId = "unknown" + val validNetworkId = mockCryptoCurrencyFactory.ethereum.network.rawId + + val tokenWithUnknownNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken( + networkId = unknownNetworkId, + derivationPath = "m/44'/60'/0'/0/0", + accountId = null, + ) + + val tokenWithValidNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken( + accountId = null, + networkId = validNetworkId, + derivationPath = "m/44'/60'/0'/0/0", + ) + + val response = listOf(tokenWithUnknownNetworkId, tokenWithValidNetworkId).toResponse() + + // Act + val actual = UserTokensResponseAccountIdEnricher(userWalletId, response) + + // Assert + val expected = listOf( + tokenWithUnknownNetworkId, + tokenWithValidNetworkId.enrichWithAccountId(accountIndex = 0), + ).toResponse() + + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun CryptoCurrency.toResponseToken( + accountId: AccountId? = null, + networkId: String? = null, + derivationPath: String, + ): UserTokensResponse.Token { + return userTokensResponseFactory.createResponseToken(this).copy( + networkId = networkId ?: network.rawId, + derivationPath = derivationPath, + accountId = accountId?.value, + ) + } + + private fun List.toResponse(): UserTokensResponse { + return UserTokensResponse( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + tokens = this, + ) + } + + private fun UserTokensResponse.Token.enrichWithAccountId(accountIndex: Int): UserTokensResponse.Token { + val derivationIndex = DerivationIndex(value = accountIndex).getOrNull()!! + val accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex) + + return copy(accountId = accountId.value) + } +} \ No newline at end of file diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricherTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricherTest.kt index faf985a7b2..77b27593e6 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricherTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricherTest.kt @@ -7,7 +7,6 @@ import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider @@ -23,7 +22,6 @@ import org.junit.Test class UserTokensResponseAddressesEnricherTest { - private lateinit var notificationsFeatureToggles: NotificationsFeatureToggles private lateinit var walletsRepository: WalletsRepository private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider() private lateinit var multiNetworkStatusSupplier: MultiNetworkStatusSupplier @@ -31,12 +29,10 @@ class UserTokensResponseAddressesEnricherTest { @Before fun setup() { - notificationsFeatureToggles = mockk() walletsRepository = mockk() multiNetworkStatusSupplier = mockk() enricher = UserTokensResponseAddressesEnricher( - notificationsFeatureToggles = notificationsFeatureToggles, walletsRepository = walletsRepository, dispatchers = dispatchers, multiNetworkStatusSupplier = multiNetworkStatusSupplier, @@ -54,7 +50,6 @@ class UserTokensResponseAddressesEnricherTest { val userWalletId = UserWalletId("1234567890abcdef") val token = createToken() val response = createUserTokensResponse(tokens = listOf(token)) - every { notificationsFeatureToggles.isNotificationsEnabled } returns false // WHEN val result = enricher(userWalletId, response) @@ -70,7 +65,6 @@ class UserTokensResponseAddressesEnricherTest { val userWalletId = UserWalletId("1234567890abcdef") val token = createToken() val response = createUserTokensResponse(tokens = listOf(token)) - every { notificationsFeatureToggles.isNotificationsEnabled } returns true coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns false coEvery { multiNetworkStatusSupplier.invoke(any()) @@ -87,6 +81,7 @@ class UserTokensResponseAddressesEnricherTest { }, amounts = emptyMap(), pendingTransactions = emptyMap(), + yieldSupplyStatuses = emptyMap(), source = StatusSource.ACTUAL, ), ), @@ -110,7 +105,6 @@ class UserTokensResponseAddressesEnricherTest { val response = createUserTokensResponse(tokens = listOf(token)) val addresses = listOf("0x123", "0x456") - every { notificationsFeatureToggles.isNotificationsEnabled } returns true coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns true coEvery { multiNetworkStatusSupplier.invoke(any()) @@ -131,6 +125,7 @@ class UserTokensResponseAddressesEnricherTest { }, amounts = emptyMap(), pendingTransactions = emptyMap(), + yieldSupplyStatuses = emptyMap(), source = StatusSource.ACTUAL, ), ), @@ -152,7 +147,6 @@ class UserTokensResponseAddressesEnricherTest { val token = createToken() val response = createUserTokensResponse(tokens = listOf(token)) - every { notificationsFeatureToggles.isNotificationsEnabled } returns true coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns true coEvery { multiNetworkStatusSupplier.invoke(any()) @@ -169,6 +163,7 @@ class UserTokensResponseAddressesEnricherTest { }, amounts = emptyMap(), pendingTransactions = emptyMap(), + yieldSupplyStatuses = emptyMap(), source = StatusSource.ACTUAL, ), ), diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt index 6b4a086180..66e4d28c01 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt @@ -5,6 +5,7 @@ import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* @@ -19,12 +20,16 @@ class UserTokensSaverTest { private val tangemTechApi: TangemTechApi = mockk() private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxed = true) private val enricher: UserTokensResponseAddressesEnricher = mockk() + private val accountsFeatureToggles = mockk { + every { this@mockk.isFeatureEnabled } returns true + } private val userTokensSaver: UserTokensSaver = UserTokensSaver( tangemTechApi = tangemTechApi, userTokensResponseStore = userTokensResponseStore, - userTokensResponseAddressesEnricher = enricher, dispatchers = TestingCoroutineDispatcherProvider(), + addressesEnricher = enricher, + accountsFeatureToggles = accountsFeatureToggles, ) @BeforeEach diff --git a/data/feedback/build.gradle.kts b/data/feedback/build.gradle.kts index 9826df2814..08b86b9db4 100644 --- a/data/feedback/build.gradle.kts +++ b/data/feedback/build.gradle.kts @@ -10,7 +10,6 @@ android { } dependencies { - // region AndroidX libraries implementation(deps.androidx.datastore) // endregion @@ -37,6 +36,8 @@ dependencies { // endregion + // Feature modules + implementation(projects.features.hotWallet.api) implementation(projects.domain.feedback) implementation(projects.domain.feedback.models) diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt index 5de6bede2a..54cb04d95b 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt @@ -4,14 +4,17 @@ import android.os.Build import com.tangem.blockchain.common.Blockchain import com.tangem.core.navigation.email.EmailSender import com.tangem.data.feedback.converters.BlockchainInfoConverter -import com.tangem.data.feedback.converters.CardInfoConverter +import com.tangem.data.feedback.converters.WalletMetaInfoConverter import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.walletmanager.WalletManagersStore +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.feedback.models.* import com.tangem.domain.feedback.repository.FeedbackRepository import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.utils.version.AppVersionProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -23,28 +26,43 @@ import java.io.File * * @property appLogsStore app logs store * @property userWalletsListManager user wallets list manager + * @property useNewUserWalletsRepository flag to use new user wallets repository + * @property userWalletsListRepository user wallets repository * @property walletManagersStore wallet managers store * @property emailSender email sender * @property appVersionProvider app version provider * [REDACTED_AUTHOR] */ +@Suppress("LongParameterList") internal class DefaultFeedbackRepository( private val appLogsStore: AppLogsStore, + private val useNewUserWalletsRepository: Boolean, + private val userWalletsListRepository: UserWalletsListRepository, private val userWalletsListManager: UserWalletsListManager, private val walletManagersStore: WalletManagersStore, private val emailSender: EmailSender, private val appVersionProvider: AppVersionProvider, + private val getSelectedWalletUseCase: GetSelectedWalletUseCase, ) : FeedbackRepository { private val blockchainsErrors = MutableStateFlow>(emptyMap()) - override fun getCardInfo(scanResponse: ScanResponse) = CardInfoConverter.convert(value = scanResponse) + override suspend fun getUserWalletMetaInfo(userWalletId: UserWalletId): WalletMetaInfo { + val userWallet = getUserWalletById(userWalletId) + return userWallet?.let { + WalletMetaInfoConverter.convert(it) + } ?: WalletMetaInfo(userWalletId) + } + + override fun getUserWalletMetaInfo(scanResponse: ScanResponse): WalletMetaInfo { + return WalletMetaInfoConverter.convert(value = scanResponse) + } override fun getUserWalletsInfo(userWalletId: UserWalletId?): UserWalletsInfo { return UserWalletsInfo( selectedUserWalletId = userWalletId?.stringValue ?: "card isn't activated", - totalUserWallets = userWalletsListManager.walletsCount, + totalUserWallets = totalUserWallets(), ) } @@ -77,7 +95,7 @@ internal class DefaultFeedbackRepository( } override fun saveBlockchainErrorInfo(error: BlockchainErrorInfo) { - val userWallet = userWalletsListManager.selectedUserWalletSync ?: error("UserWallet is not selected") + val userWallet = getSelectedWalletUseCase.sync().getOrNull() ?: error("UserWallet is not selected") blockchainsErrors.update { it.toMutableMap().apply { @@ -106,4 +124,20 @@ internal class DefaultFeedbackRepository( ), ) } + + private suspend fun getUserWalletById(userWalletId: UserWalletId): UserWallet? { + return if (useNewUserWalletsRepository) { + userWalletsListRepository.userWalletsSync().find { it.walletId == userWalletId } + } else { + userWalletsListManager.userWalletsSync.find { it.walletId == userWalletId } + } + } + + private fun totalUserWallets(): Int { + return if (useNewUserWalletsRepository) { + userWalletsListRepository.userWallets.value?.size ?: 0 + } else { + userWalletsListManager.walletsCount + } + } } \ No newline at end of file diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt b/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt deleted file mode 100644 index 1b17450f2d..0000000000 --- a/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.data.feedback.converters - -import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin -import com.tangem.domain.card.common.TapWorkarounds.isVisa -import com.tangem.domain.card.common.util.getBackupCardsCount -import com.tangem.domain.feedback.models.CardInfo -import com.tangem.domain.models.scan.CardDTO -import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.builder.UserWalletIdBuilder -import com.tangem.utils.converter.Converter - -/** - * Converter from [ScanResponse] to [CardInfo] - * -[REDACTED_AUTHOR] - */ -internal object CardInfoConverter : Converter { - - override fun convert(value: ScanResponse): CardInfo { - return with(value) { - CardInfo( - userWalletId = createUserWalletId(scanResponse = value), - cardId = card.cardId, - cardsCount = value.getBackupCardsCount()?.toString() ?: "0", - firmwareVersion = card.firmwareVersion.stringValue, - cardBlockchain = walletData?.blockchain, - signedHashesList = card.wallets.map { - CardInfo.SignedHashes(curve = it.curve.curve, total = it.totalSignedHashes?.toString()) - }, - isImported = value.card.wallets.any(CardDTO.Wallet::isImported), - isStart2Coin = value.card.isStart2Coin, - isVisa = value.card.isVisa, - ) - } - } - - private fun createUserWalletId(scanResponse: ScanResponse): UserWalletId? { - return UserWalletIdBuilder.scanResponse(scanResponse = scanResponse).build() - } -} \ No newline at end of file diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/converters/WalletMetaInfoConverter.kt b/data/feedback/src/main/java/com/tangem/data/feedback/converters/WalletMetaInfoConverter.kt new file mode 100644 index 0000000000..ceaabe5809 --- /dev/null +++ b/data/feedback/src/main/java/com/tangem/data/feedback/converters/WalletMetaInfoConverter.kt @@ -0,0 +1,66 @@ +package com.tangem.data.feedback.converters + +import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin +import com.tangem.domain.card.common.TapWorkarounds.isVisa +import com.tangem.domain.card.common.util.getBackupCardsCount +import com.tangem.domain.feedback.models.WalletMetaInfo +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.builder.UserWalletIdBuilder +import com.tangem.utils.converter.Converter + +/** + * Converter from [UserWallet] to [WalletMetaInfo] + * +[REDACTED_AUTHOR] + */ +internal object WalletMetaInfoConverter : Converter { + + override fun convert(value: UserWallet): WalletMetaInfo { + return when (value) { + is UserWallet.Cold -> { + WalletMetaInfo( + userWalletId = value.walletId, + cardId = value.scanResponse.card.cardId, + cardsCount = value.getBackupCardsCount()?.toString() ?: "0", + firmwareVersion = value.scanResponse.card.firmwareVersion.stringValue, + cardBlockchain = value.scanResponse.walletData?.blockchain, + signedHashesList = value.scanResponse.card.wallets.map { + WalletMetaInfo.SignedHashes(curve = it.curve.curve, total = it.totalSignedHashes?.toString()) + }, + isImported = value.scanResponse.card.wallets.any(CardDTO.Wallet::isImported), + isStart2Coin = value.scanResponse.card.isStart2Coin, + isVisa = value.scanResponse.card.isVisa, + ) + } + is UserWallet.Hot -> { + WalletMetaInfo( + userWalletId = value.walletId, + hotWalletIsBackedUp = value.backedUp, + ) + } + } + } + + fun convert(value: ScanResponse): WalletMetaInfo { + return WalletMetaInfo( + userWalletId = createUserWalletId(value), + cardId = value.card.cardId, + cardsCount = value.getBackupCardsCount()?.toString() ?: "0", + firmwareVersion = value.card.firmwareVersion.stringValue, + cardBlockchain = value.walletData?.blockchain, + signedHashesList = value.card.wallets.map { + WalletMetaInfo.SignedHashes(curve = it.curve.curve, total = it.totalSignedHashes?.toString()) + }, + isImported = value.card.wallets.any(CardDTO.Wallet::isImported), + isStart2Coin = value.card.isStart2Coin, + isVisa = value.card.isVisa, + ) + } + + private fun createUserWalletId(scanResponse: ScanResponse): UserWalletId? { + return UserWalletIdBuilder.scanResponse(scanResponse = scanResponse).build() + } +} \ No newline at end of file diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt b/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt index cd701c4695..067feab7a8 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt @@ -6,9 +6,12 @@ import com.tangem.data.feedback.DefaultFeedbackFeatureToggles import com.tangem.data.feedback.DefaultFeedbackRepository import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.walletmanager.WalletManagersStore +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.feedback.repository.FeedbackFeatureToggles import com.tangem.domain.feedback.repository.FeedbackRepository import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.utils.version.AppVersionProvider import dagger.Module import dagger.Provides @@ -25,9 +28,12 @@ internal object FeedbackModule { fun provideFeedbackRepository( appLogsStore: AppLogsStore, userWalletsListManager: UserWalletsListManager, + userWalletsListRepository: UserWalletsListRepository, + hotWalletFeatureToggles: HotWalletFeatureToggles, walletManagersStore: WalletManagersStore, emailSender: EmailSender, appVersionProvider: AppVersionProvider, + getSelectedWalletUseCase: GetSelectedWalletUseCase, ): FeedbackRepository { return DefaultFeedbackRepository( appLogsStore = appLogsStore, @@ -35,6 +41,9 @@ internal object FeedbackModule { walletManagersStore = walletManagersStore, emailSender = emailSender, appVersionProvider = appVersionProvider, + userWalletsListRepository = userWalletsListRepository, + useNewUserWalletsRepository = hotWalletFeatureToggles.isHotWalletEnabled, + getSelectedWalletUseCase = getSelectedWalletUseCase, ) } 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 34beb0198c..3fc333d86f 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 @@ -15,6 +15,7 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.card.common.extensions.canHandleBlockchain +import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains import com.tangem.domain.card.common.extensions.supportedBlockchains import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.managetokens.model.AddCustomTokenForm @@ -252,7 +253,9 @@ internal class DefaultCustomTokensRepository( is UserWallet.Hot -> { Blockchain.entries.mapNotNull { // TODO: refactor [REDACTED_JIRA]\ - if (it.isTestnet() || it in excludedBlockchains) return@mapNotNull null + if (it.isTestnet() || it in excludedBlockchains || it in hotWalletExcludedBlockchains) { + return@mapNotNull null + } networkFactory.create( blockchain = it, diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt index eca48639fe..c5c8b51cdd 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt @@ -23,6 +23,7 @@ import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.card.common.TapWorkarounds.isTestCard import com.tangem.domain.card.common.extensions.canHandleBlockchain import com.tangem.domain.card.common.extensions.canHandleToken +import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains import com.tangem.domain.card.common.extensions.supportedBlockchains import com.tangem.domain.card.common.extensions.supportedTokens import com.tangem.domain.card.common.util.cardTypesResolver @@ -291,13 +292,8 @@ internal class DefaultManageTokensRepository( userWallet: UserWallet, blockchain: Blockchain, ): CurrencyUnsupportedState? { - if (userWallet !is UserWallet.Cold) { - return null - } - - val canHandleBlockchain = userWallet.scanResponse.card.canHandleBlockchain( + val canHandleBlockchain = userWallet.canHandleBlockchain( blockchain = blockchain, - cardTypesResolver = userWallet.cardTypesResolver, excludedBlockchains = excludedBlockchains, ) @@ -312,6 +308,14 @@ internal class DefaultManageTokensRepository( userWallet: UserWallet, blockchain: Blockchain, ): CurrencyUnsupportedState.Token? { + if (userWallet is UserWallet.Hot) { + return if (blockchain in hotWalletExcludedBlockchains) { + CurrencyUnsupportedState.Token.NetworkTokensUnsupported(networkName = blockchain.fullName) + } else { + null + } + } + if (userWallet !is UserWallet.Cold) { return null } diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt index a8414dd685..b142323e74 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt @@ -22,6 +22,7 @@ internal object NetworkStatusDataModelConverter : Converter { diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt new file mode 100644 index 0000000000..d34edbbe5b --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt @@ -0,0 +1,46 @@ +package com.tangem.data.networks.converters + +import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.utils.converter.TwoWayConverter +import com.tangem.utils.extensions.mapNotNullValues + +private typealias YieldSupplyStatusDataModel = Map +private typealias YieldSupplyStatusDomainModel = Map + +internal object NetworkYieldSupplyStatusConverter : + TwoWayConverter { + + override fun convert(value: YieldSupplyStatusDataModel): YieldSupplyStatusDomainModel { + return value + .mapKeys { CryptoCurrency.ID.fromValue(value = it.key) } + .mapValues { (_, yieldSupplyStatus) -> + if (yieldSupplyStatus != null) { + YieldSupplyStatus( + isActive = yieldSupplyStatus.isActive, + isInitialized = yieldSupplyStatus.isInitialized, + isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, + ) + } else { + null + } + } + } + + override fun convertBack(value: YieldSupplyStatusDomainModel): YieldSupplyStatusDataModel { + return value + .mapKeys { (id, _) -> id.value } + .mapNotNullValues { (_, yieldSupplyStatus) -> + if (yieldSupplyStatus != null) { + NetworkStatusDM.YieldSupplyStatus( + isActive = yieldSupplyStatus.isActive, + isInitialized = yieldSupplyStatus.isInitialized, + isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, + ) + } else { + null + } + } + } +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt index 44d85ee5d7..40a48b378f 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt @@ -29,6 +29,7 @@ internal object SimpleNetworkStatusConverter : Converter { diff --git a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducer.kt b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducer.kt index b4fc8494be..267f975e39 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducer.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducer.kt @@ -1,5 +1,7 @@ package com.tangem.data.networks.multi +import arrow.core.Option +import arrow.core.some import com.tangem.data.common.network.NetworkFactory import com.tangem.data.networks.store.NetworksStatusesStore import com.tangem.datasource.local.userwallet.UserWalletsStore @@ -29,8 +31,7 @@ internal class DefaultMultiNetworkStatusProducer @AssistedInject constructor( private val dispatchers: CoroutineDispatcherProvider, ) : MultiNetworkStatusProducer { - override val fallback: Set - get() = setOf() + override val fallback: Option> = emptySet().some() override fun produce(): Flow> { return networksStatusesStore.get(userWalletId = params.userWalletId) diff --git a/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducer.kt b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducer.kt index f2b61d8433..76cbeeb604 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducer.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducer.kt @@ -1,5 +1,7 @@ package com.tangem.data.networks.single +import arrow.core.Option +import arrow.core.some import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.networks.multi.MultiNetworkStatusProducer import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier @@ -28,8 +30,8 @@ internal class DefaultSingleNetworkStatusProducer @AssistedInject constructor( private val dispatchers: CoroutineDispatcherProvider, ) : SingleNetworkStatusProducer { - override val fallback: NetworkStatus - get() = NetworkStatus(network = params.network, value = NetworkStatus.Unreachable(address = null)) + override val fallback: Option + get() = NetworkStatus(network = params.network, value = NetworkStatus.Unreachable(address = null)).some() override fun produce(): Flow { return multiNetworkStatusSupplier( diff --git a/data/networks/src/main/java/com/tangem/data/networks/utils/NetworkStatusFactory.kt b/data/networks/src/main/java/com/tangem/data/networks/utils/NetworkStatusFactory.kt index 018a340fd4..9c9742678c 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/utils/NetworkStatusFactory.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/utils/NetworkStatusFactory.kt @@ -8,6 +8,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import timber.log.Timber /** Factory for creating [NetworkStatus] */ @@ -70,6 +71,10 @@ object NetworkStatusFactory { transactions = result.currentTransactions, currencies = addedCurrencies, ), + yieldSupplyStatuses = formatYieldSupplyStatuses( + amounts = result.currenciesAmounts, + currencies = addedCurrencies, + ), source = StatusSource.ACTUAL, ) } @@ -107,6 +112,31 @@ object NetworkStatusFactory { } } + private fun formatYieldSupplyStatuses( + amounts: Set, + currencies: Set, + ): Map { + return currencies.associate { currency -> + val amount = when (currency) { + is CryptoCurrency.Coin -> null + is CryptoCurrency.Token -> { + amounts.filterIsInstance() + .firstOrNull { amount -> + currency.id.rawCurrencyId == amount.currencyRawId && + currency.contractAddress.equals(amount.contractAddress, ignoreCase = true) + } + } + } + + if (amount == null) { + Timber.w("Unable to find amount for cryptocurrency: $currency") + currency.id to null + } else { + currency.id to amount.yieldSupplyStatus + } + } + } + private fun formatTransactions( transactions: Set, currencies: Set, diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt index 2297a75c13..a254164b1a 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.network.NetworkStatus.Amount +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource @@ -59,6 +60,22 @@ internal class NetworkStatusDataModelConverterTest { ) to Amount.NotFound, ), pendingTransactions = mapOf(), // doesn't matter + yieldSupplyStatuses = mapOf( + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkId(rawId = "BCH"), + suffix = ID.Suffix.RawID(rawId = "bitcoin-cash"), + ) to YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkId(rawId = "BTC"), + suffix = ID.Suffix.RawID(rawId = "bitcoin"), + ) to null, + ), source = StatusSource.ACTUAL, // doesn't matter ), ), @@ -76,6 +93,13 @@ internal class NetworkStatusDataModelConverterTest { ), ), amounts = mapOf("coin⟨BCH⟩bitcoin-cash" to BigDecimal.ZERO), + yieldSupplyStatuses = mapOf( + "coin⟨BCH⟩bitcoin-cash" to NetworkStatusDM.YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ), ), ), // endregion diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt new file mode 100644 index 0000000000..c1ef31d529 --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt @@ -0,0 +1,83 @@ +package com.tangem.data.networks.converters + +import com.google.common.truth.Truth +import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.domain.models.currency.CryptoCurrency.ID +import com.tangem.domain.models.currency.CryptoCurrency.ID.Body +import com.tangem.domain.models.currency.CryptoCurrency.ID.Prefix +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import org.junit.jupiter.api.Test + +internal class NetworkYieldSupplyStatusConverterTest { + + @Test + fun convert() { + // Arrange + val value = mapOf( + "coin⟨ETH⟩ethereum" to NetworkStatusDM.YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + "coin⟨ETH→12367123⟩ethereum" to null, + ) + + // Act + val actual = NetworkYieldSupplyStatusConverter.convert(value) + + // Assert + val expected = mapOf( + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkId(rawId = "ETH"), + suffix = ID.Suffix.RawID(rawId = "ethereum"), + ) to YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123), + suffix = ID.Suffix.RawID(rawId = "ethereum"), + ) to null, + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun convertBack() { + // Arrange + val value = mapOf( + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkId(rawId = "ETH"), + suffix = ID.Suffix.RawID(rawId = "ethereum"), + ) to YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123), + suffix = ID.Suffix.RawID(rawId = "ethereum"), + ) to null, + ) + + // Act + val actual = NetworkYieldSupplyStatusConverter.convertBack(value) + + // Assert + val expected = mapOf( + "coin⟨ETH⟩ethereum" to NetworkStatusDM.YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt index cb4ad39dcd..3fe7708a7e 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt @@ -12,6 +12,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.network.NetworkStatus.Amount +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource @@ -68,6 +69,14 @@ internal class SimpleNetworkStatusConverterTest { "coin⟨BCH⟩bitcoin-cash" to BigDecimal.ZERO, "coin⟨ETH→12367123⟩ethereum" to BigDecimal.ONE, ), + yieldSupplyStatuses = mapOf( + "coin⟨ETH⟩ethereum" to NetworkStatusDM.YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + "coin⟨ETH⟩ethereum" to null, + ), ), expected = SimpleNetworkStatus( id = Network.ID( @@ -104,6 +113,22 @@ internal class SimpleNetworkStatusConverterTest { ) to Amount.Loaded(value = BigDecimal.ONE), ), pendingTransactions = emptyMap(), + yieldSupplyStatuses = mapOf( + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkId(rawId = "ETH"), + suffix = ID.Suffix.RawID(rawId = "ethereum"), + ) to YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkId(rawId = "ETH"), + suffix = ID.Suffix.RawID(rawId = "ethereum"), + ) to null, + ), source = StatusSource.CACHE, ), ).let(Result.Companion::success), @@ -178,6 +203,7 @@ internal class SimpleNetworkStatusConverterTest { ), ), amounts = emptyMap(), + yieldSupplyStatuses = emptyMap(), ), expected = Result.failure( exception = IllegalArgumentException("Selected address must not be null"), @@ -193,6 +219,7 @@ internal class SimpleNetworkStatusConverterTest { selectedAddress = "0x1", availableAddresses = setOf(), amounts = emptyMap(), + yieldSupplyStatuses = emptyMap(), ), expected = Result.failure( exception = IllegalArgumentException("Selected address must not be null"), @@ -217,6 +244,7 @@ internal class SimpleNetworkStatusConverterTest { ), ), amounts = emptyMap(), + yieldSupplyStatuses = emptyMap(), ), expected = Result.failure( exception = IllegalArgumentException("Selected address must not be null"), diff --git a/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt index d521fce1b3..f45a3fae53 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt @@ -149,6 +149,11 @@ internal class CommonNetworkStatusFetcherTest { pendingTransactions = mapOf( CryptoCurrency.ID.fromValue(value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND") to emptySet(), ), + yieldSupplyStatuses = mapOf( + CryptoCurrency.ID.fromValue( + value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND", + ) to null, + ), ) }, ), diff --git a/data/networks/src/test/java/com/tangem/data/networks/utils/NetworkStatusFactoryTest.kt b/data/networks/src/test/java/com/tangem/data/networks/utils/NetworkStatusFactoryTest.kt index 3cc7ba1297..2350f4e822 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/utils/NetworkStatusFactoryTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/utils/NetworkStatusFactoryTest.kt @@ -13,6 +13,7 @@ import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.network.NetworkStatus.Amount import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized @@ -222,6 +223,57 @@ internal class NetworkStatusFactoryTest(private val model: Model) { currencies.last().id to setOf(), ), source = StatusSource.ACTUAL, + yieldSupplyStatuses = mapOf(), + ), + ), + createSuccess( + result = updateWalletManagerResultFactory.createVerifiedWithToken(), + currencies = currencies, + status = NetworkStatus.Verified( + address = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x1", + type = NetworkAddress.Address.Type.Primary, + ), + ), + amounts = mapOf( + currencies.first().id to Amount.Loaded(BigDecimal.ONE), + currencies.last().id to Amount.NotFound, + ), + pendingTransactions = mapOf( + currencies.first().id to setOf(txInfo), + currencies.last().id to setOf(txInfo), + ), + source = StatusSource.ACTUAL, + yieldSupplyStatuses = mapOf(), + ), + ), + createSuccess( + result = updateWalletManagerResultFactory.createVerifiedWithSuppliedToken(), + currencies = currencies, + status = NetworkStatus.Verified( + address = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x1", + type = NetworkAddress.Address.Type.Primary, + ), + ), + amounts = mapOf( + currencies.first().id to Amount.Loaded(BigDecimal.ONE), + currencies.last().id to Amount.NotFound, + ), + pendingTransactions = mapOf( + currencies.first().id to setOf(txInfo), + currencies.last().id to setOf(txInfo), + ), + source = StatusSource.ACTUAL, + yieldSupplyStatuses = mapOf( + currencies.first().id to YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ), ), ), // endregion diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt index a4216c1fd9..ae8fe9d967 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt @@ -261,6 +261,49 @@ internal class DefaultOnrampRepository( storeOnrampPairs(pairs = onrampPairs.await(), providers = providers.await()) } + override suspend fun hasSepaMethod( + userWallet: UserWallet, + currency: OnrampCurrency, + country: OnrampCountry, + cryptoCurrency: CryptoCurrency, + ): Boolean { + return withContext(dispatchers.io) { + val onrampPairs = + safeApiCall( + call = { + onrampApi.getPairs( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + body = OnrampPairsRequest( + fromCurrencyCode = EUR_CURRENCY_CODE, + countryCode = country.code, + to = listOf( + OnrampDestinationDTO( + contractAddress = cryptoCurrency.getContractAddress(), + network = cryptoCurrency.network.backendId, + ), + ), + ), + ).bind() + }, + onError = { + Timber.w(it, "Unable to fetch onramp pairs") + throw it + }, + ) + + val hasSepaMethod = onrampPairs + .flatMap { it.providers } + .flatMap { it.paymentMethods } + .any { it == SEPA_METHOD_ID } + + hasSepaMethod + } + } + override suspend fun fetchQuotes(userWallet: UserWallet, cryptoCurrency: CryptoCurrency, amount: Amount) = withContext(dispatchers.io) { val pairs = requireNotNull(pairsStore.getSyncOrNull(PAIRS_KEY)) { @@ -554,5 +597,8 @@ internal class DefaultOnrampRepository( const val PROVIDER_THEME_LIGHT = "light" const val REDIRECT_URL = "https://tangem.com/onramp" + + const val SEPA_METHOD_ID = "sepa" + const val EUR_CURRENCY_CODE = "EUR" } } \ No newline at end of file diff --git a/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt b/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt index efdaae18b9..f58b3d0613 100644 --- a/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt +++ b/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt @@ -38,24 +38,19 @@ internal class DefaultPromoRepository( key = PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name), default = true, ).map { shouldShow -> - if (promoId == PromoId.Referral) { - runCatching { + when (promoId) { + PromoId.Referral -> runCatching { !referralRepository.isReferralParticipant(userWalletId) && shouldShow }.getOrDefault(false) - } else { - shouldShow + PromoId.Sepa -> shouldShow } } } override fun isReadyToShowTokenPromo(promoId: PromoId): Flow { - return if (promoId == PromoId.Referral) { - flowOf(false) - } else { - appPreferencesStore.get( - PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name), - default = false, - ) + return when (promoId) { + PromoId.Referral -> flowOf(false) + PromoId.Sepa -> flowOf(false) } } 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 index 4eac6b1927..618f75178d 100644 --- 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 @@ -8,7 +8,6 @@ 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.api.tangemTech.models.QuotesResponse import com.tangem.datasource.appcurrency.AppCurrencyResponseStore import com.tangem.domain.core.utils.catchOn import com.tangem.domain.models.currency.CryptoCurrency @@ -59,7 +58,6 @@ internal class DefaultMultiQuoteStatusFetcher @Inject constructor( fields = setOf(Field.PRICE, Field.PRICE_CHANGE_24H), ) .getOrElse { error("Cause: $it") } - .addSkippedIfHas(ids = replacementIdsResult.idsForRequest) val updatedResponse = QuotesUnsupportedCurrenciesIdAdapter.getResponseWithUnsupportedCurrencies( response = response, @@ -86,16 +84,4 @@ internal class DefaultMultiQuoteStatusFetcher @Inject constructor( return appCurrencyId } - - private fun QuotesResponse.addSkippedIfHas(ids: Set): QuotesResponse { - val skippedIds = ids.filterNot(quotes.keys::contains).ifEmpty { - return this - } - - Timber.d("Some quotes are missing from the server response: $skippedIds") - - val emptyQuotes = skippedIds.associateWith { QuotesResponse.Quote.EMPTY } - - return copy(quotes = quotes + emptyQuotes) - } } \ 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 index 4adc0ed55a..c02e49b1d8 100644 --- 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 @@ -1,5 +1,7 @@ package com.tangem.data.quotes.single +import arrow.core.Option +import arrow.core.some import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.quotes.single.SingleQuoteStatusProducer @@ -7,7 +9,10 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull /** * Default implementation of [SingleQuoteStatusProducer] @@ -21,11 +26,12 @@ internal class DefaultSingleQuoteStatusProducer @AssistedInject constructor( private val dispatchers: CoroutineDispatcherProvider, ) : SingleQuoteStatusProducer { - override val fallback: QuoteStatus = QuoteStatus(rawCurrencyId = params.rawCurrencyId) + private val default = QuoteStatus(rawCurrencyId = params.rawCurrencyId) + override val fallback: Option = default.some() override fun produce(): Flow { return quotesStatusesStore.get() - .mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } ?: fallback } + .mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } ?: default } .distinctUntilChanged() .flowOn(dispatchers.default) } diff --git a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt index e27fce119a..5412605960 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt @@ -29,15 +29,15 @@ internal class DefaultSettingsRepository( private val userCountryFlow = MutableStateFlow(value = null) - override suspend fun shouldShowSaveUserWalletScreen(): Boolean { + override suspend fun shouldShowAskBiometry(): Boolean { return appPreferencesStore.getSyncOrDefault( - key = PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY, + key = PreferencesKeys.SHOULD_SHOW_ASK_BIOMETRY_KEY, default = true, ) } - override suspend fun setShouldShowSaveUserWalletScreen(value: Boolean) { - appPreferencesStore.store(key = PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY, value = value) + override suspend fun setShouldShowAskBiometry(value: Boolean) { + appPreferencesStore.store(key = PreferencesKeys.SHOULD_SHOW_ASK_BIOMETRY_KEY, value = value) } override suspend fun isWalletScrollPreviewEnabled(): Boolean { diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducer.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducer.kt index 09e9f415de..f66ec71196 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducer.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducer.kt @@ -1,5 +1,7 @@ package com.tangem.data.staking.multi +import arrow.core.Option +import arrow.core.some import com.tangem.data.staking.store.YieldsBalancesStore import com.tangem.domain.models.staking.YieldBalance import com.tangem.domain.staking.multi.MultiYieldBalanceProducer @@ -27,8 +29,7 @@ internal class DefaultMultiYieldBalanceProducer @AssistedInject constructor( private val dispatchers: CoroutineDispatcherProvider, ) : MultiYieldBalanceProducer { - override val fallback: Set - get() = setOf() + override val fallback: Option> = emptySet().some() override fun produce(): Flow> { return yieldsBalancesStore.get(userWalletId = params.userWalletId) diff --git a/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducer.kt b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducer.kt index 9abbd5db14..3dccb55dfc 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducer.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducer.kt @@ -1,5 +1,7 @@ package com.tangem.data.staking.single +import arrow.core.Option +import arrow.core.some import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.domain.models.staking.YieldBalance @@ -34,9 +36,7 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor( private val dispatchers: CoroutineDispatcherProvider, ) : SingleYieldBalanceProducer { - override val fallback: YieldBalance by lazy { - YieldBalance.Error(stakingId = params.stakingId) - } + override val fallback: Option = YieldBalance.Error(stakingId = params.stakingId).some() override fun produce(): Flow { Timber.i("Producing yield balance for params:\n$params") diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt index a959276e85..dc71df52b3 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt @@ -1,5 +1,7 @@ package com.tangem.data.tokens +import arrow.core.Option +import arrow.core.some import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore @@ -31,8 +33,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constr private val dispatchers: CoroutineDispatcherProvider, ) : MultiWalletCryptoCurrenciesProducer { - override val fallback: Set - get() = emptySet() + override val fallback: Option> = emptySet().some() override fun produce(): Flow> { val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId) @@ -51,7 +52,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constr userWallet = userWallet, ).toSet() } - .onEmpty { emit(fallback) } + .onEmpty { emit(emptySet()) } .flowOn(dispatchers.default) } diff --git a/data/transaction/build.gradle.kts b/data/transaction/build.gradle.kts index 47cc934a90..fa74ec6c43 100644 --- a/data/transaction/build.gradle.kts +++ b/data/transaction/build.gradle.kts @@ -10,6 +10,10 @@ android { namespace = "com.tangem.data.transaction" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Tangem SDKs */ @@ -21,13 +25,14 @@ dependencies { implementation(projects.core.utils) /** Domain */ - implementation(projects.domain.transaction) + implementation(projects.libs.blockchainSdk) implementation(projects.domain.legacy) implementation(projects.domain.walletManager) - implementation(projects.libs.blockchainSdk) implementation(projects.domain.wallets.models) implementation(projects.domain.tokens.models) implementation(projects.domain.transaction.models) + implementation(projects.domain.transaction) + implementation(projects.domain.demo) /** DI */ implementation(deps.hilt.android) @@ -35,4 +40,12 @@ dependencies { /** Other */ implementation(deps.timber) + + /** tests */ + testImplementation(projects.common.test) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt index b018bc23c3..49b6e3c486 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt @@ -1,13 +1,57 @@ package com.tangem.data.transaction import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.extensions.Result import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.demo.DemoTransactionSender +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.walletmanager.WalletManagersFacade -internal class DefaultFeeRepository : FeeRepository { +internal class DefaultFeeRepository( + private val walletManagersFacade: WalletManagersFacade, + private val demoConfig: DemoConfig, +) : FeeRepository { override fun isFeeApproximate(networkId: Network.ID, amountType: AmountType): Boolean { return networkId.toBlockchain().isFeeApproximate(amountType) } + + override suspend fun calculateFee( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + transactionData: TransactionData, + ): TransactionFee { + val transactionSender = if (userWallet is UserWallet.Cold && + demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) + ) { + demoTransactionSender(userWallet, cryptoCurrency) + } else { + walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ) ?: error("WalletManager is null") + } + + return when (val result = transactionSender.getFee(transactionData)) { + is Result.Success -> result.data + is Result.Failure -> throw result.error + } + } + + private suspend fun demoTransactionSender( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + ): DemoTransactionSender { + return DemoTransactionSender( + walletManagersFacade + .getOrCreateWalletManager(userWallet.walletId, cryptoCurrency.network) + ?: error("WalletManager is null"), + ) + } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt index 6184d3fa48..267e449c57 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt @@ -3,10 +3,13 @@ package com.tangem.data.transaction.di import com.tangem.data.transaction.DefaultFeeRepository import com.tangem.data.transaction.DefaultTransactionRepository import com.tangem.data.transaction.DefaultWalletAddressServiceRepository +import com.tangem.data.transaction.error.DefaultFeeErrorResolver import com.tangem.datasource.local.walletmanager.WalletManagersStore +import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.WalletAddressServiceRepository +import com.tangem.domain.transaction.error.FeeErrorResolver import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -35,8 +38,11 @@ internal object TransactionDataModule { @Provides @Singleton - fun providesFeeRepository(): FeeRepository { - return DefaultFeeRepository() + fun providesFeeRepository(walletManagersFacade: WalletManagersFacade): FeeRepository { + return DefaultFeeRepository( + walletManagersFacade, + demoConfig = DemoConfig(), + ) } @Provides @@ -50,4 +56,10 @@ internal object TransactionDataModule { dispatchers = coroutineDispatcherProvider, ) } + + @Provides + @Singleton + fun providerFeeErrorResolver(): FeeErrorResolver { + return DefaultFeeErrorResolver() + } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt b/data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt new file mode 100644 index 0000000000..b1469917d7 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt @@ -0,0 +1,22 @@ +package com.tangem.data.transaction.error + +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.domain.transaction.error.FeeErrorResolver +import com.tangem.domain.transaction.error.GetFeeError + +internal class DefaultFeeErrorResolver : FeeErrorResolver { + override fun resolve(throwable: Throwable): GetFeeError { + return when (throwable) { + is BlockchainSdkError.Tron.AccountActivationError -> { + GetFeeError.BlockchainErrors.TronActivationError + } + is BlockchainSdkError.Kaspa.ZeroUtxoError -> { + GetFeeError.BlockchainErrors.KaspaZeroUtxo + } + is BlockchainSdkError.Sui.OneSuiRequired -> { + GetFeeError.BlockchainErrors.SuiOneCoinRequired + } + else -> GetFeeError.DataError(throwable) + } + } +} \ No newline at end of file diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 38d1d2957a..05adcdc660 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.appCurrency.models) implementation(projects.domain.tokens.models) + implementation(projects.domain.tokens) /** Project - Utils */ implementation(projects.core.utils) 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 deleted file mode 100644 index cc1a69895e..0000000000 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultKycRepository.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.tangem.data.pay - -import arrow.core.Either -import com.squareup.moshi.Moshi -import com.tangem.common.map -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.visa.model.VisaDataForApprove -import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet -import com.tangem.domain.visa.repository.VisaAuthRepository -import com.tangem.sdk.api.TangemSdkManager -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -class DefaultKycRepository @AssistedInject constructor( - @NetworkMoshi moshi: Moshi, - private val tangemPayApi: TangemPayApi, - private val visaAuthRepository: VisaAuthRepository, - private val tangemSdkManager: TangemSdkManager, -) : KycRepository { - - private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi) - - override suspend fun getKycStartInfo(address: String, cardId: String): Either { - var authHeader = "" - visaAuthRepository.getCustomerWalletAuthChallenge(address).getOrNull()?.let { result -> - tangemSdkManager.visaCustomerWalletApprove( - VisaDataForApprove( - customerWalletCardId = cardId, - targetAddress = address, - dataToSign = VisaDataToSignByCustomerWallet(hashToSign = result.challenge), - ), - ).map { signResult -> - visaAuthRepository.getTokenWithCustomerWallet( - sessionId = result.session.sessionId, - signature = signResult.signature, - nonce = signResult.dataToSign.hashToSign, - ).getOrNull()?.let { authHeader = it } - } - } - return request { - authHeader.ifEmpty { error("Cannot get auth header for KYC") } - tangemPayApi.getKycAccess(authHeader = authHeader).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(): DefaultKycRepository - } -} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt new file mode 100644 index 0000000000..450fc7c70d --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt @@ -0,0 +1,55 @@ +package com.tangem.data.pay.datasource + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.common.CompletionResult +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource +import com.tangem.domain.visa.model.VisaDataForApprove +import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource +import com.tangem.domain.visa.model.VisaAuthTokens +import com.tangem.sdk.api.TangemSdkManager +import javax.inject.Inject + +internal class DefaultTangemPayAuthDataSource @Inject constructor( + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, + private val tangemSdkManager: TangemSdkManager, +) : TangemPayAuthDataSource { + + override suspend fun generateNewAuthTokens(address: String, cardId: String): Either = + either { + val challenge = visaAuthRemoteDataSource + .getCustomerWalletAuthChallenge(address) + .mapLeft { IllegalStateException("TangemPay challenge failed. Error code: ${it.errorCode}") } + .bind() + + val signed = tangemSdkManager.visaCustomerWalletApprove( + VisaDataForApprove( + customerWalletCardId = cardId, + targetAddress = address, + dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge), + ), + ).toEither { IllegalStateException("TangemPay signing failed: $it") }.bind() + + visaAuthRemoteDataSource.getTokenWithCustomerWallet( + sessionId = challenge.session.sessionId, + signature = signed.signature, + nonce = signed.dataToSign.hashToSign, + ) + .mapLeft { IllegalStateException("TangemPay token fetch failed. Error code: ${it.errorCode}") } + .bind() + } + + override suspend fun refreshAuthTokens(refreshToken: String): Either = either { + visaAuthRemoteDataSource.refreshCustomerWalletAuthTokens( + VisaAuthTokens.RefreshToken(refreshToken, authType = VisaAuthTokens.RefreshToken.Type.CardWallet), + ) + .mapLeft { IllegalStateException("TangemPay token refresh failed. Error code: ${it.errorCode}") } + .bind() + } +} + +private fun CompletionResult.toEither(map: (Throwable) -> Throwable) = when (this) { + is CompletionResult.Success -> Either.Right(data) + is CompletionResult.Failure -> Either.Left(map(error)) +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index d0de7d5c31..c2d3ece8ad 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -1,7 +1,9 @@ package com.tangem.data.pay.di -import com.tangem.data.pay.DefaultKycRepository +import com.tangem.data.pay.repository.DefaultKycRepository +import com.tangem.data.pay.repository.DefaultOnboardingRepository import com.tangem.domain.pay.repository.KycRepository +import com.tangem.domain.pay.repository.OnboardingRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -14,5 +16,9 @@ internal interface TangemPayDataModule { @Binds @Singleton - fun bindKycRepositoryFactory(factory: DefaultKycRepository.Factory): KycRepository.Factory + fun bindKycRepository(repository: DefaultKycRepository): KycRepository + + @Binds + @Singleton + fun bindOnboardingRepository(repository: DefaultOnboardingRepository): OnboardingRepository } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt new file mode 100644 index 0000000000..0230e63d63 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt @@ -0,0 +1,24 @@ +package com.tangem.data.pay.repository + +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.domain.pay.KycStartInfo +import com.tangem.domain.pay.repository.KycRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal class DefaultKycRepository @Inject constructor( + private val tangemPayApi: TangemPayApi, + private val dispatchers: CoroutineDispatcherProvider, + private val requestHelper: TangemPayRequestPerformer, +) : KycRepository { + + override suspend fun getKycStartInfo() = withContext(dispatchers.io) { + requestHelper.request { authHeader -> + tangemPayApi.getKycAccess(authHeader = authHeader).getOrThrow().result + }.map { + KycStartInfo(token = it.token, locale = it.locale) + } + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt new file mode 100644 index 0000000000..24eb2cbaa8 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -0,0 +1,40 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.core.error.UniversalError +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest +import com.tangem.domain.pay.model.CustomerInfo +import com.tangem.domain.pay.model.ProductInstance +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.visa.error.VisaApiError +import javax.inject.Inject + +private const val VALID_STATUS = "valid" + +internal class DefaultOnboardingRepository @Inject constructor( + private val tangemPayApi: TangemPayApi, + private val requestHelper: TangemPayRequestPerformer, +) : OnboardingRepository { + + override suspend fun validateDeeplink(link: String): Either = either { + return requestHelper.request { + tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link)).getOrThrow().result + ?: raise(VisaApiError.UnknownWithoutCode) + }.map { result -> result.status == VALID_STATUS } + } + + override suspend fun getCustomerInfo(): Either = either { + return requestHelper.request { authHeader -> + val response = tangemPayApi.getCustomerMe(authHeader).getOrThrow() + response.result ?: raise(VisaApiError.UnknownWithoutCode) + }.map { result -> + CustomerInfo( + productInstance = result.productInstance?.let { ProductInstance(id = it.id, status = it.status) }, + kycStatus = result.kyc?.status, + ) + } + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt new file mode 100644 index 0000000000..943480ef0e --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt @@ -0,0 +1,159 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import arrow.core.raise.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.pay.models.response.VisaErrorResponseJsonAdapter +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.core.wallets.UserWalletsListRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.domain.visa.model.VisaAuthTokens +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * For TangemPay Customer Wallet auth we are using polygon address + */ +private const val POL_VALUE = "coin⟨POLYGON⟩polygon-ecosystem-token" + +internal class TangemPayRequestPerformer @Inject constructor( + @NetworkMoshi moshi: Moshi, + private val dispatchers: CoroutineDispatcherProvider, + private val tangemPayStorage: TangemPayStorage, + private val userWalletsRepository: UserWalletsListRepository, + private val getCurrencyUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val authDataSource: TangemPayAuthDataSource, +) { + private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi) + + private var customerWalletAddress: String? = null + + private val refreshTokensMutex = Mutex() + private var refreshTokensJob: Deferred>? = null + + suspend fun request(requestBlock: suspend (header: String) -> T): Either = either { + withContext(dispatchers.io) { + performRequest(requestBlock = requestBlock, refreshTokens = ::refreshAuthTokens).bind() + } + } + + private suspend fun performRequest( + requestBlock: suspend (header: String) -> T, + refreshTokens: (suspend () -> Either)? = null, + ): Either = either { + runCatching { + requestBlock("Bearer ${getAccessTokens().bind().accessToken}") + }.getOrElse { error -> + when (error) { + is ApiResponseError.HttpException -> { + if (refreshTokens != null && error.code == ApiResponseError.HttpException.Code.UNAUTHORIZED) { + refreshOrJoin(refreshTokens).bind() + performRequest(requestBlock, refreshTokens = null).bind() + } else { + raise(mapHttpError(error)) + } + } + else -> raise(VisaApiError.UnknownWithoutCode) + } + } + } + + private suspend fun refreshOrJoin( + refreshTokens: suspend () -> Either, + ): Either { + val jobToAwait: Deferred> = + refreshTokensMutex.withLock { + val current = refreshTokensJob + if (current == null || current.isCompleted) { + coroutineScope { + async { refreshTokens() }.also { refreshTokensJob = it } + } + } else { + current + } + } + val result = try { + jobToAwait.await() + } catch (ignore: Throwable) { + Either.Left(VisaApiError.UnknownWithoutCode) + } finally { + refreshTokensMutex.withLock { + if (refreshTokensJob === jobToAwait && jobToAwait.isCompleted) { + refreshTokensJob = null + } + } + } + return result + } + + private suspend fun getCustomerWalletAddress(): Either = either { + customerWalletAddress + ?: tangemPayStorage.getCustomerWalletAddress() + ?: fetchAuthInputData().bind().address + } + + private suspend fun getAccessTokens(): Either = either { + val address = getCustomerWalletAddress().bind() + tangemPayStorage.getAuthTokens(address) ?: fetchTokens().bind() + } + + private fun mapHttpError(throwable: ApiResponseError.HttpException): UniversalError { + val errorBody = throwable.errorBody ?: return VisaApiError.UnknownWithoutCode + return runCatching { + visaErrorAdapter.fromJson(errorBody)?.error?.code ?: throwable.code.numericCode + }.map { + VisaApiError.fromBackendError(it) + }.getOrElse { + VisaApiError.UnknownWithoutCode + } + } + + private suspend fun fetchAuthInputData(): Either = either { + val wallet = userWalletsRepository.userWalletsSync().find { it is UserWallet.Cold } as? UserWallet.Cold + ?: raise(VisaApiError.UnknownWithoutCode) + + val address = getCurrencyUseCase.invokeMultiWalletSync(wallet.walletId, CryptoCurrency.ID.fromValue(POL_VALUE)) + .getOrNull()?.value?.networkAddress?.defaultAddress?.value ?: raise(VisaApiError.UnknownWithoutCode) + + customerWalletAddress = address + tangemPayStorage.storeCustomerWalletAddress(address) + + AuthInputData(address, wallet.cardId) + } + + private suspend fun fetchTokens(): Either = either { + val inputData = fetchAuthInputData().bind() + val tokens = authDataSource.generateNewAuthTokens(inputData.address, inputData.cardId) + .getOrNull() + ?: return Either.Left(VisaApiError.UnknownWithoutCode) + tangemPayStorage.storeAuthTokens(inputData.address, tokens) + tokens + } + + private suspend fun refreshAuthTokens(): Either = either { + val customerWalletAddress = getCustomerWalletAddress().bind() + val refreshToken = getAccessTokens().bind().refreshToken.value + val tokens = authDataSource.refreshAuthTokens(refreshToken).getOrNull() + ?: raise(VisaApiError.UnknownWithoutCode) + tangemPayStorage.storeAuthTokens(customerWalletAddress, tokens) + tokens + } +} + +internal data class AuthInputData( + val address: String, + val cardId: String, +) \ 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 b38b9a82a8..939794afce 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 @@ -18,7 +18,7 @@ import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.model.* import com.tangem.domain.visa.repository.VisaActivationRepository -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -32,7 +32,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor( private val visaApi: TangemPayApi, private val dispatcherProvider: CoroutineDispatcherProvider, private val visaAuthTokenStorage: VisaAuthTokenStorage, - private val visaAuthRepository: VisaAuthRepository, + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, private val visaLibLoader: VisaLibLoader, private val apiConfigsManager: ApiConfigsManager, ) : VisaActivationRepository { @@ -194,7 +194,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor( } val authTokens = visaAuthTokenStorage.get(visaCardId.cardId) ?: error("Auth tokens are not stored") - val newTokens = visaAuthRepository.refreshAccessTokens(authTokens.refreshToken).getOrElse { + val newTokens = visaAuthRemoteDataSource.refreshAccessTokens(authTokens.refreshToken).getOrElse { return Either.Left(VisaApiError.RefreshTokenExpired) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt similarity index 80% rename from data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt rename to data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt index 8b3024f1fe..baadf36595 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt @@ -13,17 +13,16 @@ import com.tangem.domain.visa.model.VisaAuthChallenge import com.tangem.domain.visa.model.VisaAuthSession import com.tangem.domain.visa.model.VisaAuthSignedChallenge import com.tangem.domain.visa.model.VisaAuthTokens -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext import javax.inject.Inject -@Suppress("UnusedPrivateMember") -internal class DefaultVisaAuthRepository @Inject constructor( +internal class DefaultVisaAuthRemoteDataSource @Inject constructor( @NetworkMoshi private val moshi: Moshi, private val visaAuthApi: TangemPayApi, private val dispatchers: CoroutineDispatcherProvider, -) : VisaAuthRepository { +) : VisaAuthRemoteDataSource { private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi) @@ -84,7 +83,7 @@ internal class DefaultVisaAuthRepository @Inject constructor( sessionId: String, signature: String, nonce: String, - ): Either = withContext(dispatchers.io) { + ): Either = withContext(dispatchers.io) { request { visaAuthApi.getTokenByCustomerWallet( GetTokenByCustomerWalletRequest( @@ -94,7 +93,28 @@ internal class DefaultVisaAuthRepository @Inject constructor( ), ).getOrThrow() }.map { response -> - "Bearer ${response.result.accessToken}" + VisaAuthTokens( + response.result.accessToken, + VisaAuthTokens.RefreshToken( + response.result.refreshToken, + VisaAuthTokens.RefreshToken.Type.CardWallet, + ), + ) + } + } + + override suspend fun refreshCustomerWalletAuthTokens( + refreshToken: VisaAuthTokens.RefreshToken, + ): Either = withContext(dispatchers.io) { + request { + visaAuthApi.refreshCustomerWalletAccessToken( + RefreshCustomerWalletAccessTokenRequest(refreshToken = refreshToken.value), + ).getOrThrow() + }.map { response -> + VisaAuthTokens( + accessToken = response.result.accessToken, + refreshToken = refreshToken.copy(value = response.result.refreshToken), + ) } } @@ -158,24 +178,25 @@ internal class DefaultVisaAuthRepository @Inject constructor( } } - override suspend fun exchangeAccessToken(tokens: VisaAuthTokens): Either { - return request { - visaAuthApi.exchangeAccessToken( - ExchangeAccessTokenRequest( - accessToken = tokens.accessToken, - refreshToken = tokens.refreshToken.value, - ), - ).getOrThrow() - }.map { response -> - VisaAuthTokens( - accessToken = response.result.accessToken, - refreshToken = VisaAuthTokens.RefreshToken( - value = response.result.refreshToken, - authType = VisaAuthTokens.RefreshToken.Type.CardWallet, - ), - ) + override suspend fun exchangeAccessToken(tokens: VisaAuthTokens): Either = + withContext(dispatchers.io) { + request { + visaAuthApi.exchangeAccessToken( + ExchangeAccessTokenRequest( + accessToken = tokens.accessToken, + refreshToken = tokens.refreshToken.value, + ), + ).getOrThrow() + }.map { response -> + VisaAuthTokens( + accessToken = response.result.accessToken, + refreshToken = VisaAuthTokens.RefreshToken( + value = response.result.refreshToken, + authType = VisaAuthTokens.RefreshToken.Type.CardWallet, + ), + ) + } } - } private suspend fun request(requestBlock: suspend () -> T): Either { return runCatching { diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/di/VisaDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/di/VisaDataModule.kt index a173f8c5ea..a5efc3d025 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/di/VisaDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/di/VisaDataModule.kt @@ -1,10 +1,12 @@ package com.tangem.data.visa.di +import com.tangem.data.pay.datasource.DefaultTangemPayAuthDataSource import com.tangem.data.visa.DefaultVisaActivationRepository -import com.tangem.data.visa.DefaultVisaAuthRepository +import com.tangem.data.visa.DefaultVisaAuthRemoteDataSource import com.tangem.data.visa.MockVisaRepository +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.visa.repository.VisaActivationRepository -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.domain.visa.repository.VisaRepository import dagger.Binds import dagger.Module @@ -18,7 +20,7 @@ internal interface VisaDataModule { @Binds @Singleton - fun bindVisaAuthRepository(repository: DefaultVisaAuthRepository): VisaAuthRepository + fun bindVisaAuthRemoteDataSource(repository: DefaultVisaAuthRemoteDataSource): VisaAuthRemoteDataSource @Binds @Singleton @@ -39,4 +41,8 @@ internal interface VisaDataModule { // Mocked @Binds fun bindVisaRepository(repository: MockVisaRepository): VisaRepository + + @Binds + @Singleton + fun bindTangemPayAuthDataSource(repository: DefaultTangemPayAuthDataSource): TangemPayAuthDataSource } \ No newline at end of file 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 0ee7dab28e..780142a298 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 @@ -17,16 +17,14 @@ import com.tangem.data.walletconnect.respond.WcRespondService import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager import com.tangem.data.walletconnect.utils.WcNamespaceConverter import com.tangem.data.walletconnect.utils.WcNetworksConverter +import com.tangem.data.walletconnect.utils.WcScope import com.tangem.datasource.di.SdkMoshi import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.local.walletconnect.WalletConnectStore import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.WcRequestService import com.tangem.domain.walletconnect.WcRequestUseCaseFactory -import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository import com.tangem.domain.walletconnect.repository.WalletConnectRepository import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.walletconnect.usecase.disconnect.WcDisconnectUseCase @@ -39,8 +37,6 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -83,27 +79,28 @@ internal object WalletConnectDataModule { @Provides @Singleton - fun sdkDelegate(): WcPairSdkDelegate = WcPairSdkDelegate() + fun sdkDelegate(wcScope: WcScope, store: WalletConnectStore): WcPairSdkDelegate = WcPairSdkDelegate( + scope = wcScope, + store = store, + ) @Provides @Singleton fun defaultWcSessionsManager( store: WalletConnectStore, dispatchers: CoroutineDispatcherProvider, - legacyStore: WalletConnectSessionsRepository, getWallets: GetWalletsUseCase, wcNetworksConverter: WcNetworksConverter, analytics: AnalyticsEventHandler, + wcScope: WcScope, ): DefaultWcSessionsManager { - val scope = CoroutineScope(SupervisorJob() + dispatchers.io) return DefaultWcSessionsManager( store = store, dispatchers = dispatchers, - legacyStore = legacyStore, getWallets = getWallets, wcNetworksConverter = wcNetworksConverter, analytics = analytics, - scope = scope, + scope = wcScope, ) } @@ -111,6 +108,10 @@ internal object WalletConnectDataModule { @Singleton fun wcSessionsManager(default: DefaultWcSessionsManager): WcSessionsManager = default + @Provides + @Singleton + fun wcScope(dispatchers: CoroutineDispatcherProvider): WcScope = WcScope(dispatchers) + @Provides @Singleton fun wcRequestService(default: DefaultWcRequestService): WcRequestService = default @@ -177,15 +178,11 @@ internal object WalletConnectDataModule { fun wcNetworksConverter( namespaceConverters: Set<@JvmSuppressWildcards WcNamespaceConverter>, walletManagersFacade: WalletManagersFacade, - currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): WcNetworksConverter = WcNetworksConverter( namespaceConverters = namespaceConverters, walletManagersFacade = walletManagersFacade, - currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) @Provides @@ -193,15 +190,11 @@ internal object WalletConnectDataModule { fun associateNetworksDelegate( namespaceConverters: Set<@JvmSuppressWildcards WcNamespaceConverter>, getWallets: GetWalletsUseCase, - currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): AssociateNetworksDelegate = AssociateNetworksDelegate( namespaceConverters = namespaceConverters, getWallets = getWallets, - currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) @Provides 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 e82c497072..165fe556d9 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 @@ -74,12 +74,7 @@ internal class WcEthSendTransactionUseCase @AssistedInject constructor( logoUrl = tokenInfo.logoUrl, chainId = tokenInfo.chainId, ) - BlockAidTransactionCheck.Result.Approval( - result = result, - approval = this, - tokenInfo = tokenInfo, - isMutable = true, - ) + BlockAidTransactionCheck.Result.Approval(result = result) } } 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 8cfe678df0..e71f37533d 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 @@ -68,12 +68,7 @@ internal class WcEthSignTransactionUseCase @AssistedInject constructor( chainId = tokenInfo.chainId, ) } - BlockAidTransactionCheck.Result.Approval( - result = result, - approval = this, - tokenInfo = tokenInfo, - isMutable = true, - ) + BlockAidTransactionCheck.Result.Approval(result = result) } } diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaSignTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaSignTransactionUseCase.kt index c1d301135a..2a3d2c624f 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaSignTransactionUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaSignTransactionUseCase.kt @@ -1,6 +1,7 @@ package com.tangem.data.walletconnect.network.solana import arrow.core.left +import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.extensions.encodeBase58 import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -10,10 +11,13 @@ 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.models.wallet.UserWallet import com.tangem.domain.transaction.usecase.PrepareAndSignUseCase +import com.tangem.domain.transaction.usecase.SendLargeSolanaTransactionUseCase import com.tangem.domain.walletconnect.error.parseSendError import com.tangem.domain.walletconnect.model.WcSolanaMethod import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck +import com.tangem.domain.walletconnect.usecase.method.SignRequirements import com.tangem.domain.walletconnect.usecase.method.WcSignState import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase import dagger.assisted.Assisted @@ -22,18 +26,21 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import okio.ByteString.Companion.decodeBase64 +import timber.log.Timber @Suppress("LongParameterList") internal class WcSolanaSignTransactionUseCase @AssistedInject constructor( override val respondService: WcRespondService, override val analytics: AnalyticsEventHandler, private val prepareAndSign: PrepareAndSignUseCase, + private val sendLargeSolanaTransactionUseCase: SendLargeSolanaTransactionUseCase, @Assisted override val context: WcMethodUseCaseContext, @Assisted override val method: WcSolanaMethod.SignTransaction, blockAidDelegate: BlockAidVerificationDelegate, addressConverter: SolanaBlockAidAddressConverter, ) : BaseWcSignUseCase(), - WcTransactionUseCase { + WcTransactionUseCase, + SignRequirements { override val securityStatus = blockAidDelegate.getSecurityStatus( network = network, @@ -44,15 +51,35 @@ internal class WcSolanaSignTransactionUseCase @AssistedInject constructor( ).map { lce -> lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) } } override suspend fun SignCollector.onSign(state: WcSignState) { - val hash = prepareAndSign.invoke(transactionData = state.signModel, userWallet = wallet, network = network) - .onLeft { error -> - emit(state.toResult(parseSendError(error).left())) - } - .getOrNull() - ?: return - val respond = "{ signature: \"${hash.encodeBase58()}\" }" - val respondResult = respondService.respond(rawSdkRequest, respond) - emit(state.toResult(respondResult)) + val hash = state.signModel.getTxHashFromCompiled() + val formattedHash = getFormattedHash(hash) // uses for flow sendLargeSolanaTransaction + if (context.session.wallet is UserWallet.Cold && isLargeHash(formattedHash)) { + // workaround for large transactions that cannot be signed directly by card + Timber.w("The transaction hash is too large to be signed directly: ${formattedHash.size} bytes") + sendLargeSolanaTransactionUseCase(context.session.wallet as UserWallet.Cold, context.network, formattedHash) + .fold( + ifLeft = { + Timber.e(it.toString()) + emit(state.toResult(parseSendError(it).left())) + }, + ifRight = { + val emptyRespond = ByteArray(0).formatAsSolanaSignature() + val respondResult = respondService.respond(rawSdkRequest, emptyRespond) + emit(state.toResult(respondResult)) + }, + ) + } else { + val signedHash = + prepareAndSign.invoke(transactionData = state.signModel, userWallet = wallet, network = network) + .onLeft { error -> + emit(state.toResult(parseSendError(error).left())) + } + .getOrNull() + ?: return + val respond = signedHash.formatAsSolanaSignature() + val respondResult = respondService.respond(rawSdkRequest, respond) + emit(state.toResult(respondResult)) + } } override fun invoke(): Flow> { @@ -64,6 +91,40 @@ internal class WcSolanaSignTransactionUseCase @AssistedInject constructor( return delegate.invoke(transactionData) } + private fun ByteArray.formatAsSolanaSignature(): String { + return "{ signature: \"${this.encodeBase58()}\" }" + } + + private fun TransactionData.getTxHashFromCompiled(): ByteArray { + return when (this) { + is TransactionData.Compiled -> (value as? TransactionData.Compiled.Data.Bytes)?.data + ?: error("Invalid transaction data") + is TransactionData.Uncompiled -> error("Transaction must be compiled") + } + } + + private fun isLargeHash(hash: ByteArray): Boolean { + return hash.size > LARGE_HASH_SIZE + } + + private fun getFormattedHash(hash: ByteArray): ByteArray { + return try { + SolanaTransactionHelper.removeSignaturesPlaceholders(hash) + } catch (e: Exception) { + Timber.e("Failed to format the hash: ${e.message}") + hash + } + } + + override fun isMultipleSignRequired(): Boolean { + val data = method.transaction.decodeBase64()?.toByteArray() ?: ByteArray(0) + return isLargeHash(data) + } + + private companion object { + private const val LARGE_HASH_SIZE = 930 // bytes + } + @AssistedFactory interface Factory { fun create( 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 d0f06c74b2..705efb5df5 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 @@ -10,8 +10,6 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletconnect.model.WcPairError import com.tangem.domain.walletconnect.model.WcSessionProposal.ProposalNetwork import com.tangem.domain.wallets.usecase.GetWalletsUseCase @@ -19,9 +17,7 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase internal class AssociateNetworksDelegate( private val namespaceConverters: Set, private val getWallets: GetWalletsUseCase, - private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { @Throws(WcPairError.UnsupportedBlockchains::class) @@ -96,14 +92,10 @@ internal class AssociateNetworksDelegate( } private suspend fun getWalletNetworks(userWalletId: UserWalletId): List { - return if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId), + ) + .orEmpty() .filterIsInstance() .map(CryptoCurrency.Coin::network) // flatten all derivation 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 2383bb2e9d..529f7a9302 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 @@ -9,13 +9,11 @@ import com.domain.blockaid.models.dapp.DAppData import com.reown.walletkit.client.Wallet import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.walletconnect.utils.WC_TAG -import com.tangem.data.walletconnect.utils.WcSdkSessionConverter import com.tangem.data.walletconnect.utils.getDappOriginUrl import com.tangem.domain.blockaid.BlockAidVerifier import com.tangem.domain.walletconnect.WcAnalyticEvents import com.tangem.domain.walletconnect.model.* import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData -import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.walletconnect.usecase.pair.WcPairState import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase import dagger.assisted.Assisted @@ -25,12 +23,12 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import org.joda.time.DateTime +import org.joda.time.Duration import timber.log.Timber import java.net.URI @Suppress("LongParameterList") internal class DefaultWcPairUseCase @AssistedInject constructor( - private val sessionsManager: WcSessionsManager, private val associateNetworksDelegate: AssociateNetworksDelegate, private val caipNamespaceDelegate: CaipNamespaceDelegate, private val sdkDelegate: WcPairSdkDelegate, @@ -105,24 +103,29 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( // start flow of approving in wc sdk emit(WcPairState.Approving.Loading(sessionForApprove)) + + val connectingTime = DateTime.now().millis + val expiredTime = connectingTime + Duration + .standardMinutes(PENDING_SESSION_EXPIRED_DURATION_MIN) + .millis + val sessionDTO = WcSessionDTO( + topic = "", + walletId = sessionForApprove.wallet.walletId, + url = sdkVerifyContext.getDappOriginUrl(), + securityStatus = proposalState.dAppSession.securityStatus, + connectingTime = connectingTime, + ) + val pendingSessionForSave = WcPendingApprovalSessionDTO( + pairingTopic = sdkSessionProposal.pairingTopic, + session = sessionDTO, + expiredTime = expiredTime, + ) + val either = walletKitApproveSession( + pendingSessionForSave = pendingSessionForSave, sessionForApprove = sessionForApprove, sdkSessionProposal = sdkSessionProposal, ).map { settledSession -> - val newSession = WcSession( - wallet = sessionForApprove.wallet, - sdkModel = WcSdkSessionConverter.convert( - value = WcSdkSessionConverter.Input( - originUrl = sdkVerifyContext.getDappOriginUrl(), - session = settledSession.session, - ), - ), - securityStatus = proposalState.dAppSession.securityStatus, - networks = sessionForApprove.network.toSet(), - connectingTime = DateTime.now().millis, - showWalletInfo = proposalState.dAppSession.proposalNetwork.keys.size > 1, - ) - sessionsManager.saveSession(newSession) analytics.send( WcAnalyticEvents.DAppConnected( sessionProposal = proposalState.dAppSession, @@ -130,7 +133,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( securityStatus = proposalState.dAppSession.securityStatus, ), ) - newSession + proposalState.dAppSession.dAppMetaData }.onLeft { analytics.send( WcAnalyticEvents.DAppConnectionFailed( @@ -170,9 +173,10 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( } private suspend fun walletKitApproveSession( + pendingSessionForSave: WcPendingApprovalSessionDTO, sessionForApprove: WcSessionApprove, sdkSessionProposal: Wallet.Model.SessionProposal, - ): Either = try { + ): Either = try { val namespaces = caipNamespaceDelegate.associate( sdkSessionProposal, sessionForApprove, @@ -181,7 +185,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( proposerPublicKey = sdkSessionProposal.proposerPublicKey, namespaces = namespaces, ) - sdkDelegate.approve(sessionApprove) + sdkDelegate.approve(pendingSessionForSave, sessionApprove) } catch (e: Throwable) { Timber.tag(WC_TAG).e(e, "Failed to sdk approve session $pairRequest") WcPairError.ApprovalFailed(e.message.orEmpty()).left() @@ -233,6 +237,10 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( }, ) + private companion object { + const val PENDING_SESSION_EXPIRED_DURATION_MIN = 15L + } + private sealed interface TerminalAction { data class Approve(val sessionForApprove: WcSessionApprove) : TerminalAction data object Reject : TerminalAction 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 2e71915a53..a9d9030333 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 @@ -6,25 +6,45 @@ import arrow.core.right import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.data.walletconnect.utils.WcScope import com.tangem.data.walletconnect.utils.WcSdkObserver +import com.tangem.datasource.local.walletconnect.WalletConnectStore import com.tangem.data.walletconnect.utils.getDappOriginUrl import com.tangem.domain.walletconnect.model.WcPairError import com.tangem.domain.walletconnect.model.WcPairError.ApprovalFailed +import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.* import timber.log.Timber import kotlin.coroutines.resume import kotlin.time.Duration.Companion.seconds -internal class WcPairSdkDelegate : WcSdkObserver { +internal class WcPairSdkDelegate( + private val scope: WcScope, + private val store: WalletConnectStore, +) : WcSdkObserver { private val onSessionProposal = Channel>() private val onSdkErrorCallback = Channel() - private val onSessionSettleResponse = Channel() + private val onSessionSettleCallback = Channel() + + init { + onSessionSettleCallback.receiveAsFlow() + .filterIsInstance() + .buffer() + .onEach { settledResponse -> + val savedPending = store.pendingApproval.first() + val settledSession = settledResponse.session + val savedPendingSession = savedPending + .find { it.pairingTopic == settledSession.pairingTopic } + ?: return@onEach + store.saveSession(savedPendingSession.session.copy(topic = settledSession.topic)) + store.removePendingApproval(setOf(savedPendingSession)) + } + .launchIn(scope) + } suspend fun pair( url: String, @@ -56,43 +76,20 @@ internal class WcPairSdkDelegate : WcSdkObserver { }.first() suspend fun approve( + pendingSessionForSave: WcPendingApprovalSessionDTO, sessionApprove: Wallet.Params.SessionApprove, - ): Either = coroutineScope { - val approveCallback = async { withTimeout(CALLBACK_TIMEOUT.seconds) { approveCallback() } } - val approveCall = async { sdkApprove(sessionApprove) } - approveCall.await() - .onLeft { - approveCallback.cancel() - return@coroutineScope it.left() - } - return@coroutineScope approveCallback.await().fold( - ifLeft = { it.left() }, - ifRight = { result -> - when (result) { - is Wallet.Model.SettledSessionResponse.Result -> result.right() - is Wallet.Model.SettledSessionResponse.Error -> ApprovalFailed(result.errorMessage).left() - } + ): Either = coroutineScope { + val forSave = setOf(pendingSessionForSave) + store.savePendingApproval(forSave) + sdkApprove(sessionApprove).fold( + ifRight = { Unit.right() }, + ifLeft = { + store.removePendingApproval(forSave) + it.left() }, ) } - private suspend fun approveCallback() = callbackFlow> { - // wait first onSessionSettleResponse callback - launch { - val settledSessionResponse = onSessionSettleResponse.receiveAsFlow().first() - trySend(settledSessionResponse.right()) - channel.close() - } - // OR - // wait first onError callback - launch { - val error = onSdkErrorCallback.receiveAsFlow().first() - trySend(error.throwable.toApproveError()) - channel.close() - } - awaitClose() - }.first() - fun rejectSession(proposerPublicKey: String) { Timber.tag(WC_TAG).i("reject session proposerPublicKey = $proposerPublicKey") WalletKit.rejectSession( @@ -120,7 +117,7 @@ internal class WcPairSdkDelegate : WcSdkObserver { override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { // Triggered when wallet receives the session settlement response from Dapp - onSessionSettleResponse.trySend(settleSessionResponse) + onSessionSettleCallback.trySend(settleSessionResponse) } private suspend fun sdkApprove(sessionApprove: Wallet.Params.SessionApprove): Either { 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 69aa56551c..6786e8a318 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 @@ -3,54 +3,44 @@ package com.tangem.data.walletconnect.sessions import arrow.core.Either import arrow.core.left import arrow.core.right -import com.domain.blockaid.models.dapp.CheckDAppResult import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.data.walletconnect.utils.WC_TAG -import com.tangem.data.walletconnect.utils.WcNetworksConverter -import com.tangem.data.walletconnect.utils.WcSdkObserver -import com.tangem.data.walletconnect.utils.WcSdkSessionConverter +import com.tangem.data.walletconnect.utils.* import com.tangem.datasource.local.walletconnect.WalletConnectStore import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.walletconnect.WcAnalyticEvents import com.tangem.domain.walletconnect.model.WcSession import com.tangem.domain.walletconnect.model.WcSessionDTO -import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* -import org.joda.time.DateTime +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.coroutines.resume @Suppress("LongParameterList") internal class DefaultWcSessionsManager( private val store: WalletConnectStore, - private val legacyStore: WalletConnectSessionsRepository, private val getWallets: GetWalletsUseCase, private val dispatchers: CoroutineDispatcherProvider, private val wcNetworksConverter: WcNetworksConverter, private val analytics: AnalyticsEventHandler, - private val scope: CoroutineScope, + private val scope: WcScope, ) : WcSessionsManager, WcSdkObserver { private val onSessionDelete = Channel(capacity = Channel.BUFFERED) - private val oneTimeMigration = MutableStateFlow(true) override val sessions: Flow>> get() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore } .transform { pair -> val (wallets, inStore) = pair val inSdk: List = WalletKit.getListOfActiveSessions() - if (oneTimeMigration.value) { - oneTimeMigration.value = false - val someMigrated = migrateLegacyStore(inStore, inSdk, wallets) - if (someMigrated) return@transform // ignore emit, wait next one - } val associatedSessions: List = associate(inSdk, inStore, wallets) val someRemove = removeUnknownSessions(inStore, inSdk, associatedSessions) if (someRemove) return@transform // ignore emit, wait next one @@ -60,23 +50,10 @@ internal class DefaultWcSessionsManager( .flowOn(dispatchers.io) override fun onWcSdkInit() { - oneTimeMigration.value = true listenOnSessionDelete() extendSessions() } - override suspend fun saveSession(session: WcSession) { - store.saveSession( - WcSessionDTO( - topic = session.sdkModel.topic, - walletId = session.wallet.walletId, - url = session.sdkModel.appMetaData.url, - securityStatus = session.securityStatus, - connectingTime = session.connectingTime ?: DateTime.now().millis, - ), - ) - } - override suspend fun removeSession(session: WcSession): Either { val topic = session.sdkModel.topic val sdkCall = sdkDisconnectSession(topic) @@ -96,45 +73,21 @@ internal class DefaultWcSessionsManager( onSessionDelete.trySend(sessionDelete) } - private suspend fun migrateLegacyStore( - inNewStore: Set, - inSdk: List, - wallets: List, - ): Boolean { - val walletIds = wallets.map { wallet -> wallet.walletId } - val inLegacyStore = walletIds - .map { walletId -> - flow { - emit( - legacyStore.loadSessions(walletId.stringValue).mapNotNull { legacySession -> - val url = - inSdk.find { it.topic == legacySession.topic }?.metaData?.url ?: return@mapNotNull null - WcSessionDTO( - topic = legacySession.topic, - walletId = walletId, - url = url, - securityStatus = CheckDAppResult.FAILED_TO_VERIFY, - ) - }, - ) - } - } - .merge() - .reduce { accumulator, value -> accumulator.plus(value) } - // migrate only active legacySessions - .filter { legacySession -> inSdk.any { inSdkSession -> inSdkSession.topic == legacySession.topic } } - - val mustSaveInNewStore = inLegacyStore.subtract(inNewStore) - if (mustSaveInNewStore.isNotEmpty()) store.saveSessions(mustSaveInNewStore) - return mustSaveInNewStore.isNotEmpty() - } - private suspend fun associate( inSdk: List, inStore: Set, wallets: List, ): List { - val wcSessions = inStore.mapNotNull { storeSession -> + // if the WcSdk `onSessionSettleResponse` callback arrives late, merge pending approvals with WcSdk sessions + val savedPending = store.pendingApproval.first() + .mapNotNullTo(mutableSetOf()) { savedPendingSession -> + val sdkSession = inSdk + .find { sdkSession -> sdkSession.pairingTopic == savedPendingSession.pairingTopic } + ?: return@mapNotNullTo null + savedPendingSession.session.copy(topic = sdkSession.topic) + } + + val wcSessions = savedPending.plus(inStore).mapNotNull { storeSession -> val wallet = wallets.find { it.walletId == storeSession.walletId } ?: return@mapNotNull null val sdkSession = inSdk.find { it.topic == storeSession.topic } ?: return@mapNotNull null val networks = wcNetworksConverter.findWalletNetworks(wallet, sdkSession) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt index a7d1d285fb..9d998db372 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt @@ -9,8 +9,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletconnect.model.WcSession import com.tangem.domain.walletconnect.model.WcSessionApprove import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest @@ -20,9 +18,7 @@ import javax.inject.Inject internal class WcNetworksConverter @Inject constructor( private val namespaceConverters: Set, private val walletManagersFacade: WalletManagersFacade, - private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { fun createNetwork(chainId: String, wallet: UserWallet): Network? { @@ -101,12 +97,10 @@ internal class WcNetworksConverter @Inject constructor( } private suspend fun getWalletNetworks(userWalletId: UserWalletId): List { - return if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId), - ).orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - }.filterIsInstance().map(CryptoCurrency.Coin::network) + return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId), + ) + .orEmpty() + .filterIsInstance().map(CryptoCurrency.Coin::network) } } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcScope.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcScope.kt new file mode 100644 index 0000000000..d394ce320d --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcScope.kt @@ -0,0 +1,13 @@ +package com.tangem.data.walletconnect.utils + +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.CoroutineContext + +internal class WcScope( + dispatchers: CoroutineDispatcherProvider, +) : CoroutineScope { + + override val coroutineContext: CoroutineContext = SupervisorJob() + dispatchers.io +} \ No newline at end of file diff --git a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt index 6058a8f797..60a4fe2b99 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 @@ -16,11 +16,7 @@ import com.tangem.data.walletconnect.pair.WcPairSdkDelegate import com.tangem.data.walletconnect.utils.WcSdkSessionConverter import com.tangem.domain.blockaid.BlockAidVerifier import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.walletconnect.model.WcPairError -import com.tangem.domain.walletconnect.model.WcPairRequest -import com.tangem.domain.walletconnect.model.WcSession -import com.tangem.domain.walletconnect.model.WcSessionApprove -import com.tangem.domain.walletconnect.repository.WcSessionsManager +import com.tangem.domain.walletconnect.model.* import com.tangem.domain.walletconnect.usecase.pair.WcPairState import io.mockk.coEvery import io.mockk.coVerifyOrder @@ -32,7 +28,6 @@ import org.junit.Test internal class DefaultWcPairUseCaseTest { - private val sessionsManager: WcSessionsManager = mockk() private val associateNetworksDelegate: AssociateNetworksDelegate = mockk() private val caipNamespaceDelegate: CaipNamespaceDelegate = mockk() private val analytics: AnalyticsEventHandler = mockk(relaxed = true) @@ -83,11 +78,6 @@ internal class DefaultWcPairUseCaseTest { namespaces = mapOf(), ) - private val sdkApproveSuccess: Wallet.Model.SettledSessionResponse.Result - get() = Wallet.Model.SettledSessionResponse.Result( - session = sdkSession, - ) - private val sdkSession: Wallet.Model.Session get() = Wallet.Model.Session( pairingTopic = "", @@ -115,7 +105,6 @@ internal class DefaultWcPairUseCaseTest { ) private fun useCaseFactory() = DefaultWcPairUseCase( - sessionsManager = sessionsManager, associateNetworksDelegate = associateNetworksDelegate, caipNamespaceDelegate = caipNamespaceDelegate, sdkDelegate = sdkDelegate, @@ -155,12 +144,11 @@ internal class DefaultWcPairUseCaseTest { @Test fun `success pair and approve flow`() = runTest { val approveLoading = WcPairState.Approving.Loading(sessionForApprove) - val sessionForSave = sdkSession.sessionForSave - val result = WcPairState.Approving.Result(sessionForApprove, sessionForSave.right()) + val appMetaData = sdkSession.sessionForSave.sdkModel.appMetaData + val result = WcPairState.Approving.Result(sessionForApprove, appMetaData.right()) coEvery { sdkDelegate.pair(url) } returns (sdkProposal to sdkVerifyContext).right() - coEvery { sdkDelegate.approve(sdkApprove) } returns sdkApproveSuccess.right() - coEvery { sessionsManager.saveSession(any()) } returns Unit + coEvery { sdkDelegate.approve(any(), any()) } returns Unit.right() coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE } val useCase = useCaseFactory() @@ -177,8 +165,7 @@ internal class DefaultWcPairUseCaseTest { assertEquals(approveLoading, awaitItem()) coVerifyOrder { - sdkDelegate.approve(sdkApprove) - sessionsManager.saveSession(any()) + sdkDelegate.approve(any(), any()) } val actual: WcPairState = awaitItem() assert(actual is WcPairState.Approving.Result) @@ -250,7 +237,7 @@ internal class DefaultWcPairUseCaseTest { val approveLoading = WcPairState.Approving.Loading(sessionForApprove) val error = WcPairError.ApprovalFailed("error").left() coEvery { sdkDelegate.pair(url) } returns (sdkProposal to sdkVerifyContext).right() - coEvery { sdkDelegate.approve(sdkApprove) } returns error + coEvery { sdkDelegate.approve(any(), any()) } returns error coEvery { sdkDelegate.rejectSession(sdkApprove.proposerPublicKey) } returns Unit coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE } @@ -269,7 +256,7 @@ internal class DefaultWcPairUseCaseTest { assertEquals(approveLoading, awaitItem()) coVerifyOrder { - sdkDelegate.approve(sdkApprove) + sdkDelegate.approve(any(), any()) sdkDelegate.rejectSession(sdkApprove.proposerPublicKey) } assertEquals(errorResult, awaitItem()) diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactory.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactory.kt index 0d7e463bb3..55054ba3c8 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactory.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactory.kt @@ -1,24 +1,20 @@ package com.tangem.data.walletmanager -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.AmountType -import com.tangem.blockchain.common.Token -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.TransactionStatus -import com.tangem.blockchain.common.WalletManager +import com.tangem.blockchain.common.* import com.tangem.blockchain.common.address.Address import com.tangem.blockchainsdk.models.UpdateWalletManagerResult import com.tangem.blockchainsdk.utils.amountToCreateAccount import com.tangem.data.walletmanager.utils.SdkAddressToAddressConverter import com.tangem.data.walletmanager.utils.TransactionDataToTxHistoryItemConverter import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import timber.log.Timber import java.math.BigDecimal -/** Factory for creating [com.tangem.blockchainsdk.models.UpdateWalletManagerResult] */ +/** Factory for creating [UpdateWalletManagerResult] */ internal class UpdateWalletManagerResultFactory { - /** Get [com.tangem.blockchainsdk.models.UpdateWalletManagerResult.Verified] result for [walletManager] */ + /** Get [UpdateWalletManagerResult.Verified] result for [walletManager] */ fun getResult(walletManager: WalletManager): UpdateWalletManagerResult.Verified { val wallet = walletManager.wallet val addresses = getAvailableAddresses(wallet.addresses) @@ -110,7 +106,7 @@ internal class UpdateWalletManagerResultFactory { is AmountType.Token -> { val value = getCurrencyAmountValue(amount) ?: return null - UpdateWalletManagerResult.CryptoCurrencyAmount.Token( + UpdateWalletManagerResult.CryptoCurrencyAmount.Token.BasicToken( currencyRawId = type.token.id?.let(CryptoCurrency::RawID), contractAddress = type.token.contractAddress, value = value, @@ -121,6 +117,20 @@ internal class UpdateWalletManagerResultFactory { UpdateWalletManagerResult.CryptoCurrencyAmount.Coin(value = value) } + is AmountType.TokenYieldSupply -> { + val value = getCurrencyAmountValue(amount) ?: return null + + UpdateWalletManagerResult.CryptoCurrencyAmount.Token.YieldSupplyToken( + value = value, + currencyRawId = type.token.id?.let(CryptoCurrency::RawID), + contractAddress = type.token.contractAddress, + yieldSupplyStatus = YieldSupplyStatus( + isActive = type.isActive, + isInitialized = type.isInitialized, + isAllowedToSpend = type.isAllowedToSpend, + ), + ) + } is AmountType.FeeResource, is AmountType.Reserve, -> null @@ -147,7 +157,7 @@ internal class UpdateWalletManagerResultFactory { ) return tokens.mapTo(demoAmounts) { token -> - UpdateWalletManagerResult.CryptoCurrencyAmount.Token( + UpdateWalletManagerResult.CryptoCurrencyAmount.Token.BasicToken( currencyRawId = token.id?.let(CryptoCurrency::RawID), contractAddress = token.contractAddress, value = amountValue, @@ -188,6 +198,15 @@ internal class UpdateWalletManagerResultFactory { txInfo = txHistoryItem, ) } + is AmountType.TokenYieldSupply -> { + val txHistoryItem = txHistoryItemConverter.convert(data) ?: return null + + UpdateWalletManagerResult.CryptoCurrencyTransaction.Token( + tokenId = type.token.id, + contractAddress = type.token.contractAddress, + txInfo = txHistoryItem, + ) + } is AmountType.FeeResource, is AmountType.Reserve, -> null diff --git a/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactoryTest.kt b/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactoryTest.kt index 9df3c38d66..4c5c929262 100644 --- a/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactoryTest.kt +++ b/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactoryTest.kt @@ -188,7 +188,7 @@ internal class UpdateWalletManagerResultFactoryTest { ), currenciesAmounts = setOf( UpdateWalletManagerResult.CryptoCurrencyAmount.Coin(value = BigDecimal.ONE), - UpdateWalletManagerResult.CryptoCurrencyAmount.Token( + UpdateWalletManagerResult.CryptoCurrencyAmount.Token.BasicToken( value = BigDecimal.ZERO, currencyRawId = usdtToken.id?.let(CryptoCurrency::RawID), contractAddress = usdtToken.contractAddress, @@ -416,7 +416,7 @@ internal class UpdateWalletManagerResultFactoryTest { ), currenciesAmounts = setOf( UpdateWalletManagerResult.CryptoCurrencyAmount.Coin(value = BigDecimal.ZERO), - UpdateWalletManagerResult.CryptoCurrencyAmount.Token( + UpdateWalletManagerResult.CryptoCurrencyAmount.Token.BasicToken( value = BigDecimal.ZERO, currencyRawId = usdtToken.id?.let(CryptoCurrency::RawID), contractAddress = usdtToken.contractAddress, @@ -477,7 +477,7 @@ internal class UpdateWalletManagerResultFactoryTest { ), currenciesAmounts = setOf( UpdateWalletManagerResult.CryptoCurrencyAmount.Coin(value = BigDecimal.TEN), - UpdateWalletManagerResult.CryptoCurrencyAmount.Token( + UpdateWalletManagerResult.CryptoCurrencyAmount.Token.BasicToken( value = BigDecimal.TEN, currencyRawId = usdtToken.id?.let(CryptoCurrency::RawID), contractAddress = usdtToken.contractAddress, diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt index e6c82e427a..d8edb5dc18 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt @@ -37,6 +37,7 @@ import com.tangem.utils.coroutines.runCatching import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.collections.mutableSetOf typealias SeedPhraseNotificationsStatuses = Map @@ -50,6 +51,9 @@ internal class DefaultWalletsRepository( private val authProvider: AuthProvider, ) : WalletsRepository { + private val upgradeWalletNotificationDisabled: MutableStateFlow> = + MutableStateFlow(mutableSetOf()) + override suspend fun shouldSaveUserWalletsSync(): Boolean { return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false) } @@ -333,6 +337,16 @@ internal class DefaultWalletsRepository( } } + override fun isUpgradeWalletNotificationEnabled(userWalletId: UserWalletId): Flow { + return upgradeWalletNotificationDisabled.map { + it.contains(userWalletId) + } + } + + override suspend fun dismissUpgradeWalletNotification(userWalletId: UserWalletId) { + upgradeWalletNotificationDisabled.update { it.plus(userWalletId) } + } + override suspend fun setWalletName(walletId: String, walletName: String) = withContext(dispatchers.io) { tangemTechApi.updateWallet( walletId = walletId, diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt index 3eb9431fc3..bdc3f7fdf2 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.domain.wallets.usecase.BackendId import com.tangem.hot.sdk.model.DeriveWalletRequest import com.tangem.operations.derivation.ExtendedPublicKeysMap diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/HotWalletAccessor.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt similarity index 69% rename from data/wallets/src/main/java/com/tangem/data/wallets/hot/HotWalletAccessor.kt rename to data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt index b5ca40b2de..9fac877712 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/HotWalletAccessor.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt @@ -3,30 +3,83 @@ package com.tangem.data.wallets.hot import com.tangem.common.core.TangemSdkError import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.copy +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.exception.WrongPasswordException import com.tangem.hot.sdk.model.* +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject +import kotlin.collections.set -class HotWalletAccessor @Inject constructor( +class DefaultHotWalletAccessor @Inject constructor( private val tangemHotSdk: TangemHotSdk, private val userWalletsListRepository: UserWalletsListRepository, private val hotWalletPasswordRequester: HotWalletPasswordRequester, private val walletsRepository: WalletsRepository, -) { + dispatchers: CoroutineDispatcherProvider, +) : HotWalletAccessor { - suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List): List = + private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) + + private var contextualUnlockHotWallet: ConcurrentHashMap = ConcurrentHashMap() + + override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List): List = hotSdkRequest(hotWalletId) { unlock -> tangemHotSdk.signHashes(unlockHotWallet = unlock, dataToSign = dataToSign) } - suspend fun derivePublicKeys(hotWalletId: HotWalletId, request: DeriveWalletRequest): DerivedPublicKeyResponse = - hotSdkRequest(hotWalletId) { unlock -> - tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request) + override suspend fun derivePublicKeys( + hotWalletId: HotWalletId, + request: DeriveWalletRequest, + ): DerivedPublicKeyResponse = hotSdkRequest(hotWalletId) { unlock -> + tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request) + } + + override suspend fun exportSeedPhrase(hotWalletId: HotWalletId): SeedPhrasePrivateInfo { + val unlockHotWallet = contextualUnlockHotWallet[hotWalletId] ?: hotSdkRequest(hotWalletId) { it } + return tangemHotSdk.exportMnemonic(unlockHotWallet = unlockHotWallet) + } + + override suspend fun unlockContextual(hotWalletId: HotWalletId): UnlockHotWallet = hotSdkRequest(hotWalletId) { + tangemHotSdk.getContextUnlock(it).also { unlockHotWallet -> + contextualUnlockHotWallet[hotWalletId] = unlockHotWallet } + } + + override fun getContextualUnlock(hotWalletId: HotWalletId): UnlockHotWallet? = + contextualUnlockHotWallet[hotWalletId] + + override fun clearContextualUnlock(hotWalletId: HotWalletId) { + contextualUnlockHotWallet.remove(hotWalletId) + scope.launch { + tangemHotSdk.clearUnlockContext(hotWalletId) + } + } + + override fun clearContextualUnlock(userWalletId: UserWalletId) { + scope.launch { + val userWallet = userWalletsListRepository.userWalletsSync() + .find { it is UserWallet.Hot && it.walletId == userWalletId } + as? UserWallet.Hot + ?: return@launch + clearContextualUnlock(userWallet.hotWalletId) + } + } + + override fun clearAllContextualUnlock() { + val hotWalletsIds = contextualUnlockHotWallet.keys.toList() + contextualUnlockHotWallet.clear() + scope.launch { + hotWalletsIds.forEach { tangemHotSdk.clearUnlockContext(it) } + } + } private suspend fun hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T { val isAccessCodeRequired = walletsRepository.requireAccessCode() @@ -51,6 +104,7 @@ class HotWalletAccessor @Inject constructor( return runCatchingSdkErrors(hotWalletId, auth) { block(UnlockHotWallet(hotWalletId, it)).also { + hotWalletPasswordRequester.successfulAuthentication() hotWalletPasswordRequester.dismiss() } } diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/TangemHotWalletSigner.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/TangemHotWalletSigner.kt index b5bfc9da12..ccef106815 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/TangemHotWalletSigner.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/TangemHotWalletSigner.kt @@ -6,6 +6,7 @@ import com.tangem.common.CompletionResult import com.tangem.common.core.TangemSdkError import com.tangem.common.map import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.hot.sdk.model.DataToSign import com.tangem.operations.sign.SignData import dagger.assisted.Assisted diff --git a/data/yield-supply/build.gradle.kts b/data/yield-supply/build.gradle.kts new file mode 100644 index 0000000000..b0cc1f2844 --- /dev/null +++ b/data/yield-supply/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.data.yield.supply" +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +dependencies { + + /** Tangem SDKs */ + implementation(tangemDeps.blockchain) + + /** Core */ + implementation(projects.core.datasource) + implementation(projects.core.utils) + + /** Domain */ + implementation(projects.domain.yieldSupply) + implementation(projects.domain.walletManager) + implementation(projects.domain.legacy) + + implementation(projects.libs.blockchainSdk) + + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /** Other */ + implementation(deps.timber) + + /** tests */ + testImplementation(projects.common.test) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) +} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt new file mode 100644 index 0000000000..86c802d7db --- /dev/null +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt @@ -0,0 +1,371 @@ +package com.tangem.data.yield.supply + +import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils +import com.tangem.blockchain.blockchains.ethereum.tokenmethods.ApprovalERC20TokenCallData +import com.tangem.blockchain.blockchains.tron.TronTransactionExtras +import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.math.BigDecimal + +@Suppress("LargeClass") +internal class DefaultYieldSupplyTransactionRepository( + private val walletManagersFacade: WalletManagersFacade, + private val dispatchers: CoroutineDispatcherProvider, +) : YieldSupplyTransactionRepository { + + override suspend fun createEnterTransactions( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): List { + val cryptoCurrency = cryptoCurrencyStatus.currency + + require(cryptoCurrency is CryptoCurrency.Token) + + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + + val existingYieldContractAddress = getYieldContractAddress( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) + + val calculatedYieldContractAddress = calculateYieldContractAddress( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) ?: error("Calculated yield contract address is null") + + val yieldTokenStatus = cryptoCurrencyStatus.value.yieldSupplyStatus ?: getYieldTokenStatus( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + ) + + return buildEnterTransactions( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + existingYieldContractAddress = existingYieldContractAddress, + calculatedYieldContractAddress = calculatedYieldContractAddress, + yieldTokenStatus = yieldTokenStatus, + ) + } + + override suspend fun createExitTransaction( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + yieldSupplyStatus: YieldSupplyStatus, + fee: Fee?, + ): TransactionData.Uncompiled = withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + + val callData = YieldSupplyContractCallDataProviderFactory.getExitCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + ) + + createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = walletManager.getYieldContract(), + yieldSupplyStatus = yieldSupplyStatus, + fee = fee, + ) + } + + private fun buildEnterTransactions( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + existingYieldContractAddress: String?, + calculatedYieldContractAddress: String, + yieldTokenStatus: YieldSupplyStatus?, + ): MutableList { + val enterTransactions = mutableListOf() + + when { + existingYieldContractAddress == null || existingYieldContractAddress == EthereumUtils.ZERO_ADDRESS -> { + enterTransactions.add( + createDeployTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + ), + ) + } + yieldTokenStatus == null -> error("Yield token status is null") + !yieldTokenStatus.isInitialized -> enterTransactions.add( + createInitTokenTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + yieldSupplyStatus = yieldTokenStatus, + yieldContractAddress = calculatedYieldContractAddress, + ), + ) + !yieldTokenStatus.isActive -> enterTransactions.add( + createReactivateTokenTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + yieldSupplyStatus = yieldTokenStatus, + yieldContractAddress = calculatedYieldContractAddress, + ), + ) + else -> Unit + } + + if (yieldTokenStatus?.isAllowedToSpend == false) { + enterTransactions.add( + createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = ApprovalERC20TokenCallData( + spenderAddress = calculatedYieldContractAddress, + amount = null, + ), + destinationAddress = cryptoCurrency.contractAddress, + yieldSupplyStatus = yieldTokenStatus, + fee = null, + ), + ) + } + + enterTransactions.add( + createEnterTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + yieldSupplyStatus = yieldTokenStatus, + yieldContractAddress = calculatedYieldContractAddress, + ), + ) + + return enterTransactions + } + + private suspend fun calculateYieldContractAddress( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): String? = withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + runCatching { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + walletManager.calculateYieldContract() + }.onFailure(Timber::e) + .getOrNull() + } + + private suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String? = + withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + runCatching { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + walletManager.getYieldContract() + }.onFailure(Timber::e) + .getOrNull() + } + + private suspend fun getYieldTokenStatus( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency, + ): YieldSupplyStatus? = withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + runCatching { + val sdkSupplyStatus = walletManager.getYieldSupplyStatus(cryptoCurrency.contractAddress) + val isAllowedToSpend = walletManager.isAllowedToSpend(cryptoCurrency.contractAddress) + + YieldSupplyStatus( + isActive = sdkSupplyStatus?.isActive == true, + isInitialized = sdkSupplyStatus?.isInitialized == true, + isAllowedToSpend = isAllowedToSpend, + ) + }.onFailure(Timber::e).getOrNull() + } + + private fun createDeployTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + ): TransactionData.Uncompiled { + val callData = YieldSupplyContractCallDataProviderFactory.getDeployCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + walletAddress = walletManager.wallet.address, + maxNetworkFee = MAX_NETWORK_FEE.convertToSdkAmount(cryptoCurrency), + ) + + val factoryContractAddress = walletManager.getYieldSupplyContractAddresses()?.factoryContractAddress + ?: error("Factory contract address is null") + + return createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = factoryContractAddress, + yieldSupplyStatus = null, + fee = null, + ) + } + + private fun createInitTokenTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + yieldContractAddress: String, + yieldSupplyStatus: YieldSupplyStatus, + ): TransactionData.Uncompiled { + val callData = YieldSupplyContractCallDataProviderFactory.getInitTokenCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + maxNetworkFee = MAX_NETWORK_FEE.convertToSdkAmount(cryptoCurrency), + ) + + return createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = yieldContractAddress, + yieldSupplyStatus = yieldSupplyStatus, + fee = null, + ) + } + + private fun createReactivateTokenTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + yieldContractAddress: String, + yieldSupplyStatus: YieldSupplyStatus, + ): TransactionData.Uncompiled { + val callData = YieldSupplyContractCallDataProviderFactory.getReactivateTokenCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + maxNetworkFee = MAX_NETWORK_FEE.convertToSdkAmount(cryptoCurrency), + ) + + return createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = yieldContractAddress, + yieldSupplyStatus = yieldSupplyStatus, + fee = null, + ) + } + + private fun createEnterTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + yieldSupplyStatus: YieldSupplyStatus?, + yieldContractAddress: String, + ): TransactionData.Uncompiled { + val callData = YieldSupplyContractCallDataProviderFactory.getEnterCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + ) + + return createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = yieldContractAddress, + yieldSupplyStatus = yieldSupplyStatus, + fee = null, + ) + } + + @Suppress("LongParameterList") + private fun createTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency, + callData: SmartContractCallData, + destinationAddress: String, + yieldSupplyStatus: YieldSupplyStatus?, + fee: Fee?, + ): TransactionData.Uncompiled { + requireNotNull(cryptoCurrency as? CryptoCurrency.Token) + val blockchain = cryptoCurrency.network.id.toBlockchain() + + val extras = createTransactionDataExtras( + callData = callData, + blockchain = blockchain, + ) + + val amount = getYieldSupplyAmount(cryptoCurrency, yieldSupplyStatus) + + return if (fee != null) { + walletManager.createTransaction( + amount = amount, + fee = fee, + destination = destinationAddress, + ).copy( + extras = extras, + ) + } else { + TransactionData.Uncompiled( + amount = amount, + sourceAddress = walletManager.wallet.address, + destinationAddress = destinationAddress, + extras = extras, + fee = null, + ) + } + } + + private fun createTransactionDataExtras( + callData: SmartContractCallData, + blockchain: Blockchain, + ): TransactionExtras { + return when { + blockchain.isEvm() -> { + EthereumTransactionExtras( + callData = callData, + gasLimit = null, + nonce = null, + ) + } + blockchain == Blockchain.Tron -> { + TronTransactionExtras( + callData = callData, + ) + } + else -> error("Data extras not supported for $blockchain") + } + } + + private fun getYieldSupplyAmount(cryptoCurrency: CryptoCurrency.Token, yieldSupplyStatus: YieldSupplyStatus?) = + BigDecimal.ZERO.convertToSdkAmount( + cryptoCurrency = cryptoCurrency, + amountType = AmountType.TokenYieldSupply( + token = Token( + symbol = cryptoCurrency.symbol, + contractAddress = cryptoCurrency.contractAddress, + decimals = cryptoCurrency.decimals, + ), + isActive = yieldSupplyStatus?.isActive ?: false, + isInitialized = yieldSupplyStatus?.isInitialized ?: false, + isAllowedToSpend = yieldSupplyStatus?.isAllowedToSpend ?: false, + ), + ) + + private companion object { + val MAX_NETWORK_FEE: BigDecimal = BigDecimal.TEN // TODO for TESTNET only + } +} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt new file mode 100644 index 0000000000..2b2f45f06c --- /dev/null +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt @@ -0,0 +1,28 @@ +package com.tangem.data.yield.supply.di + +import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object YieldSupplyDataModule { + + @Provides + @Singleton + fun providerYieldSupplyTransactionRepository( + walletManagersFacade: WalletManagersFacade, + dispatchers: CoroutineDispatcherProvider, + ): YieldSupplyTransactionRepository { + return DefaultYieldSupplyTransactionRepository( + walletManagersFacade = walletManagersFacade, + dispatchers = dispatchers, + ) + } +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt new file mode 100644 index 0000000000..069e3630f8 --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt @@ -0,0 +1,288 @@ +package com.tangem.data.yield.supply + +import com.google.common.truth.Truth +import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.WalletManager +import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory +import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import com.tangem.blockchain.yieldsupply.providers.YieldSupplyStatus as SDKYieldSupplyStatus +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +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 + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultYieldSupplyTransactionRepositoryTest { + + private val networkId = Network.ID(value = "ETH/test", derivationPath = Network.DerivationPath.None) + private val mockedContractAddress = "0x000000000000000000000000000000000000" + private val yieldContractAddress = "0x1234" + + private val userWalletId = mockk() + private val cryptoCurrency = mockk(relaxed = true) { + every { network.id } returns networkId + every { contractAddress } returns mockedContractAddress + } + private val cryptoCurrencyStatus = mockk(relaxed = true) { + every { currency } returns cryptoCurrency + every { value.yieldSupplyStatus } returns null + } + + private val walletManager = mockk(relaxed = true) { + every { wallet } returns mockk(relaxed = true) + every { getYieldSupplyContractAddresses() } returns mockk(relaxed = true) { + every { factoryContractAddress } returns "factory" + } + } + private val walletManagersFacade: WalletManagersFacade = mockk { + coEvery { getOrCreateWalletManager(any(), any(), any()) } returns walletManager + } + private lateinit var repository: DefaultYieldSupplyTransactionRepository + + @BeforeEach + fun setUp() { + repository = spyk( + objToCopy = DefaultYieldSupplyTransactionRepository( + walletManagersFacade = walletManagersFacade, + dispatchers = TestingCoroutineDispatcherProvider(), + ), + recordPrivateCalls = true, + ) + every { mockk().contractAddress } returns mockedContractAddress + } + + @Test + fun `createEnterTransactions returns deploy-approve-enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns EthereumUtils.ZERO_ADDRESS + coEvery { walletManager.getYieldSupplyStatus(any()) } returns null + coEvery { walletManager.isAllowedToSpend(any()) } returns false + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that 3 transactions are returned: deploy, approve, enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - deploy + val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getDeployCallData( + walletAddress = walletManager.wallet.address, + tokenContractAddress = mockedContractAddress, + maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency), + ) + val firstTransaction = result.first() + + Truth.assertThat(firstTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((firstTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(firstExpectedCallData.data) + + // Check transaction - approve + val secondExpectedCallData = SmartContractCallDataProviderFactory.getApprovalCallData( + spenderAddress = yieldContractAddress, + amount = null, + blockchain = Blockchain.EthereumTestnet, + ) + val secondTransaction = result[1] + + Truth.assertThat(secondTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((secondTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(secondExpectedCallData.data) + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[2] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createEnterTransactions returns init-approve-enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns yieldContractAddress + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus( + isActive = false, + isInitialized = false, + maxNetworkFee = BigDecimal.TEN, + ) + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that 3 transactions are returned: init token, approve, enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - init token + val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getInitTokenCallData( + tokenContractAddress = mockedContractAddress, + maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency), + ) + val firstTransaction = result.first() + + Truth.assertThat(firstTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((firstTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(firstExpectedCallData.data) + + // Check transaction - approve + val secondExpectedCallData = SmartContractCallDataProviderFactory.getApprovalCallData( + spenderAddress = yieldContractAddress, + amount = null, + blockchain = Blockchain.EthereumTestnet, + ) + val secondTransaction = result[1] + + Truth.assertThat(secondTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((secondTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(secondExpectedCallData.data) + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[2] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createEnterTransactions returns reactivate-approve-enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns yieldContractAddress + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus( + isActive = false, + isInitialized = true, + maxNetworkFee = BigDecimal.TEN, + ) + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that 3 transactions are returned: reactivate token, approve, enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - reactivate token + val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getReactivateTokenCallData( + tokenContractAddress = mockedContractAddress, + maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency), + ) + val firstTransaction = result.first() + + Truth.assertThat(firstTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((firstTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(firstExpectedCallData.data) + + // Check transaction - approve + val secondExpectedCallData = SmartContractCallDataProviderFactory.getApprovalCallData( + spenderAddress = yieldContractAddress, + amount = null, + blockchain = Blockchain.EthereumTestnet, + ) + val secondTransaction = result[1] + + Truth.assertThat(secondTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((secondTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(secondExpectedCallData.data) + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[2] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createEnterTransactions returns reactivate-enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns yieldContractAddress + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus( + isActive = false, + isInitialized = true, + maxNetworkFee = BigDecimal.TEN, + ) + coEvery { walletManager.isAllowedToSpend(any()) } returns true + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that 2 transactions are returned: approve, enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - reactivate token + val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getReactivateTokenCallData( + tokenContractAddress = mockedContractAddress, + maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency), + ) + val firstTransaction = result.first() + + Truth.assertThat(firstTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((firstTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(firstExpectedCallData.data) + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[1] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createEnterTransactions returns enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns yieldContractAddress + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus( + isActive = true, + isInitialized = true, + maxNetworkFee = BigDecimal.TEN, + ) + coEvery { walletManager.isAllowedToSpend(any()) } returns true + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that transaction is returned: enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[0] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createExitTransaction returns valid transaction`() = runTest { + val expectedCallData = + YieldSupplyContractCallDataProviderFactory.getExitCallData(mockedContractAddress) + + val yieldSupplyStatus = mockk(relaxed = true) + + val result = repository.createExitTransaction(userWalletId, cryptoCurrency, yieldSupplyStatus, null) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((result.extras as EthereumTransactionExtras).callData?.data).isEqualTo(expectedCallData.data) + } +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/featuretoggle/AccountsFeatureToggles.kt b/domain/account/src/main/java/com/tangem/domain/account/featuretoggle/AccountsFeatureToggles.kt new file mode 100644 index 0000000000..289b2a02e6 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/featuretoggle/AccountsFeatureToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.account.featuretoggle + +/** + * Accounts feature toggle + * +[REDACTED_AUTHOR] + */ +interface AccountsFeatureToggles { + + val isFeatureEnabled: Boolean +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/fetcher/MultiAccountListFetcher.kt b/domain/account/src/main/java/com/tangem/domain/account/fetcher/MultiAccountListFetcher.kt new file mode 100644 index 0000000000..bff4dd124e --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/fetcher/MultiAccountListFetcher.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.account.fetcher + +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Component that fetches a list of accounts for multiple wallets by a set of [UserWalletId]s or + * all accounts if no set is provided + * +[REDACTED_AUTHOR] + */ +interface MultiAccountListFetcher : FlowFetcher { + + sealed interface Params { + + data class Set(val ids: kotlin.collections.Set) : Params + + data object All : Params + } +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/fetcher/SingleAccountListFetcher.kt b/domain/account/src/main/java/com/tangem/domain/account/fetcher/SingleAccountListFetcher.kt new file mode 100644 index 0000000000..d282f20949 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/fetcher/SingleAccountListFetcher.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.account.fetcher + +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Component that fetches a list of accounts for a single wallet by [UserWalletId] + * +[REDACTED_AUTHOR] + */ +interface SingleAccountListFetcher : FlowFetcher { + + data class Params(val userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt index 89f7fd33c8..0a5b08f87b 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt @@ -6,6 +6,7 @@ import arrow.core.raise.ensure import com.tangem.domain.models.TokensGroupType import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.utils.extensions.addOrReplace @@ -119,8 +120,8 @@ data class AccountList private constructor( } @Serializable - data object DuplicateAccountNames : Error { - override fun toString(): String = "$tag: Account list contains duplicate account names" + data class DuplicateAccountNames(val message: String) : Error { + override fun toString(): String = "$tag: Account list contains duplicate account names. $message" } } @@ -160,8 +161,18 @@ data class AccountList private constructor( val uniqueAccountIdsCount = accounts.map { it.accountId.value }.distinct().size ensure(accounts.size == uniqueAccountIdsCount) { Error.DuplicateAccountIds } - val uniqueAccountNameCount = accounts.map { it.accountName.value }.distinct().size - ensure(accounts.size == uniqueAccountNameCount) { Error.DuplicateAccountNames } + val defaultMainNameCount = accounts.count { it.accountName is AccountName.DefaultMain } + + val customNames = accounts.mapNotNull { (it.accountName as? AccountName.Custom)?.value } + val uniqueCustomNameCount = customNames.distinct().size + + ensure(defaultMainNameCount == 0 || defaultMainNameCount == 1) { + Error.DuplicateAccountNames("Only one account can have the default main name.") + } + + ensure(customNames.size == uniqueCustomNameCount) { + Error.DuplicateAccountNames("Custom account names must be unique.") + } AccountList( userWallet = userWallet, diff --git a/domain/account/src/main/java/com/tangem/domain/account/producer/MultiAccountListProducer.kt b/domain/account/src/main/java/com/tangem/domain/account/producer/MultiAccountListProducer.kt new file mode 100644 index 0000000000..345e6897bc --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/producer/MultiAccountListProducer.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.account.producer + +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.core.flow.FlowProducer + +/** + * Produces a list of [AccountList]s for all user wallets. + * +[REDACTED_AUTHOR] + */ +interface MultiAccountListProducer : FlowProducer> { + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountListProducer.kt b/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountListProducer.kt new file mode 100644 index 0000000000..8d85130007 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountListProducer.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.account.producer + +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Produces a list of [AccountList] for a specific user wallet. + * +[REDACTED_AUTHOR] + */ +interface SingleAccountListProducer : FlowProducer { + + data class Params(val userWalletId: UserWalletId) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt index ac6921e167..e58992d600 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt @@ -22,7 +22,7 @@ interface AccountsCRUDRepository { * @param userWalletId the unique identifier of the user wallet * @return an [Option] containing the [AccountList] if found, or `Option.None` if not */ - suspend fun getAccounts(userWalletId: UserWalletId): Option + suspend fun getAccountListSync(userWalletId: UserWalletId): Option /** * Retrieves a specific account by its unique identifier @@ -30,14 +30,14 @@ interface AccountsCRUDRepository { * @param accountId the unique identifier of the account * @return an [Option] containing the [Account.CryptoPortfolio] if found, or `Option.None` if not */ - suspend fun getAccount(accountId: AccountId): Option + suspend fun getAccountSync(accountId: AccountId): Option /** * Retrieves a archived account by its unique identifier * * @param accountId the unique identifier of the account */ - suspend fun getArchivedAccount(accountId: AccountId): Option + suspend fun getArchivedAccountSync(accountId: AccountId): Option /** * Retrieves a list of archived accounts associated with a specific user wallet @@ -45,7 +45,7 @@ interface AccountsCRUDRepository { * @param userWalletId the unique identifier of the user wallet * @return an [Option] containing a list of [ArchivedAccount] if found, or `Option.None` if not */ - suspend fun getArchivedAccountsSync(userWalletId: UserWalletId): Option> + suspend fun getArchivedAccountListSync(userWalletId: UserWalletId): Option> /** * Provides a flow of archived accounts associated with a specific user wallet @@ -73,7 +73,14 @@ interface AccountsCRUDRepository { * * @param userWalletId the unique identifier of the user wallet */ - suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Int + suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option + + /** + * Provides a flow of the total count of accounts associated with a specific user wallet including archived accounts + * + * @param userWalletId the unique identifier of the user wallet + */ + fun getTotalAccountsCount(userWalletId: UserWalletId): Flow> /** * Retrieves a user wallet by its unique identifier @@ -82,4 +89,10 @@ interface AccountsCRUDRepository { * @return the [UserWallet] associated with the given identifier */ fun getUserWallet(userWalletId: UserWalletId): UserWallet + + /** Provides a flow of all user wallets */ + fun getUserWallets(): Flow> + + /** Synchronously retrieves all user wallets */ + fun getUserWalletsSync(): List } \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/supplier/MultiAccountListSupplier.kt b/domain/account/src/main/java/com/tangem/domain/account/supplier/MultiAccountListSupplier.kt new file mode 100644 index 0000000000..45989af478 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/supplier/MultiAccountListSupplier.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.account.supplier + +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.MultiAccountListProducer +import com.tangem.domain.core.flow.FlowCachingSupplier +import kotlinx.coroutines.flow.Flow + +/** + * Supplier that provides a list of [AccountList]s for all user wallets. + * +[REDACTED_AUTHOR] + */ +abstract class MultiAccountListSupplier( + override val factory: MultiAccountListProducer.Factory, + override val keyCreator: (Unit) -> String, +) : FlowCachingSupplier>() { + + operator fun invoke(): Flow> { + return super.invoke(params = Unit) + } +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountListSupplier.kt b/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountListSupplier.kt new file mode 100644 index 0000000000..cddb1d9182 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountListSupplier.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.account.supplier + +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.core.flow.FlowCachingSupplier + +/** + * Supplier that provides a single [AccountList] for a specific user wallet. + * +[REDACTED_AUTHOR] + */ +abstract class SingleAccountListSupplier( + override val factory: SingleAccountListProducer.Factory, + override val keyCreator: (SingleAccountListProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt index 6a3866bc4b..6f233d2da7 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt @@ -55,7 +55,7 @@ class AddCryptoPortfolioUseCase( newAccount } - private fun Raise.createAccount( + private fun createAccount( userWalletId: UserWalletId, accountName: AccountName, icon: CryptoPortfolioIcon, @@ -72,7 +72,7 @@ class AddCryptoPortfolioUseCase( private suspend fun Raise.getAccountList(userWalletId: UserWalletId): Option { return catch( - block = { crudRepository.getAccounts(userWalletId = userWalletId) }, + block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) } diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt index 0611b106fb..75056d6eb0 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt @@ -40,7 +40,7 @@ class ArchiveCryptoPortfolioUseCase( private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { return catch( - block = { crudRepository.getAccounts(userWalletId = userWalletId) }, + block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) } diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt index cbcfb13168..afaa2a587e 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt @@ -57,7 +57,7 @@ class GetArchivedAccountsUseCase( private suspend fun getArchivedAccounts(userWalletId: UserWalletId): Either { return Either.catch { - crudRepository.getArchivedAccountsSync(userWalletId = userWalletId).getOrElse { + crudRepository.getArchivedAccountListSync(userWalletId = userWalletId).getOrElse { error("Archived accounts not found for user wallet: $userWalletId") } } @@ -70,7 +70,11 @@ class GetArchivedAccountsUseCase( private suspend fun ProducerScope>.subscribeOnArchivedAccounts( userWalletId: UserWalletId, ) { - crudRepository.getArchivedAccounts(userWalletId) + runCatching { crudRepository.getArchivedAccounts(userWalletId) } + .getOrElse { + send(it.lceError()) + return + } .distinctUntilChanged() .retryWhen { cause, _ -> send(cause.lceError()) diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt index c34240e22b..9fbb2ed7d5 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt @@ -35,9 +35,10 @@ class GetUnoccupiedAccountIndexUseCase( private suspend fun Raise.getTotalAccountsCount(userWalletId: UserWalletId): Int { return catch( - block = { crudRepository.getTotalAccountsCount(userWalletId = userWalletId) }, + block = { crudRepository.getTotalAccountsCountSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) + .getOrElse { raise(Error.DataNotFound) } } /** @@ -48,6 +49,10 @@ class GetUnoccupiedAccountIndexUseCase( val tag: String get() = this::class.simpleName ?: "GetUnoccupiedAccountIndexUseCase.Error" + data object DataNotFound : Error { + override fun toString(): String = "$tag: Data not found" + } + /** Error indicating that the derivation index is invalid */ data class InvalidDerivationIndex(val cause: DerivationIndex.Error) : Error { override fun toString(): String = "$tag: Invalid derivation index: $cause" diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt new file mode 100644 index 0000000000..9c295b058d --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt @@ -0,0 +1,66 @@ +package com.tangem.domain.account.usecase + +import arrow.core.Option +import arrow.core.getOrElse +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isMultiCurrency +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* + +/** + * Use case to determine if the accounts mode is enabled. + * Accounts mode is considered enabled if there are at least two accounts in any of the user wallets that support + * multiple currencies. + * + * @property crudRepository repository to interact with user wallets and their accounts + * +[REDACTED_AUTHOR] + */ +class IsAccountsModeEnabledUseCase( + private val crudRepository: AccountsCRUDRepository, + private val accountsFeatureToggles: AccountsFeatureToggles, +) { + + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke(): Flow { + if (!accountsFeatureToggles.isFeatureEnabled) return flowOf(value = false) + + return crudRepository.getUserWallets() + .flatMapLatest { userWallets -> + val totalAccountsCountList = getTotalAccountsCountList(userWallets) + + combine(flows = totalAccountsCountList) { it.toList().isModeEnabled() } + } + .onEmpty { emit(false) } + } + + suspend fun invokeSync(): Boolean { + if (!accountsFeatureToggles.isFeatureEnabled) return false + + return crudRepository.getUserWalletsSync() + .map { userWallet -> + // If the wallet does not support multiple currencies, we consider its account count as 0 + if (!userWallet.isMultiCurrency) return@map 0 + + crudRepository.getTotalAccountsCountSync(userWalletId = userWallet.walletId).getOrZero() + } + .isModeEnabled() + } + + private fun getTotalAccountsCountList(userWallets: List): List> { + return userWallets + .map { userWallet -> + // If the wallet does not support multiple currencies, we consider its account count as 0 + if (!userWallet.isMultiCurrency) return@map flowOf(0) + + crudRepository.getTotalAccountsCount(userWalletId = userWallet.walletId) + .map { maybeCount -> maybeCount.getOrZero() } + } + } + + private fun Option.getOrZero(): Int = getOrElse { 0 } + + private fun List.isModeEnabled(): Boolean = any { it >= 2 } +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt index f5dcec41aa..1bff615daf 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt @@ -44,7 +44,7 @@ class RecoverCryptoPortfolioUseCase( private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { return catch( - block = { crudRepository.getAccounts(userWalletId = userWalletId) }, + block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) } @@ -52,7 +52,7 @@ class RecoverCryptoPortfolioUseCase( private suspend fun Raise.getArchivedAccount(accountId: AccountId): ArchivedAccount { return catch( - block = { crudRepository.getArchivedAccount(accountId = accountId) }, + block = { crudRepository.getArchivedAccountSync(accountId = accountId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt index 4451a5f50d..2145e7ed15 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt @@ -61,7 +61,7 @@ class UpdateCryptoPortfolioUseCase( private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { return catch( - block = { crudRepository.getAccounts(userWalletId = userWalletId) }, + block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt index 0c43198fa0..4ed41500f0 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt @@ -157,7 +157,7 @@ class AccountListTest { createAccount(userWalletId = userWalletId, name = "Name", derivationIndex = 0), createAccount(userWalletId = userWalletId, name = "Name", derivationIndex = 1), ), - expected = AccountList.Error.DuplicateAccountNames.left(), + expected = AccountList.Error.DuplicateAccountNames("Custom account names must be unique.").left(), ), ) } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt index f30bc1dfda..f677187f17 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt @@ -41,7 +41,7 @@ class AddCryptoPortfolioUseCaseTest { val accountList = AccountList.empty(userWallet) val updatedAccountList = (accountList + newAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() // Act val actual = useCase( @@ -56,7 +56,7 @@ class AddCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.saveAccounts(updatedAccountList) } @@ -69,7 +69,7 @@ class AddCryptoPortfolioUseCaseTest { val newAccount = createNewAccount() val newAccountList = (AccountList.empty(userWallet) + newAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId) } returns None + coEvery { crudRepository.getAccountListSync(userWalletId) } returns None coEvery { crudRepository.getUserWallet(userWalletId) } returns userWallet // Act @@ -85,7 +85,7 @@ class AddCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.getUserWallet(userWalletId) crudRepository.saveAccounts(newAccountList) } @@ -102,7 +102,7 @@ class AddCryptoPortfolioUseCaseTest { val newAccount = createNewAccount(derivationIndex = 21) - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() // Act val actual = useCase( @@ -119,7 +119,7 @@ class AddCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.getUserWallet(any()) @@ -133,7 +133,7 @@ class AddCryptoPortfolioUseCaseTest { val newAccount = createNewAccount() val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId) } throws exception // Act val actual = useCase( @@ -147,7 +147,7 @@ class AddCryptoPortfolioUseCaseTest { val expected = AddCryptoPortfolioUseCase.Error.DataOperationFailed(cause = exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.getUserWallet(any()) @@ -164,7 +164,7 @@ class AddCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception // Act @@ -180,7 +180,7 @@ class AddCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.saveAccounts(updatedAccountList) } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt index 1e442938fa..fe67edc618 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt @@ -41,7 +41,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val updatedAccountList = (accountList - account).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId) @@ -51,7 +51,7 @@ class ArchiveCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.saveAccounts(updatedAccountList) } } @@ -64,7 +64,7 @@ class ArchiveCryptoPortfolioUseCaseTest { derivationIndex = DerivationIndex.Main, ) - coEvery { crudRepository.getAccounts(userWalletId) } returns None + coEvery { crudRepository.getAccountListSync(userWalletId) } returns None // Act val actual = useCase(accountId) @@ -73,7 +73,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -87,7 +87,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId) } throws exception // Act val actual = useCase(accountId) @@ -96,7 +96,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -109,7 +109,7 @@ class ArchiveCryptoPortfolioUseCaseTest { derivationIndex = DerivationIndex(1).getOrNull()!!, ) - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId) @@ -118,7 +118,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountNotFound(accountId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -133,7 +133,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Save failed") - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception // Act @@ -144,7 +144,7 @@ class ArchiveCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.saveAccounts(updatedAccountList) } } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt index eb0019f93c..5fd7b2ccce 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt @@ -43,7 +43,7 @@ class GetArchivedAccountsUseCaseTest { mockk(), mockk(), ) - coEvery { crudRepository.getArchivedAccountsSync(userWalletId) } returns archivedAccounts.toOption() + coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } returns archivedAccounts.toOption() every { crudRepository.getArchivedAccounts(userWalletId) } returns flowOf(archivedAccounts) // Act @@ -54,7 +54,7 @@ class GetArchivedAccountsUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getArchivedAccountsSync(userWalletId) + crudRepository.getArchivedAccountListSync(userWalletId) crudRepository.getArchivedAccounts(userWalletId) } @@ -69,7 +69,7 @@ class GetArchivedAccountsUseCaseTest { mockk(), ) - coEvery { crudRepository.getArchivedAccountsSync(userWalletId) } returns None + coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } returns None every { crudRepository.getArchivedAccounts(userWalletId) } returns flowOf(archivedAccounts) // Act @@ -83,7 +83,7 @@ class GetArchivedAccountsUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerify(exactly = 1) { - crudRepository.getArchivedAccountsSync(userWalletId) + crudRepository.getArchivedAccountListSync(userWalletId) crudRepository.fetchArchivedAccounts(userWalletId) crudRepository.getArchivedAccounts(userWalletId) } @@ -98,7 +98,7 @@ class GetArchivedAccountsUseCaseTest { mockk(), ) - coEvery { crudRepository.getArchivedAccountsSync(userWalletId) } throws exception + coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } throws exception every { crudRepository.getArchivedAccounts(userWalletId) } returns flowOf(archivedAccounts) // Act @@ -112,7 +112,7 @@ class GetArchivedAccountsUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerify(exactly = 1) { - crudRepository.getArchivedAccountsSync(userWalletId) + crudRepository.getArchivedAccountListSync(userWalletId) crudRepository.fetchArchivedAccounts(userWalletId) crudRepository.getArchivedAccounts(userWalletId) } @@ -123,7 +123,7 @@ class GetArchivedAccountsUseCaseTest { // Arrange val exception = IllegalStateException("Fetch error") - coEvery { crudRepository.getArchivedAccountsSync(userWalletId) } returns None + coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } returns None every { crudRepository.getArchivedAccounts(userWalletId) } returns emptyFlow() coEvery { crudRepository.fetchArchivedAccounts(userWalletId) } throws exception @@ -139,7 +139,7 @@ class GetArchivedAccountsUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerify(exactly = 1) { - crudRepository.getArchivedAccountsSync(userWalletId) + crudRepository.getArchivedAccountListSync(userWalletId) crudRepository.fetchArchivedAccounts(userWalletId) crudRepository.getArchivedAccounts(userWalletId) } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt index 4c994aeb21..5f85fe95ef 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt @@ -1,6 +1,7 @@ package com.tangem.domain.account.usecase import arrow.core.left +import arrow.core.toOption import com.google.common.truth.Truth import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.models.account.DerivationIndex @@ -29,7 +30,7 @@ class GetUnoccupiedAccountIndexUseCaseTest { @Test fun `invoke should return next unoccupied index when repository returns count`() = runTest { // Arrange - coEvery { crudRepository.getTotalAccountsCount(userWalletId) } returns 3 + coEvery { crudRepository.getTotalAccountsCountSync(userWalletId) } returns 3.toOption() // Act val actual = useCase(userWalletId = userWalletId) @@ -38,14 +39,14 @@ class GetUnoccupiedAccountIndexUseCaseTest { val expected = DerivationIndex(4) Truth.assertThat(actual).isEqualTo(expected) - coVerify { crudRepository.getTotalAccountsCount(userWalletId) } + coVerify { crudRepository.getTotalAccountsCountSync(userWalletId) } } @Test fun `invoke should return error if repository throws exception`() = runTest { // Arrange val exception = IllegalStateException("Test error") - coEvery { crudRepository.getTotalAccountsCount(userWalletId) } throws exception + coEvery { crudRepository.getTotalAccountsCountSync(userWalletId) } throws exception // Act val actual = useCase(userWalletId = userWalletId) @@ -54,6 +55,6 @@ class GetUnoccupiedAccountIndexUseCaseTest { val expected = GetUnoccupiedAccountIndexUseCase.Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerify { crudRepository.getTotalAccountsCount(userWalletId) } + coVerify { crudRepository.getTotalAccountsCountSync(userWalletId) } } } \ No newline at end of file diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt new file mode 100644 index 0000000000..06b7c267d0 --- /dev/null +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt @@ -0,0 +1,301 @@ +package com.tangem.domain.account.usecase + +import arrow.core.none +import arrow.core.some +import com.google.common.truth.Truth +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import io.mockk.* +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class IsAccountsModeEnabledUseCaseTest { + + private val accountsCRUDRepository: AccountsCRUDRepository = mockk() + private val featureToggles: AccountsFeatureToggles = mockk() + + private val useCase = IsAccountsModeEnabledUseCase(accountsCRUDRepository, featureToggles) + + @AfterEach + fun tearDown() { + clearMocks(accountsCRUDRepository, featureToggles) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Invoke { + + @Test + fun `returns false when feature is disabled`() = runTest { + // Arrange + every { featureToggles.isFeatureEnabled } returns false + + // Act + val actual = useCase.invoke().firstOrNull() + + // Assert + Truth.assertThat(actual).isFalse() + + verify(exactly = 1) { featureToggles.isFeatureEnabled } + verify(inverse = true) { accountsCRUDRepository.getUserWallets() } + } + + @Test + fun `returns false when getUserWallets emits empty flow`() = runTest { + // Arrange + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWallets() } returns emptyFlow() + + // Act + val actual = useCase.invoke().firstOrNull() + + // Assert + Truth.assertThat(actual).isFalse() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWallets() + } + + verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(any()) } + } + + @Test + fun `returns false when getUserWallets emits one wallet with isMultiCurrency false`() = runTest { + // Arrange + val wallet = createUserWallet(isMultiCurrency = false) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet)) + + // Act + val actual = useCase.invoke().first() + + // Assert + Truth.assertThat(actual).isFalse() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWallets() + } + + verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(any()) } + } + + @Test + fun `returns true when getUserWallets emits one wallet with isMultiCurrency true`() = runTest { + // Arrange + val wallet = createUserWallet(isMultiCurrency = true) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet)) + every { accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) } returns flowOf(2.some()) + + // Act + val actual = useCase.invoke().first() + + // Assert + Truth.assertThat(actual).isTrue() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWallets() + accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) + } + } + + @Test + fun `returns false when getUserWallets emits one wallet with isMultiCurrency true and None counts`() = runTest { + // Arrange + val wallet = createUserWallet(isMultiCurrency = true) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet)) + every { accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) } returns flowOf(none()) + + // Act + val actual = useCase.invoke().first() + + // Assert + Truth.assertThat(actual).isFalse() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWallets() + accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) + } + } + + @Test + fun `returns true when getUserWallets emits two wallets, one isMultiCurrency false, one true`() = runTest { + // Arrange + val wallet1 = createUserWallet(isMultiCurrency = false) + val wallet2 = createUserWallet(isMultiCurrency = true) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet1, wallet2)) + every { accountsCRUDRepository.getTotalAccountsCount(wallet2.walletId) } returns flowOf(2.some()) + + // Act + val actual = useCase.invoke().first() + + // Assert + Truth.assertThat(actual).isTrue() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWallets() + accountsCRUDRepository.getTotalAccountsCount(wallet2.walletId) + } + + verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(wallet1.walletId) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class InvokeSync { + + @Test + fun `returns false when feature is disabled`() = runTest { + // Arrange + every { featureToggles.isFeatureEnabled } returns false + + // Act + val actual = useCase.invokeSync() + + // Assert + Truth.assertThat(actual).isFalse() + + verify(exactly = 1) { featureToggles.isFeatureEnabled } + verify(inverse = true) { accountsCRUDRepository.getUserWalletsSync() } + } + + @Test + fun `returns false when getUserWalletsSync returns empty list`() = runTest { + // Arrange + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWalletsSync() } returns emptyList() + + // Act + val actual = useCase.invokeSync() + + // Assert + Truth.assertThat(actual).isFalse() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWalletsSync() + } + + coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(any()) } + } + + @Test + fun `returns false when getUserWalletsSync returns one wallet with isMultiCurrency false`() = runTest { + // Arrange + val wallet = createUserWallet(isMultiCurrency = false) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet) + + // Act + val actual = useCase.invokeSync() + + // Assert + Truth.assertThat(actual).isFalse() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWalletsSync() + } + + coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(any()) } + } + + @Test + fun `returns true when getUserWalletsSync returns one wallet with isMultiCurrency true`() = runTest { + // Arrange + val wallet = createUserWallet(isMultiCurrency = true) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet) + coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) } returns 2.some() + + // Act + val actual = useCase.invokeSync() + + // Assert + Truth.assertThat(actual).isTrue() + + coVerifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWalletsSync() + accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) + } + } + + @Test + fun `returns false when getUserWalletsSync returns multi wallet with None counts`() = runTest { + // Arrange + val wallet = createUserWallet(isMultiCurrency = true) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet) + coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) } returns none() + + // Act + val actual = useCase.invokeSync() + + // Assert + Truth.assertThat(actual).isFalse() + + coVerifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWalletsSync() + accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) + } + } + + @Test + fun `returns true when getUserWalletsSync returns multi and single wallets`() = runTest { + // Arrange + val wallet1 = createUserWallet(isMultiCurrency = false) + val wallet2 = createUserWallet(isMultiCurrency = true) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet1, wallet2) + coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet2.walletId) } returns 2.some() + + // Act + val actual = useCase.invokeSync() + + // Assert + Truth.assertThat(actual).isTrue() + + coVerifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWalletsSync() + accountsCRUDRepository.getTotalAccountsCountSync(wallet2.walletId) + } + + coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(wallet1.walletId) } + } + } + + private fun createUserWallet(isMultiCurrency: Boolean): UserWallet = mockk { + every { this@mockk.walletId } returns UserWalletId(stringValue = "011") + every { this@mockk.isMultiCurrency } returns isMultiCurrency + } +} \ No newline at end of file diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt index 7c8f1a847f..eb4e423c11 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt @@ -52,8 +52,8 @@ class RecoverCryptoPortfolioUseCaseTest { val updatedAccountList = (accountList + account).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() - coEvery { crudRepository.getArchivedAccount(account.accountId) } returns archivedAccount.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns archivedAccount.toOption() // Act val actual = useCase(account.accountId) @@ -63,8 +63,8 @@ class RecoverCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) - crudRepository.getArchivedAccount(account.accountId) + crudRepository.getAccountListSync(userWalletId) + crudRepository.getArchivedAccountSync(account.accountId) crudRepository.saveAccounts(updatedAccountList) } } @@ -77,7 +77,7 @@ class RecoverCryptoPortfolioUseCaseTest { derivationIndex = DerivationIndex.Main, ) - coEvery { crudRepository.getAccounts(userWalletId) } returns None + coEvery { crudRepository.getAccountListSync(userWalletId) } returns None // Act val actual = useCase(accountId) @@ -86,9 +86,9 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { - crudRepository.getArchivedAccount(any()) + crudRepository.getArchivedAccountSync(any()) crudRepository.saveAccounts(any()) } } @@ -102,7 +102,7 @@ class RecoverCryptoPortfolioUseCaseTest { ) val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId) } throws exception // Act val actual = useCase(accountId) @@ -111,9 +111,9 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { - crudRepository.getArchivedAccount(any()) + crudRepository.getArchivedAccountSync(any()) crudRepository.saveAccounts(any()) } } @@ -125,8 +125,8 @@ class RecoverCryptoPortfolioUseCaseTest { val accountList = AccountList.empty(userWallet) val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() - coEvery { crudRepository.getArchivedAccount(account.accountId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getArchivedAccountSync(account.accountId) } throws exception // Act val actual = useCase(account.accountId) @@ -136,8 +136,8 @@ class RecoverCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) - crudRepository.getArchivedAccount(account.accountId) + crudRepository.getAccountListSync(userWalletId) + crudRepository.getArchivedAccountSync(account.accountId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -148,8 +148,8 @@ class RecoverCryptoPortfolioUseCaseTest { val account = createAccount(userWalletId) val accountList = AccountList.empty(userWallet) - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() - coEvery { crudRepository.getArchivedAccount(account.accountId) } returns None + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns None // Act val actual = useCase(account.accountId) @@ -159,8 +159,8 @@ class RecoverCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) - crudRepository.getArchivedAccount(account.accountId) + crudRepository.getAccountListSync(userWalletId) + crudRepository.getArchivedAccountSync(account.accountId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -182,8 +182,8 @@ class RecoverCryptoPortfolioUseCaseTest { val updatedAccountList = (accountList + account).getOrNull()!! val exception = IllegalStateException("Save failed") - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() - coEvery { crudRepository.getArchivedAccount(account.accountId) } returns archivedAccount.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns archivedAccount.toOption() coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception // Act @@ -194,8 +194,8 @@ class RecoverCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) - crudRepository.getArchivedAccount(account.accountId) + crudRepository.getAccountListSync(userWalletId) + crudRepository.getArchivedAccountSync(account.accountId) crudRepository.saveAccounts(updatedAccountList) } } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt index f1ba896a7c..5d6625fbe0 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt @@ -48,7 +48,7 @@ class UpdateCryptoPortfolioUseCaseTest { val updatedAccount = accountList.mainAccount.copy(accountName = newAccountName) val updatedAccountList = (accountList + updatedAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId, accountName = newAccountName) @@ -58,7 +58,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId = userWalletId) + crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } } @@ -76,7 +76,7 @@ class UpdateCryptoPortfolioUseCaseTest { val updatedAccount = accountList.mainAccount.copy(icon = newAccountIcon) val updatedAccountList = (accountList + updatedAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId, icon = newAccountIcon) @@ -86,7 +86,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId = userWalletId) + crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } } @@ -105,7 +105,7 @@ class UpdateCryptoPortfolioUseCaseTest { val updatedAccount = accountList.mainAccount.copy(accountName = newAccountName, icon = newAccountIcon) val updatedAccountList = (accountList + updatedAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId, accountName = newAccountName, icon = newAccountIcon) @@ -115,7 +115,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId = userWalletId) + crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } } @@ -126,7 +126,7 @@ class UpdateCryptoPortfolioUseCaseTest { val accountList = AccountList.empty(userWallet = userWallet) val accountId = accountList.mainAccount.accountId - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId) @@ -136,7 +136,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerify(inverse = true) { - crudRepository.getAccounts(userWalletId = any()) + crudRepository.getAccountListSync(userWalletId = any()) crudRepository.saveAccounts(accountList = any()) } } @@ -151,7 +151,7 @@ class UpdateCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Test exception") - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } throws exception // Act val actual = useCase(accountId = accountId, accountName = newAccountName) @@ -160,7 +160,7 @@ class UpdateCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(cause = exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId = userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) } } @@ -175,7 +175,7 @@ class UpdateCryptoPortfolioUseCaseTest { val newAccountName = AccountName("New name").getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList // Act val actual = useCase(accountId = accountId, accountName = newAccountName) @@ -184,7 +184,7 @@ class UpdateCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId = userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) } } @@ -199,7 +199,7 @@ class UpdateCryptoPortfolioUseCaseTest { val newAccountName = AccountName("New name").getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId, accountName = newAccountName) @@ -208,7 +208,7 @@ class UpdateCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountNotFound(accountId = accountId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId = userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) } } @@ -224,7 +224,7 @@ class UpdateCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Save failed") - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() coEvery { crudRepository.saveAccounts(accountList = updatedAccountList) } throws exception // Act @@ -235,7 +235,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId = userWalletId) + crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } } diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/common/extensions/CardSdk.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/common/extensions/CardSdk.kt index a189ade273..4606c72970 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/common/extensions/CardSdk.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/common/extensions/CardSdk.kt @@ -19,6 +19,11 @@ import com.tangem.domain.models.wallet.UserWallet val FirmwareVersion.Companion.SolanaTokensAvailable get() = FirmwareVersion(4, 52) +val hotWalletExcludedBlockchains = setOf( + Blockchain.Hedera, + Blockchain.HederaTestnet, +) + fun UserWallet.supportedBlockchains(excludedBlockchains: ExcludedBlockchains): List { return when (this) { is UserWallet.Cold -> { @@ -28,7 +33,9 @@ fun UserWallet.supportedBlockchains(excludedBlockchains: ExcludedBlockchains): L ) } is UserWallet.Hot -> { - Blockchain.entries.filter { it.isTestnet().not() && it !in excludedBlockchains } + Blockchain.entries.filter { + it.isTestnet().not() && it !in excludedBlockchains && it !in hotWalletExcludedBlockchains + } } } } @@ -80,7 +87,7 @@ fun UserWallet.canHandleToken(supportedTokens: List, blockchain: Blo cardTypesResolver = scanResponse.cardTypesResolver, ) } - is UserWallet.Hot -> blockchain in supportedTokens + is UserWallet.Hot -> blockchain in supportedTokens && blockchain !in hotWalletExcludedBlockchains } } @@ -96,6 +103,7 @@ fun UserWallet.canHandleToken(blockchain: Blockchain, excludedBlockchains: Exclu is UserWallet.Hot -> blockchain.isTestnet().not() && blockchain !in excludedBlockchains && + blockchain !in hotWalletExcludedBlockchains && blockchain.canHandleTokens() } } @@ -110,7 +118,8 @@ fun UserWallet.canHandleBlockchain(blockchain: Blockchain, excludedBlockchains: ) } is UserWallet.Hot -> blockchain.isTestnet().not() && - blockchain !in excludedBlockchains + blockchain !in excludedBlockchains && + blockchain !in hotWalletExcludedBlockchains } } diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowProducer.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowProducer.kt index 92e95f552d..8984ab4e36 100644 --- a/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowProducer.kt +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowProducer.kt @@ -1,5 +1,6 @@ package com.tangem.domain.core.flow +import arrow.core.Option import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.retryWhen @@ -14,7 +15,7 @@ import kotlinx.coroutines.flow.retryWhen interface FlowProducer { /** Fallback value if [Flow] throws exception */ - val fallback: Data + val fallback: Option /** Produce [Flow] */ fun produce(): Flow @@ -22,7 +23,7 @@ interface FlowProducer { /** Produce [Flow] with retry mechanism */ fun produceWithFallback(): Flow { return produce().retryWhen { _, _ -> - emit(value = fallback) + fallback.onSome { emit(value = it) } delay(timeMillis = 2000) diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/UserWalletsListRepository.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/UserWalletsListRepository.kt index fbcfeb9a0c..c3704524c2 100644 --- a/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/UserWalletsListRepository.kt +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/UserWalletsListRepository.kt @@ -7,6 +7,7 @@ import com.tangem.domain.core.wallets.error.SaveWalletError import com.tangem.domain.core.wallets.error.SelectWalletError import com.tangem.domain.core.wallets.error.SetLockError import com.tangem.domain.core.wallets.error.UnlockWalletError +import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.StateFlow @@ -133,10 +134,10 @@ interface UserWalletsListRepository { data object NoLock : LockMethod() } - enum class UnlockMethod { - Biometric, - AccessCode, - Scan, + sealed class UnlockMethod { + data object Biometric : UnlockMethod() + data object AccessCode : UnlockMethod() + data class Scan(val scanResponse: ScanResponse? = null) : UnlockMethod() } } diff --git a/domain/core/src/test/kotlin/com/tangem/domain/core/flow/FlowCachingSupplierTest.kt b/domain/core/src/test/kotlin/com/tangem/domain/core/flow/FlowCachingSupplierTest.kt index 42822e7796..674ad5a9d6 100644 --- a/domain/core/src/test/kotlin/com/tangem/domain/core/flow/FlowCachingSupplierTest.kt +++ b/domain/core/src/test/kotlin/com/tangem/domain/core/flow/FlowCachingSupplierTest.kt @@ -1,5 +1,7 @@ package com.tangem.domain.core.flow +import arrow.core.Option +import arrow.core.some import com.google.common.truth.Truth import io.mockk.every import io.mockk.mockk @@ -105,8 +107,8 @@ internal class FlowCachingSupplierTest { private class MockFlowProducer(private val params: Int) : FlowProducer { - override val fallback: String - get() = "fallback" + override val fallback: Option + get() = "fallback".some() override fun produce(): Flow = flowOf("test_$params") @@ -125,8 +127,8 @@ internal class FlowCachingSupplierTest { private class MockErrorFlowProducer : FlowProducer { - override val fallback: String - get() = "fallback" + override val fallback: Option + get() = "fallback".some() override fun produce(): Flow = flow { throw IllegalStateException() diff --git a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/CardInfo.kt b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/CardInfo.kt deleted file mode 100644 index 527416d699..0000000000 --- a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/CardInfo.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.domain.feedback.models - -import com.tangem.domain.models.wallet.UserWalletId -import kotlinx.serialization.Serializable - -@Serializable -data class CardInfo( - val userWalletId: UserWalletId?, - val cardId: String, - val firmwareVersion: String, - val cardsCount: String, - val cardBlockchain: String?, - val signedHashesList: List, - val isImported: Boolean, - val isStart2Coin: Boolean, - val isVisa: Boolean, -) { - - @Serializable - data class SignedHashes(val curve: String, val total: String?) -} \ No newline at end of file diff --git a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt index 7853427ca5..5649d73a23 100644 --- a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt +++ b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt @@ -9,32 +9,32 @@ import com.tangem.domain.visa.model.VisaTxDetails */ sealed interface FeedbackEmailType { - val cardInfo: CardInfo? + val walletMetaInfo: WalletMetaInfo? /** User initiate request yourself. Example, button on DetailsScreen or OnboardingScreen */ - data class DirectUserRequest(override val cardInfo: CardInfo) : FeedbackEmailType + data class DirectUserRequest(override val walletMetaInfo: WalletMetaInfo) : FeedbackEmailType /** User rate the app as "can be better" */ - data class RateCanBeBetter(override val cardInfo: CardInfo) : FeedbackEmailType + data class RateCanBeBetter(override val walletMetaInfo: WalletMetaInfo) : FeedbackEmailType /** User has problem with scanning */ data object ScanningProblem : FeedbackEmailType { - override val cardInfo: CardInfo? = null + override val walletMetaInfo: WalletMetaInfo? = null } /** User has problem with sending transaction */ - data class TransactionSendingProblem(override val cardInfo: CardInfo) : FeedbackEmailType + data class TransactionSendingProblem(override val walletMetaInfo: WalletMetaInfo) : FeedbackEmailType /** User has problem with staking */ data class StakingProblem( - override val cardInfo: CardInfo, + override val walletMetaInfo: WalletMetaInfo, val validatorName: String?, val transactionTypes: List, val unsignedTransactions: List, ) : FeedbackEmailType data class SwapProblem( - override val cardInfo: CardInfo, + override val walletMetaInfo: WalletMetaInfo, val providerName: String, val txId: String, ) : FeedbackEmailType @@ -46,23 +46,23 @@ sealed interface FeedbackEmailType { * @property currencyName currency name */ data class CurrencyDescriptionError(val currencyId: String, val currencyName: String) : FeedbackEmailType { - override val cardInfo: CardInfo? = null + override val walletMetaInfo: WalletMetaInfo? = null } - data class PreActivatedWallet(override val cardInfo: CardInfo) : FeedbackEmailType + data class PreActivatedWallet(override val walletMetaInfo: WalletMetaInfo) : FeedbackEmailType data object CardAttestationFailed : FeedbackEmailType { - override val cardInfo: CardInfo? = null + override val walletMetaInfo: WalletMetaInfo? = null } sealed class Visa : FeedbackEmailType { - data class DirectUserRequest(override val cardInfo: CardInfo) : Visa() + data class DirectUserRequest(override val walletMetaInfo: WalletMetaInfo) : Visa() - data class Activation(override val cardInfo: CardInfo) : Visa() + data class Activation(override val walletMetaInfo: WalletMetaInfo) : Visa() data class Dispute( val visaTxDetails: VisaTxDetails, - override val cardInfo: CardInfo, + override val walletMetaInfo: WalletMetaInfo, ) : Visa() } } \ No newline at end of file diff --git a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/WalletMetaInfo.kt b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/WalletMetaInfo.kt new file mode 100644 index 0000000000..8b9f46f512 --- /dev/null +++ b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/WalletMetaInfo.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.feedback.models + +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.Serializable + +@Serializable +data class WalletMetaInfo( + val userWalletId: UserWalletId?, + val hotWalletIsBackedUp: Boolean? = null, + val cardId: String? = null, + val firmwareVersion: String? = null, + val cardsCount: String? = null, + val cardBlockchain: String? = null, + val signedHashesList: List? = null, + val isImported: Boolean? = null, + val isStart2Coin: Boolean? = null, + val isVisa: Boolean? = null, +) { + + @Serializable + data class SignedHashes(val curve: String, val total: String?) +} \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt index 7d0f5aa49f..e5f65f4c77 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt @@ -44,13 +44,14 @@ internal class FeedbackDataBuilder { builder.appendKeyValue("Total saved wallets", userWalletsInfo.totalUserWallets.toString()) } - fun addCardInfo(cardInfo: CardInfo) { - builder.appendKeyValue("Card ID", cardInfo.cardId) - builder.appendKeyValue("Firmware version", cardInfo.firmwareVersion) - builder.appendKeyValue("Linked cards count", cardInfo.cardsCount) - builder.appendKeyValue("Has seed phrase", cardInfo.isImported.toString()) - builder.appendKeyValue("Card Blockchain", cardInfo.cardBlockchain) - builder.appendSignedHashes(cardInfo.signedHashesList) + fun addUserWalletMetaInfo(walletMetaInfo: WalletMetaInfo) { + builder.appendKeyValue("Mobile Wallet is backed up", walletMetaInfo.hotWalletIsBackedUp?.toString()) + builder.appendKeyValue("Card ID", walletMetaInfo.cardId) + builder.appendKeyValue("Firmware version", walletMetaInfo.firmwareVersion) + builder.appendKeyValue("Linked cards count", walletMetaInfo.cardsCount) + builder.appendKeyValue("Has seed phrase", walletMetaInfo.isImported?.toString()) + builder.appendKeyValue("Card Blockchain", walletMetaInfo.cardBlockchain) + walletMetaInfo.signedHashesList?.let { builder.appendSignedHashes(it) } } fun addBlockchainInfoList(blockchainInfoList: List) { @@ -146,7 +147,7 @@ internal class FeedbackDataBuilder { append("$keyValuePrefix$value\n") } - private fun StringBuilder.appendSignedHashes(signedHashesList: List) { + private fun StringBuilder.appendSignedHashes(signedHashesList: List) { signedHashesList.forEach { appendKeyValue("Signed hashes [${it.curve}]", it.total) } diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/GetCardInfoUseCase.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/GetCardInfoUseCase.kt deleted file mode 100644 index f2ecf40fca..0000000000 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/GetCardInfoUseCase.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.domain.feedback - -import arrow.core.Either -import arrow.core.Either.Companion.catch -import com.tangem.domain.feedback.models.CardInfo -import com.tangem.domain.feedback.repository.FeedbackRepository -import com.tangem.domain.models.scan.ScanResponse - -/** - * UseCase for creating 'CardInfo' - * - * @property feedbackRepository feedback repository - * -[REDACTED_AUTHOR] - */ -class GetCardInfoUseCase( - private val feedbackRepository: FeedbackRepository, -) { - - operator fun invoke(scanResponse: ScanResponse): Either = catch { - feedbackRepository.getCardInfo(scanResponse) - } -} \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/GetWalletMetaInfoUseCase.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/GetWalletMetaInfoUseCase.kt new file mode 100644 index 0000000000..a3bd2788d4 --- /dev/null +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/GetWalletMetaInfoUseCase.kt @@ -0,0 +1,28 @@ +package com.tangem.domain.feedback + +import arrow.core.Either +import arrow.core.Either.Companion.catch +import com.tangem.domain.feedback.models.WalletMetaInfo +import com.tangem.domain.feedback.repository.FeedbackRepository +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWalletId + +/** + * UseCase for creating 'UserWalletMetaInfo' from [UserWalletId] or [ScanResponse] + * + * @property feedbackRepository feedback repository + * +[REDACTED_AUTHOR] + */ +class GetWalletMetaInfoUseCase( + private val feedbackRepository: FeedbackRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId): Either = catch { + feedbackRepository.getUserWalletMetaInfo(userWalletId) + } + + operator fun invoke(scanResponse: ScanResponse): Either = catch { + feedbackRepository.getUserWalletMetaInfo(scanResponse) + } +} \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt index 86cd5c8945..a8d5177b41 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt @@ -37,8 +37,8 @@ class SendFeedbackEmailUseCase( private fun getAddress(type: FeedbackEmailType): String { return when { - type is FeedbackEmailType.Visa || type.cardInfo?.isVisa == true -> TANGEM_VISA_SUPPORT_EMAIL - type.cardInfo?.isStart2Coin == true -> START2COIN_SUPPORT_EMAIL + type is FeedbackEmailType.Visa || type.walletMetaInfo?.isVisa == true -> TANGEM_VISA_SUPPORT_EMAIL + type.walletMetaInfo?.isStart2Coin == true -> START2COIN_SUPPORT_EMAIL else -> TANGEM_SUPPORT_EMAIL } } diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt index 55ee6e8be1..3661f00b48 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt @@ -7,7 +7,9 @@ import java.io.File interface FeedbackRepository { - fun getCardInfo(scanResponse: ScanResponse): CardInfo + suspend fun getUserWalletMetaInfo(userWalletId: UserWalletId): WalletMetaInfo + + fun getUserWalletMetaInfo(scanResponse: ScanResponse): WalletMetaInfo fun getUserWalletsInfo(userWalletId: UserWalletId?): UserWalletsInfo diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt index b976293f59..5a94a88857 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt @@ -1,7 +1,7 @@ package com.tangem.domain.feedback.utils import com.tangem.domain.feedback.FeedbackDataBuilder -import com.tangem.domain.feedback.models.CardInfo +import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.repository.FeedbackRepository import com.tangem.domain.visa.model.VisaTxDetails @@ -20,37 +20,40 @@ internal class EmailMessageBodyResolver( /** Resolve email message body by [type] */ suspend fun resolve(type: FeedbackEmailType): String = with(FeedbackDataBuilder()) { when (type) { - is FeedbackEmailType.DirectUserRequest -> addUserRequestBody(type.cardInfo) - is FeedbackEmailType.RateCanBeBetter -> addCardAndPhoneInfo(type.cardInfo) - is FeedbackEmailType.TransactionSendingProblem -> addTransactionSendingProblemBody(type.cardInfo) + is FeedbackEmailType.DirectUserRequest -> addUserRequestBody(type.walletMetaInfo) + is FeedbackEmailType.RateCanBeBetter -> addCardAndPhoneInfo(type.walletMetaInfo) + is FeedbackEmailType.TransactionSendingProblem -> addTransactionSendingProblemBody(type.walletMetaInfo) is FeedbackEmailType.StakingProblem -> addStakingProblemBody(type) is FeedbackEmailType.SwapProblem -> addSwapProblemBody(type) is FeedbackEmailType.CurrencyDescriptionError -> addTokenInfo(type) - is FeedbackEmailType.PreActivatedWallet -> addUserRequestBody(type.cardInfo) + is FeedbackEmailType.PreActivatedWallet -> addUserRequestBody(type.walletMetaInfo) is FeedbackEmailType.ScanningProblem, is FeedbackEmailType.CardAttestationFailed, -> addPhoneInfoBody() - is FeedbackEmailType.Visa.Activation -> addUserRequestBody(type.cardInfo) - is FeedbackEmailType.Visa.DirectUserRequest -> addUserRequestBody(type.cardInfo) - is FeedbackEmailType.Visa.Dispute -> addVisaRequestBody(type.cardInfo, type.visaTxDetails) + is FeedbackEmailType.Visa.Activation -> addUserRequestBody(type.walletMetaInfo) + is FeedbackEmailType.Visa.DirectUserRequest -> addUserRequestBody(type.walletMetaInfo) + is FeedbackEmailType.Visa.Dispute -> addVisaRequestBody(type.walletMetaInfo, type.visaTxDetails) } return build() } - private suspend fun FeedbackDataBuilder.addVisaRequestBody(cardInfo: CardInfo, visaTxDetails: VisaTxDetails) { - addUserRequestBody(cardInfo) + private suspend fun FeedbackDataBuilder.addVisaRequestBody( + walletMetaInfo: WalletMetaInfo, + visaTxDetails: VisaTxDetails, + ) { + addUserRequestBody(walletMetaInfo) addDelimiter() addVisaTxInfo(visaTxDetails) } - private suspend fun FeedbackDataBuilder.addUserRequestBody(cardInfo: CardInfo) { - addUserWalletsInfo(userWalletsInfo = feedbackRepository.getUserWalletsInfo(cardInfo.userWalletId)) + private suspend fun FeedbackDataBuilder.addUserRequestBody(walletMetaInfo: WalletMetaInfo) { + addUserWalletsInfo(userWalletsInfo = feedbackRepository.getUserWalletsInfo(walletMetaInfo.userWalletId)) addDelimiter() - addCardInfo(cardInfo) + addUserWalletMetaInfo(walletMetaInfo) addDelimiter() - val userWalletId = cardInfo.userWalletId + val userWalletId = walletMetaInfo.userWalletId if (userWalletId != null) { val blockchainInfoList = feedbackRepository.getBlockchainInfoList(userWalletId) @@ -68,11 +71,11 @@ internal class EmailMessageBodyResolver( addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo()) } - private suspend fun FeedbackDataBuilder.addTransactionSendingProblemBody(cardInfo: CardInfo) { - addCardInfo(cardInfo) + private suspend fun FeedbackDataBuilder.addTransactionSendingProblemBody(walletMetaInfo: WalletMetaInfo) { + addUserWalletMetaInfo(walletMetaInfo) addDelimiter() - val userWalletId = requireNotNull(cardInfo.userWalletId) { "UserWalletId must be not null" } + val userWalletId = requireNotNull(walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) val blockchainInfo = blockchainError?.let { feedbackRepository.getBlockchainInfo( @@ -91,10 +94,10 @@ internal class EmailMessageBodyResolver( } private suspend fun FeedbackDataBuilder.addStakingProblemBody(type: FeedbackEmailType.StakingProblem) { - addCardInfo(type.cardInfo) + addUserWalletMetaInfo(type.walletMetaInfo) addDelimiter() - val userWalletId = requireNotNull(type.cardInfo.userWalletId) { "UserWalletId must be not null" } + val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) val blockchainInfo = blockchainError?.let { feedbackRepository.getBlockchainInfo( @@ -120,10 +123,10 @@ internal class EmailMessageBodyResolver( } private suspend fun FeedbackDataBuilder.addSwapProblemBody(type: FeedbackEmailType.SwapProblem) { - addCardInfo(type.cardInfo) + addUserWalletMetaInfo(type.walletMetaInfo) addDelimiter() - val userWalletId = requireNotNull(type.cardInfo.userWalletId) { "UserWalletId must be not null" } + val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) val blockchainInfo = blockchainError?.let { feedbackRepository.getBlockchainInfo( @@ -144,8 +147,8 @@ internal class EmailMessageBodyResolver( addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo()) } - private fun FeedbackDataBuilder.addCardAndPhoneInfo(cardInfo: CardInfo) { - addCardInfo(cardInfo) + private fun FeedbackDataBuilder.addCardAndPhoneInfo(walletMetaInfo: WalletMetaInfo) { + addUserWalletMetaInfo(walletMetaInfo) addDelimiter() addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo()) } diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt index 849d1ce0c4..490c6611dd 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt @@ -18,7 +18,7 @@ internal class EmailSubjectResolver(private val resources: Resources) { fun resolve(type: FeedbackEmailType): String { return when (type) { is FeedbackEmailType.DirectUserRequest -> { - if (type.cardInfo.isStart2Coin) { + if (type.walletMetaInfo.isStart2Coin == true) { resources.getStringSafe(R.string.feedback_subject_support) } else { resources.getStringSafe(R.string.feedback_subject_support_tangem) diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt b/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt index 9dbf3c435b..9ea3f6e4db 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt @@ -21,4 +21,5 @@ object NetworkLogConfig { object AnalyticsHandlersLogConfig { val firebase: Boolean = BuildConfig.LOG_ENABLED val amplitude: Boolean = BuildConfig.LOG_ENABLED + val appsflyer: Boolean = BuildConfig.LOG_ENABLED } \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt b/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt index 1bfffa0dc3..677c7b354a 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt @@ -7,18 +7,26 @@ import java.math.BigDecimal import com.tangem.blockchain.common.Amount as SdkAmount /** Converts `BigDecimal` [cryptoCurrency] to [SdkAmount] */ -fun BigDecimal.convertToSdkAmount(cryptoCurrency: CryptoCurrency): SdkAmount = SdkAmount( +fun BigDecimal.convertToSdkAmount( + cryptoCurrency: CryptoCurrency, + amountType: AmountType = getAmountTypeFromCryptoCurrency(cryptoCurrency), +): SdkAmount = SdkAmount( currencySymbol = cryptoCurrency.symbol, value = this, decimals = cryptoCurrency.decimals, - type = when (cryptoCurrency) { - is CryptoCurrency.Coin -> AmountType.Coin - is CryptoCurrency.Token -> AmountType.Token( - token = Token( - symbol = cryptoCurrency.symbol, - contractAddress = cryptoCurrency.contractAddress, - decimals = cryptoCurrency.decimals, - ), - ) - }, -) \ No newline at end of file + type = amountType, +) + +/** + * Converts [CryptoCurrency] to [AmountType] based on its type + */ +private fun getAmountTypeFromCryptoCurrency(cryptoCurrency: CryptoCurrency) = when (cryptoCurrency) { + is CryptoCurrency.Coin -> AmountType.Coin + is CryptoCurrency.Token -> AmountType.Token( + token = Token( + symbol = cryptoCurrency.symbol, + contractAddress = cryptoCurrency.contractAddress, + decimals = cryptoCurrency.decimals, + ), + ) +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt index b376be0db1..d3962fbd50 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt @@ -162,7 +162,7 @@ sealed interface Account { userWalletId = userWalletId, derivationIndex = derivationIndex, ), - accountName = AccountName.Main, + accountName = AccountName.DefaultMain, icon = CryptoPortfolioIcon.ofMainAccount(userWalletId), derivationIndex = derivationIndex, cryptoCurrencies = cryptoCurrencies, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt index a9cf874078..3133206dd3 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt @@ -1,5 +1,8 @@ package com.tangem.domain.models.account +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure import com.tangem.common.extensions.toByteArray import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.extensions.toHexString @@ -18,9 +21,39 @@ data class AccountId private constructor( val userWalletId: UserWalletId, ) { + sealed interface Error { + + val tag: String + get() = this::class.simpleName ?: "AccountId.Error" + + data object Empty : Error { + override fun toString(): String = "$tag: Account ID cannot be blank" + } + + data object InvalidFormat : Error { + override fun toString(): String = "$tag: Account ID must be a 64-character hexadecimal string" + } + } + companion object { private val sha256Digest: MessageDigest by lazy { MessageDigest.getInstance("SHA-256") } + private val hexRegex = Regex("^[a-fA-F0-9]{64}$") + + /** + * Creates a unique account identifier for a crypto portfolio + * + * @param userWalletId the identifier of the user wallet + * @param value the unique string value representing the account + * + * @return an [Either] containing the [AccountId] on success, or an [Error] on failure + */ + fun forCryptoPortfolio(userWalletId: UserWalletId, value: String): Either = either { + ensure(value.isNotBlank()) { Error.Empty } + ensure(value.matches(hexRegex)) { Error.InvalidFormat } + + AccountId(value = value, userWalletId = userWalletId) + } /** * Creates a unique account identifier for a crypto portfolio diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountName.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountName.kt index 29fe653a8b..ead33f51a3 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountName.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountName.kt @@ -8,14 +8,44 @@ import kotlinx.serialization.Serializable /** * Represents an account name * - * @property value the validated account name as a string - * [REDACTED_AUTHOR] */ @Serializable -data class AccountName private constructor( - val value: String, -) { +sealed interface AccountName { + + /** + * Represents the default main account name. + * If the user renames the main account, it will be converted to a [Custom] account name. + */ + @Serializable + data object DefaultMain : AccountName + + /** + * Represents a custom account name provided by the user + * + * @property value the string value of the custom account name + */ + @Serializable + data class Custom private constructor(val value: String) : AccountName { + + companion object { + + /** + * Factory method to create an [AccountName.Custom] instance. + * Validates the input string to ensure it is not blank and does not exceed the maximum length. + * + * @param value the input string representing the account name + */ + operator fun invoke(value: String): Either = either { + val trimmedValue = value.trim() + + ensure(trimmedValue.isNotBlank()) { Error.Empty } + ensure(trimmedValue.length <= MAX_LENGTH) { Error.ExceedsMaxLength } + + Custom(value = trimmedValue) + } + } + } /** * Represents possible validation errors @@ -44,26 +74,14 @@ data class AccountName private constructor( companion object { - private const val MAIN_ACCOUNT_NAME = "Main Account" private const val MAX_LENGTH = 20 - /** Default name for the main account */ - val Main: AccountName - get() = AccountName(value = MAIN_ACCOUNT_NAME) - /** - * Factory method to create an `AccountName` instance. + * Factory method to create an [AccountName] instance. * Validates the input string to ensure it is not blank and does not exceed the maximum length. * * @param value the input string representing the account name */ - operator fun invoke(value: String): Either = either { - val trimmedValue = value.trim() - - ensure(trimmedValue.isNotBlank()) { Error.Empty } - ensure(trimmedValue.length <= MAX_LENGTH) { Error.ExceedsMaxLength } - - AccountName(value = trimmedValue) - } + operator fun invoke(value: String): Either = Custom(value) } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyStatus.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyStatus.kt index 64e478276e..0663a46f9f 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyStatus.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyStatus.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.serialization.SerializedBigDecimal import com.tangem.domain.models.staking.YieldBalance +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import kotlinx.serialization.Serializable /** @@ -58,6 +59,12 @@ data class CryptoCurrencyStatus( /** Staking yield balance */ val yieldBalance: YieldBalance? get() = null + /** + * !!! DO NOT CONFUSE with STAKING YIELD BALANCE + * Yield supply status + */ + val yieldSupplyStatus: YieldSupplyStatus? get() = null + /** Sources */ val sources: Sources get() = Sources() } @@ -157,6 +164,7 @@ data class CryptoCurrencyStatus( override val fiatRate: SerializedBigDecimal, override val priceChange: SerializedBigDecimal, override val yieldBalance: YieldBalance?, + override val yieldSupplyStatus: YieldSupplyStatus?, override val hasCurrentNetworkTransactions: Boolean, override val pendingTransactions: Set, override val networkAddress: NetworkAddress, @@ -184,6 +192,7 @@ data class CryptoCurrencyStatus( override val fiatRate: SerializedBigDecimal?, override val priceChange: SerializedBigDecimal?, override val yieldBalance: YieldBalance?, + override val yieldSupplyStatus: YieldSupplyStatus?, override val hasCurrentNetworkTransactions: Boolean, override val pendingTransactions: Set, override val networkAddress: NetworkAddress, @@ -205,6 +214,7 @@ data class CryptoCurrencyStatus( data class NoQuote( override val amount: SerializedBigDecimal, override val yieldBalance: YieldBalance?, + override val yieldSupplyStatus: YieldSupplyStatus?, override val hasCurrentNetworkTransactions: Boolean, override val pendingTransactions: Set, override val networkAddress: NetworkAddress, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/NetworkStatus.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/NetworkStatus.kt index 9911c172f3..a98e61e40b 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/network/NetworkStatus.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/NetworkStatus.kt @@ -2,6 +2,7 @@ package com.tangem.domain.models.network import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import java.math.BigDecimal /** @@ -58,6 +59,7 @@ data class NetworkStatus(val network: Network, val value: Value) { val address: NetworkAddress, val amounts: Map, val pendingTransactions: Map>, + val yieldSupplyStatuses: Map, override val source: StatusSource, ) : Value() @@ -89,4 +91,14 @@ data class NetworkStatus(val network: Network, val value: Value) { /** Amount which failed to load */ data object NotFound : Amount } +} + +/** Gets the address from the NetworkStatus if available */ +fun NetworkStatus?.getAddress(): String? { + return when (val value = this?.value) { + is NetworkStatus.NoAccount -> value.address.defaultAddress.value + is NetworkStatus.Unreachable -> value.address?.defaultAddress?.value + is NetworkStatus.Verified -> value.address.defaultAddress.value + else -> null + } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/yield/supply/YieldSupplyStatus.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/yield/supply/YieldSupplyStatus.kt new file mode 100644 index 0000000000..3a8165a366 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/yield/supply/YieldSupplyStatus.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.models.yield.supply + +import kotlinx.serialization.Serializable + +/** + * Represents the status of yield supply for a cryptocurrency asset. + * + * This data class encapsulates the current state of yield supply, including whether it is active, + * initialized, and allowed to spend. + * + * @property isActive Indicates if the yield token is currently active. + * @property isInitialized Indicates if the yield token has been initialized. + * @property isAllowedToSpend Indicates if spending from the yield module is permitted. + */ +@Serializable +data class YieldSupplyStatus( + val isActive: Boolean, + val isInitialized: Boolean, + val isAllowedToSpend: Boolean, +) \ No newline at end of file diff --git a/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountNameTest.kt b/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountNameTest.kt index e2aee138be..d68bc46ab9 100644 --- a/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountNameTest.kt +++ b/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountNameTest.kt @@ -3,7 +3,6 @@ package com.tangem.domain.models.account import arrow.core.Either import arrow.core.left import com.google.common.truth.Truth -import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource @@ -14,16 +13,6 @@ import org.junit.jupiter.params.provider.MethodSource @TestInstance(TestInstance.Lifecycle.PER_CLASS) class AccountNameTest { - @Test - fun main_returnsMainAccountName() { - // Act - val main = AccountName.Main.value - - // Assert - val expected = "Main Account" - Truth.assertThat(main).isEqualTo(expected) - } - @ParameterizedTest @MethodSource("provideTestModels") fun invoke(model: InvokeTestModel) { diff --git a/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountTest.kt b/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountTest.kt index 91baaf2a76..9578f81fb1 100644 --- a/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountTest.kt +++ b/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountTest.kt @@ -147,7 +147,7 @@ class AccountTest { userWalletId = userWalletId, derivationIndex = derivationIndex, ), - accountName = AccountName.Main, + accountName = AccountName.DefaultMain, icon = CryptoPortfolioIcon.ofMainAccount(userWalletId), derivationIndex = derivationIndex, cryptoCurrencies = emptySet(), diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/DisableWalletNFTUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/DisableWalletNFTUseCase.kt index 32f37d3fe6..c671f030a9 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/DisableWalletNFTUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/DisableWalletNFTUseCase.kt @@ -1,32 +1,24 @@ package com.tangem.domain.nft +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.repository.NFTRepository import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.repository.WalletsRepository class DisableWalletNFTUseCase( private val walletsRepository: WalletsRepository, private val nftRepository: NFTRepository, - private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke(userWalletId: UserWalletId) { walletsRepository.disableNFT(userWalletId) - val currencies = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCachedCurrenciesSync(userWalletId) - } + val currencies = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() val networks = currencies.map { it.network } nftRepository.clearCache(userWalletId, networks) diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionsUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionsUseCase.kt index 421ae063af..b2b8d20503 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionsUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionsUseCase.kt @@ -1,28 +1,20 @@ package com.tangem.domain.nft +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.repository.NFTRepository import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.models.wallet.UserWalletId class FetchNFTCollectionsUseCase( - private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val nftRepository: NFTRepository, ) { suspend operator fun invoke(userWalletId: UserWalletId) { - val currencies = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + val currencies = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() nftRepository.refreshCollections(userWalletId, currencies.map { it.network }.distinct()) } diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/RefreshAllNFTUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/RefreshAllNFTUseCase.kt index b274246dcc..a3cba338d2 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/RefreshAllNFTUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/RefreshAllNFTUseCase.kt @@ -1,29 +1,21 @@ package com.tangem.domain.nft import arrow.core.Either +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.repository.NFTRepository import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.models.wallet.UserWalletId class RefreshAllNFTUseCase( - private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val nftRepository: NFTRepository, ) { suspend operator fun invoke(userWalletId: UserWalletId): Either = Either.catch { - val currencies = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + val currencies = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() nftRepository.refreshAll(userWalletId, currencies.map { it.network }.distinct()) } diff --git a/domain/notifications/toggles/build.gradle.kts b/domain/notifications/toggles/build.gradle.kts deleted file mode 100644 index 7ff7fb7522..0000000000 --- a/domain/notifications/toggles/build.gradle.kts +++ /dev/null @@ -1,4 +0,0 @@ -plugins { - alias(deps.plugins.kotlin.jvm) - id("configuration") -} \ No newline at end of file diff --git a/domain/notifications/toggles/src/main/java/com/tangem/domain/notifications/toggles/NotificationsFeatureToggles.kt b/domain/notifications/toggles/src/main/java/com/tangem/domain/notifications/toggles/NotificationsFeatureToggles.kt deleted file mode 100644 index 22bbec7d10..0000000000 --- a/domain/notifications/toggles/src/main/java/com/tangem/domain/notifications/toggles/NotificationsFeatureToggles.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.domain.notifications.toggles - -interface NotificationsFeatureToggles { - val isNotificationsEnabled: Boolean -} \ No newline at end of file diff --git a/domain/onramp/build.gradle.kts b/domain/onramp/build.gradle.kts index da56879693..7823e8dcea 100644 --- a/domain/onramp/build.gradle.kts +++ b/domain/onramp/build.gradle.kts @@ -4,6 +4,10 @@ plugins { id("configuration") } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Core modules */ implementation(projects.core.analytics.models) @@ -15,4 +19,11 @@ dependencies { api(projects.domain.core) api(projects.domain.settings) implementation(deps.kotlin.serialization) + + /** Tests */ + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) } \ No newline at end of file diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt new file mode 100644 index 0000000000..56700b01f4 --- /dev/null +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.onramp.model + +import java.math.BigDecimal + +data class OnrampOffersBlock( + val category: OnrampOfferCategory, + val offers: List, + val hasMoreOffers: Boolean, +) + +data class OnrampOffer( + val quote: OnrampQuote, + val rateDif: BigDecimal?, + val advantages: OnrampOfferAdvantages = OnrampOfferAdvantages.Default, +) + +enum class OnrampOfferAdvantages { + Default, BestRate, Fastest, +} + +enum class OnrampOfferCategory { + Recent, Recommended, +} \ No newline at end of file diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt index a786bb843b..17c2e41ae0 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt @@ -13,27 +13,59 @@ data class OnrampPaymentMethod( enum class PaymentMethodType(val id: String?) { GOOGLE_PAY(id = "google-pay"), CARD(id = "card"), + REVOLUT_PAY(id = "invoice-revolut-pay"), + SEPA(id = "sepa"), OTHER(id = null), ; + @Suppress("MagicNumber") fun getPriority(isGooglePayEnabled: Boolean): Int = if (isGooglePayEnabled) { when (this) { GOOGLE_PAY -> 0 CARD -> 1 - OTHER -> 2 + SEPA -> 2 + REVOLUT_PAY -> 3 + OTHER -> 4 } } else { when (this) { CARD -> 0 GOOGLE_PAY -> 1 - OTHER -> 2 + SEPA -> 2 + REVOLUT_PAY -> 3 + OTHER -> 4 } } + /** + * BE AWARE. HARDCODED. Returns the speed of transaction for payment method type. + */ + fun getProcessingSpeed(): PaymentSpeed = when (this) { + REVOLUT_PAY, + GOOGLE_PAY, + -> PaymentSpeed.Instant + CARD -> PaymentSpeed.FewMin + SEPA -> PaymentSpeed.FewDays + OTHER -> PaymentSpeed.PlentyDays + } + + /** + * @param speed - the lower the value, the faster the speed. + */ + @Suppress("MagicNumber") + enum class PaymentSpeed(val speed: Int) { + Instant(0), FewMin(1), FewDays(2), PlentyDays(3), Unknown(4) + } + + fun isInstant(): Boolean = getProcessingSpeed() == PaymentSpeed.Instant + companion object { + fun getType(id: String): PaymentMethodType = when (id) { GOOGLE_PAY.id -> GOOGLE_PAY CARD.id -> CARD + REVOLUT_PAY.id -> REVOLUT_PAY + SEPA.id -> SEPA else -> OTHER } } diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethodGroup.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethodGroup.kt new file mode 100644 index 0000000000..9a957d44ff --- /dev/null +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethodGroup.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.onramp.model + +import java.math.BigDecimal + +data class OnrampPaymentMethodGroup( + val paymentMethod: OnrampPaymentMethod, + val offers: List, + val bestRateOffer: OnrampOffer?, + val providerCount: Int, + val isBestPaymentMethod: Boolean, +) { + + val bestRateAmount: BigDecimal? = bestRateOffer?.let { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote.toAmount.value + else -> BigDecimal.ZERO + } + } +} \ No newline at end of file diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampSource.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampSource.kt index 86b72907ad..e193dbc3b2 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampSource.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampSource.kt @@ -6,4 +6,5 @@ enum class OnrampSource(val analyticsName: String) { TOKEN_LONG_TAP("Long Tap"), TOKEN_DETAILS("Token"), MARKETS("Markets"), + SEPA_BANNER("SEPA Banner"), } \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt new file mode 100644 index 0000000000..a7e1d2754f --- /dev/null +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt @@ -0,0 +1,81 @@ +package com.tangem.domain.onramp + +import arrow.core.left +import arrow.core.right +import com.tangem.domain.core.utils.EitherFlow +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.OnrampOffer +import com.tangem.domain.onramp.model.OnrampOfferAdvantages +import com.tangem.domain.onramp.model.OnrampPaymentMethodGroup +import com.tangem.domain.onramp.model.OnrampQuote +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.onramp.repositories.OnrampErrorResolver +import com.tangem.domain.onramp.repositories.OnrampRepository +import com.tangem.domain.onramp.utils.calculateRateDif +import com.tangem.domain.onramp.utils.compareOffersByRateSpeedAndPriority +import com.tangem.domain.settings.repositories.SettingsRepository +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.math.BigDecimal + +class GetOnrampAllOffersUseCase( + private val onrampRepository: OnrampRepository, + private val errorResolver: OnrampErrorResolver, + private val settingsRepository: SettingsRepository, +) { + + operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrencyId: CryptoCurrency.ID, + ): EitherFlow> { + return onrampRepository.getQuotes() + .map { quotes -> processAllOffers(quotes).right() } + .catch { throwable -> errorResolver.resolve(throwable).left() } + } + + private suspend fun processAllOffers(quotes: List): List { + val validQuotes = quotes.filterIsInstance() + if (validQuotes.isEmpty()) return emptyList() + val isGooglePayAvailable = settingsRepository.isGooglePayAvailability() + + val overallBestRateQuote = validQuotes.maxWithOrNull(compareOffersByRateSpeedAndPriority(isGooglePayAvailable)) + val bestRate = overallBestRateQuote?.toAmount?.value + + val offersByPaymentMethod = validQuotes.groupBy { it.paymentMethod } + + return offersByPaymentMethod.map { (paymentMethod, methodQuotes) -> + val methodOffers = methodQuotes.map { quote -> + val advantages = if (quote == overallBestRateQuote) { + OnrampOfferAdvantages.BestRate + } else { + OnrampOfferAdvantages.Default + } + val rateDif = calculateRateDif(quote.toAmount.value, bestRate) + OnrampOffer(quote = quote, rateDif = rateDif, advantages = advantages) + } + + val groupBestRateOfferData = + methodQuotes.maxWithOrNull(compareOffersByRateSpeedAndPriority(isGooglePayAvailable)) + val groupBestRateOffer = methodOffers.find { + when (val quote = it.quote) { + is OnrampQuote.Data -> quote == groupBestRateOfferData + else -> false + } + } + + OnrampPaymentMethodGroup( + paymentMethod = paymentMethod, + offers = methodOffers.sortedByDescending { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote.toAmount.value + else -> BigDecimal.ZERO + } + }, + providerCount = methodOffers.map { it.quote.provider.id }.distinct().size, + bestRateOffer = groupBestRateOffer, + isBestPaymentMethod = overallBestRateQuote?.paymentMethod == paymentMethod, + ) + }.sortedBy { it.paymentMethod.type.getPriority(isGooglePayAvailable) } + } +} \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampCountriesUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampCountriesUseCase.kt index 1ede7cb75b..e57bfdfacd 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampCountriesUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampCountriesUseCase.kt @@ -13,7 +13,7 @@ class GetOnrampCountriesUseCase( private val errorResolver: OnrampErrorResolver, ) { - suspend operator fun invoke(): EitherFlow> { + operator fun invoke(): EitherFlow> { return onrampRepository.getCountries().map { Either.catch { it }.mapLeft(errorResolver::resolve) } diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt new file mode 100644 index 0000000000..81ee5e82fd --- /dev/null +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt @@ -0,0 +1,225 @@ +package com.tangem.domain.onramp + +import arrow.core.left +import arrow.core.right +import com.tangem.domain.core.utils.EitherFlow +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.* +import com.tangem.domain.onramp.model.cache.OnrampTransaction +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.onramp.repositories.OnrampErrorResolver +import com.tangem.domain.onramp.repositories.OnrampRepository +import com.tangem.domain.onramp.repositories.OnrampTransactionRepository +import com.tangem.domain.onramp.utils.calculateRateDif +import com.tangem.domain.onramp.utils.compareOffersByRateSpeedAndPriority +import com.tangem.domain.settings.repositories.SettingsRepository +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +class GetOnrampOffersUseCase( + private val onrampRepository: OnrampRepository, + private val onrampTransactionRepository: OnrampTransactionRepository, + private val errorResolver: OnrampErrorResolver, + private val settingsRepository: SettingsRepository, +) { + + operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrencyId: CryptoCurrency.ID, + ): EitherFlow> { + return combine( + onrampRepository.getQuotes(), + onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId), + ) { quotes, transactions -> + processOffers(quotes, transactions) + } + .map { offers -> offers.right() } + .catch { throwable -> errorResolver.resolve(throwable).left() } + } + + private suspend fun processOffers( + quotes: List, + transactions: List, + ): List { + val validQuotes = quotes.filterIsInstance() + if (validQuotes.isEmpty()) return emptyList() + + val isGooglePayAvailable = settingsRepository.isGooglePayAvailability() + val bestRateQuote = validQuotes.maxWithOrNull(compareOffersByRateSpeedAndPriority(isGooglePayAvailable)) + val bestRate = bestRateQuote?.toAmount?.value + + val offers = validQuotes.map { quote -> + val rateDif = calculateRateDif(quote.toAmount.value, bestRate) + OnrampOffer(quote = quote, rateDif = rateDif) + } + + val recentOffer = findRecentOffer(offers, transactions) + val bestRateOffer = findBestRateOffer(offers, isGooglePayAvailable) + val fastestOffer = findFastestOffer(offers, isGooglePayAvailable) + + return buildOffersBlocks( + recentOffer = recentOffer, + bestRateOffer = bestRateOffer, + fastestOffer = fastestOffer, + allOffers = offers, + ) + } + + private fun findRecentOffer(offers: List, transactions: List): OnrampOffer? { + val lastTransaction = transactions.maxByOrNull { it.timestamp } ?: return null + + return offers.find { offer -> + offer.quote.provider.id == lastTransaction.providerType && + offer.quote.paymentMethod.id == lastTransaction.paymentMethod + } + } + + private fun findBestRateOffer(offers: List, isGooglePayAvailable: Boolean): OnrampOffer? { + return offers.maxWithOrNull(offerComparator(isGooglePayAvailable)) + } + + private fun findFastestOffer(offers: List, isGooglePayAvailable: Boolean): OnrampOffer? { + val instantOffers = offers.filter { it.quote.paymentMethod.type.isInstant() } + return if (instantOffers.isNotEmpty()) { + instantOffers.maxWithOrNull(offerComparator(isGooglePayAvailable)) + } else { + val offersBySpeed = offers.groupBy { offer -> + offer.quote.paymentMethod.type.getProcessingSpeed().speed + } + val fastestSpeed = offersBySpeed.keys.minOrNull() ?: return null + val fastestOffers = offersBySpeed[fastestSpeed] ?: return null + fastestOffers.maxWithOrNull(offerComparator(isGooglePayAvailable)) + } + } + + private fun offerComparator(isGooglePayAvailable: Boolean): Comparator = Comparator { offer1, offer2 -> + when (val quote1 = offer1.quote) { + is OnrampQuote.Data -> { + when (val quote2 = offer2.quote) { + is OnrampQuote.Data -> { + compareOffersByRateSpeedAndPriority(isGooglePayAvailable).compare(quote1, quote2) + } + else -> 1 + } + } + else -> -1 + } + } + + private fun buildOffersBlocks( + recentOffer: OnrampOffer?, + bestRateOffer: OnrampOffer?, + fastestOffer: OnrampOffer?, + allOffers: List, + ): List { + val recommendedOffers = buildRecommendedOffers( + recentOffer = recentOffer, + bestRateOffer = bestRateOffer, + fastestOffer = fastestOffer, + ) + + val shownOffersCount = (if (recentOffer != null) 1 else 0) + recommendedOffers.size + val hasMoreOffers = allOffers.size > shownOffersCount + + return buildList { + if (recentOffer != null) { + add( + OnrampOffersBlock( + category = OnrampOfferCategory.Recent, + offers = listOf( + recentOffer.copy( + advantages = determineAdvantages( + recentOffer, + bestRateOffer, + fastestOffer, + ), + rateDif = if (bestRateOffer != null) recentOffer.rateDif else null, + ), + ), + hasMoreOffers = false, + ), + ) + } + + if (recommendedOffers.isNotEmpty() && hasOnlyOneMethodAndProvider(allOffers).not()) { + add( + OnrampOffersBlock( + category = OnrampOfferCategory.Recommended, + offers = recommendedOffers, + hasMoreOffers = hasMoreOffers, + ), + ) + } + } + } + + private fun determineAdvantages( + recentOffer: OnrampOffer, + bestRateOffer: OnrampOffer?, + fastestOffer: OnrampOffer?, + ): OnrampOfferAdvantages { + if (isSameOffer(recentOffer, bestRateOffer) && isSameOffer(recentOffer, fastestOffer)) { + return OnrampOfferAdvantages.BestRate + } + if (isSameOffer(recentOffer, bestRateOffer)) { + return OnrampOfferAdvantages.BestRate + } + if (isSameOffer(recentOffer, fastestOffer)) { + return OnrampOfferAdvantages.Fastest + } + return OnrampOfferAdvantages.Default + } + + private fun buildRecommendedOffers( + recentOffer: OnrampOffer?, + bestRateOffer: OnrampOffer?, + fastestOffer: OnrampOffer?, + ): List { + return buildList { + if (isSameOffer(bestRateOffer, fastestOffer)) { + bestRateOffer?.let { offer -> + add( + offer.copy( + advantages = OnrampOfferAdvantages.BestRate, + rateDif = null, + ), + ) + } + } else { + if (bestRateOffer != null && !isSameOffer(bestRateOffer, recentOffer)) { + add( + bestRateOffer.copy( + advantages = OnrampOfferAdvantages.BestRate, + rateDif = null, + ), + ) + } + + if (fastestOffer != null && !isSameOffer(fastestOffer, recentOffer) && + !isSameOffer(fastestOffer, bestRateOffer) + ) { + add( + fastestOffer.copy( + advantages = OnrampOfferAdvantages.Fastest, + rateDif = if (bestRateOffer != null) fastestOffer.rateDif else null, + ), + ) + } + } + } + } + + private fun hasOnlyOneMethodAndProvider(offers: List): Boolean { + val uniquePaymentMethods = offers.map { it.quote.paymentMethod.id }.distinct() + val uniqueProviders = offers.map { it.quote.provider.id }.distinct() + return uniquePaymentMethods.size == 1 && uniqueProviders.size == 1 + } + + private fun isSameOffer(offer1: OnrampOffer?, offer2: OnrampOffer?): Boolean { + if (offer1 == null || offer2 == null) return false + return offer1.quote.provider.id == offer2.quote.provider.id && + offer1.quote.paymentMethod.id == offer2.quote.paymentMethod.id + } +} \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampGetDefaultCurrencyUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampGetDefaultCurrencyUseCase.kt new file mode 100644 index 0000000000..4945eac2a7 --- /dev/null +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampGetDefaultCurrencyUseCase.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.onramp + +import arrow.core.Either +import com.tangem.domain.onramp.model.OnrampCurrency +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.onramp.repositories.OnrampErrorResolver +import com.tangem.domain.onramp.repositories.OnrampRepository + +class OnrampGetDefaultCurrencyUseCase( + private val onrampRepository: OnrampRepository, + private val errorResolver: OnrampErrorResolver, +) { + + suspend operator fun invoke(): Either { + return Either + .catch { requireNotNull(onrampRepository.getDefaultCurrencySync()) } + .mapLeft { errorResolver.resolve(it) } + } +} \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampSepaAvailableUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampSepaAvailableUseCase.kt new file mode 100644 index 0000000000..ad7c22bfaa --- /dev/null +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampSepaAvailableUseCase.kt @@ -0,0 +1,80 @@ +package com.tangem.domain.onramp + +import arrow.core.Either +import arrow.core.getOrElse +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.onramp.repositories.OnrampRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.onramp.model.OnrampCountry +import com.tangem.domain.onramp.model.OnrampCurrency + +class OnrampSepaAvailableUseCase( + private val repository: OnrampRepository, +) { + + suspend operator fun invoke( + userWallet: UserWallet, + currency: OnrampCurrency, + country: OnrampCountry, + cryptoCurrency: CryptoCurrency, + ): Boolean { + if (country.code !in SEPA_AVAILABLE_COUNTRY_CODES) { + return false + } + + return Either.catch { + repository.hasSepaMethod( + userWallet = userWallet, + currency = currency, + country = country, + cryptoCurrency = cryptoCurrency, + ) + }.getOrElse { false } + } + + companion object { + val SEPA_AVAILABLE_COUNTRY_CODES = listOf( + "AL", // Albania + "AD", // Andorra + "AT", // Austria + "BE", // Belgium + "BG", // Bulgaria + "HR", // Croatia + "CY", // Cyprus + "CZ", // Czech Republic + "DK", // Denmark + "EE", // Estonia + "FI", // Finland + "FR", // France + "DE", // Germany + "GR", // Greece + "HU", // Hungary + "IS", // Iceland + "IE", // Ireland + "IT", // Italy + "LV", // Latvia + "LI", // Liechtenstein + "LT", // Lithuania + "LU", // Luxembourg + "MT", // Malta + "MD", // Moldova + "MC", // Monaco + "ME", // Montenegro + "NL", // Netherlands + "MK", // North Macedonia + "NO", // Norway + "PL", // Poland + "PT", // Portugal + "RO", // Romania + "SM", // San Marino + "RS", // Serbia + "SK", // Slovakia + "SI", // Slovenia + "ES", // Spain + "SE", // Sweden + "CH", // Switzerland + "GB", // United Kingdom + "VA", // Vatican City + ) + } +} \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt index cf54baee5b..7dd1e1a772 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt @@ -163,4 +163,35 @@ sealed class OnrampAnalyticsEvent( ERROR_DESCRIPTION to errorDescription, ), ) + + data class FastestBuyMethodClicked( + private val tokenSymbol: String, + private val providerName: String, + private val paymentMethod: String, + ) : OnrampAnalyticsEvent( + event = "Fastest Method Clicked", + params = mapOf( + TOKEN_PARAM to tokenSymbol, + PROVIDER to providerName, + PAYMENT_METHOD to paymentMethod, + ), + ) + + data class BestRateClicked( + private val tokenSymbol: String, + private val providerName: String, + private val paymentMethod: String, + ) : OnrampAnalyticsEvent( + event = "Best Rate Clicked", + params = mapOf( + TOKEN_PARAM to tokenSymbol, + PROVIDER to providerName, + PAYMENT_METHOD to paymentMethod, + ), + ) + + data object AllOffersClicked : OnrampAnalyticsEvent( + event = "Button - All Offers", + params = emptyMap(), + ) } \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/OnrampRepository.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/OnrampRepository.kt index b4879d8cc7..2dad8a9e87 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/OnrampRepository.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/OnrampRepository.kt @@ -15,6 +15,12 @@ interface OnrampRepository { suspend fun getCountriesSync(): List? suspend fun getCountryByIp(userWallet: UserWallet): OnrampCountry suspend fun getStatus(userWallet: UserWallet, txId: String): OnrampStatus + suspend fun hasSepaMethod( + userWallet: UserWallet, + currency: OnrampCurrency, + country: OnrampCountry, + cryptoCurrency: CryptoCurrency, + ): Boolean suspend fun fetchCurrencies(userWallet: UserWallet) suspend fun fetchCountries(userWallet: UserWallet): List suspend fun fetchPaymentMethodsIfAbsent(userWallet: UserWallet) diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt new file mode 100644 index 0000000000..7329a88a84 --- /dev/null +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt @@ -0,0 +1,34 @@ +package com.tangem.domain.onramp.utils + +import com.tangem.domain.onramp.model.OnrampQuote +import java.math.BigDecimal + +internal fun calculateRateDif(currentTokenRate: BigDecimal, bestRate: BigDecimal?): BigDecimal? { + if (bestRate == null) return null + return BigDecimal.ONE - currentTokenRate / bestRate +} + +internal fun compareOffersByRateSpeedAndPriority(isGooglePayAvailable: Boolean): Comparator { + return Comparator { quote1, quote2 -> + val rateComparison = quote1 + .toAmount + .value + .compareTo(quote2.toAmount.value) + if (rateComparison != 0) return@Comparator rateComparison + + val speedComparison = + quote2 + .paymentMethod + .type + .getProcessingSpeed() + .speed + .compareTo(quote1.paymentMethod.type.getProcessingSpeed().speed) + if (speedComparison != 0) return@Comparator speedComparison + + quote1 + .paymentMethod + .type + .getPriority(isGooglePayAvailable) + .compareTo(quote2.paymentMethod.type.getPriority(isGooglePayAvailable)) + } +} \ No newline at end of file diff --git a/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampAllOffersUseCaseTest.kt b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampAllOffersUseCaseTest.kt new file mode 100644 index 0000000000..89c4b8e6f9 --- /dev/null +++ b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampAllOffersUseCaseTest.kt @@ -0,0 +1,177 @@ +package com.tangem.domain.onramp + +import com.google.common.truth.Truth +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.OnrampOfferAdvantages +import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.domain.onramp.model.OnrampProvider +import com.tangem.domain.onramp.model.OnrampQuote +import com.tangem.domain.onramp.repositories.OnrampErrorResolver +import com.tangem.domain.onramp.repositories.OnrampRepository +import com.tangem.domain.settings.repositories.SettingsRepository +import io.mockk.* +import kotlinx.coroutines.flow.flowOf +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 + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetOnrampAllOffersUseCaseTest { + + private val onrampRepository: OnrampRepository = mockk(relaxUnitFun = true) + private val errorResolver: OnrampErrorResolver = mockk(relaxUnitFun = true) + private val settingsRepository: SettingsRepository = mockk(relaxUnitFun = true) + private val cryptoCurrencyId: CryptoCurrency.ID = mockk(relaxUnitFun = true) + private val userWalletId: UserWalletId = mockk(relaxUnitFun = true) + + private lateinit var useCase: GetOnrampAllOffersUseCase + + @BeforeEach + fun setup() { + clearMocks(onrampRepository, errorResolver, settingsRepository, cryptoCurrencyId) + useCase = GetOnrampAllOffersUseCase( + onrampRepository = onrampRepository, + errorResolver = errorResolver, + settingsRepository = settingsRepository, + ) + } + + @Test + fun `invoke should return empty list when no valid quotes`() = runTest { + val emptyQuotes = listOf() + coEvery { onrampRepository.getQuotes() } returns flowOf(emptyQuotes) + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> Truth.assertThat(offers).isEmpty() }, + ) + } + coVerify { onrampRepository.getQuotes() } + } + + @Test + fun `invoke should return grouped offers with best rate marked`() = runTest { + val paymentMethod1 = createMockPaymentMethod("card", "Card") + val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer") + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + + val quotes = listOf( + createMockQuote(paymentMethod1, provider1, BigDecimal("100.0")), + createMockQuote(paymentMethod1, provider2, BigDecimal("95.0")), + createMockQuote(paymentMethod2, provider1, BigDecimal("98.0")), + ) + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { settingsRepository.isGooglePayAvailability() } returns false + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(2) + + val cardGroup = offers.find { it.paymentMethod.id == "card" } + Truth.assertThat(cardGroup).isNotNull() + Truth.assertThat(cardGroup?.offers).hasSize(2) + Truth.assertThat(cardGroup?.providerCount).isEqualTo(2) + Truth.assertThat(cardGroup?.isBestPaymentMethod).isTrue() + + val bestRateOffer = cardGroup?.offers?.find { it.advantages == OnrampOfferAdvantages.BestRate } + Truth.assertThat(bestRateOffer).isNotNull() + + val bankGroup = offers.find { it.paymentMethod.id == "bank" } + Truth.assertThat(bankGroup).isNotNull() + Truth.assertThat(bankGroup?.offers).hasSize(1) + Truth.assertThat(bankGroup?.providerCount).isEqualTo(1) + Truth.assertThat(bankGroup?.isBestPaymentMethod).isFalse() + }, + ) + } + + coVerify { onrampRepository.getQuotes() } + coVerify { settingsRepository.isGooglePayAvailability() } + } + + @Test + fun `invoke should sort offers by toAmount descending`() = runTest { + val paymentMethod = createMockPaymentMethod("card", "Card") + val provider = createMockProvider("provider1", "Provider 1") + + val quotes = listOf( + createMockQuote(paymentMethod, provider, BigDecimal("90.0")), + createMockQuote(paymentMethod, provider, BigDecimal("100.0")), + createMockQuote(paymentMethod, provider, BigDecimal("95.0")), + ) + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { settingsRepository.isGooglePayAvailability() } returns false + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(1) + val group = offers.first() + Truth.assertThat(group.offers).hasSize(3) + + val amounts = group.offers.map { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote.toAmount.value + else -> BigDecimal.ZERO + } + } + Truth.assertThat(amounts).containsExactly( + BigDecimal("100.0"), + BigDecimal("95.0"), + BigDecimal("90.0"), + ).inOrder() + }, + ) + } + } + + private fun createMockPaymentMethod(id: String, name: String): OnrampPaymentMethod { + return mockk { + every { this@mockk.id } returns id + every { this@mockk.name } returns name + every { this@mockk.type } returns mockk { + every { getPriority(any()) } returns 1 + } + } + } + + private fun createMockProvider(id: String, name: String): OnrampProvider { + return mockk { + every { this@mockk.id } returns id + every { this@mockk.info.name } returns name + } + } + + private fun createMockQuote( + paymentMethod: OnrampPaymentMethod, + provider: OnrampProvider, + toAmount: BigDecimal, + ): OnrampQuote.Data { + return mockk { + every { this@mockk.paymentMethod } returns paymentMethod + every { this@mockk.provider } returns provider + every { this@mockk.toAmount } returns mockk { + every { value } returns toAmount + } + } + } +} \ No newline at end of file diff --git a/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt new file mode 100644 index 0000000000..87f9fbb1c6 --- /dev/null +++ b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt @@ -0,0 +1,288 @@ +package com.tangem.domain.onramp + +import com.google.common.truth.Truth +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.* +import com.tangem.domain.onramp.model.cache.OnrampTransaction +import com.tangem.domain.onramp.repositories.OnrampErrorResolver +import com.tangem.domain.onramp.repositories.OnrampRepository +import com.tangem.domain.onramp.repositories.OnrampTransactionRepository +import com.tangem.domain.settings.repositories.SettingsRepository +import io.mockk.* +import kotlinx.coroutines.flow.flowOf +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 + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetOnrampOffersUseCaseTest { + + private val onrampRepository: OnrampRepository = mockk(relaxUnitFun = true) + private val onrampTransactionRepository: OnrampTransactionRepository = mockk(relaxUnitFun = true) + private val errorResolver: OnrampErrorResolver = mockk(relaxUnitFun = true) + private val settingsRepository: SettingsRepository = mockk(relaxUnitFun = true) + private val cryptoCurrencyId: CryptoCurrency.ID = mockk(relaxUnitFun = true) + private val userWalletId: UserWalletId = mockk(relaxUnitFun = true) + + private lateinit var useCase: GetOnrampOffersUseCase + + @BeforeEach + fun setup() { + clearMocks(onrampRepository, onrampTransactionRepository, errorResolver, cryptoCurrencyId) + useCase = GetOnrampOffersUseCase( + onrampRepository = onrampRepository, + onrampTransactionRepository = onrampTransactionRepository, + errorResolver = errorResolver, + settingsRepository = settingsRepository, + ) + } + + @Test + fun `invoke should return empty list when no valid quotes`() = runTest { + val emptyQuotes = listOf() + val emptyTransactions = listOf() + + coEvery { settingsRepository.isGooglePayAvailability() } returns false + coEvery { onrampRepository.getQuotes() } returns flowOf(emptyQuotes) + coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + emptyTransactions, + ) + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> Truth.assertThat(offers).isEmpty() }, + ) + } + + coVerify { onrampRepository.getQuotes() } + coVerify { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } + } + + @Test + fun `invoke should return offers blocks with recent and recommended categories`() = runTest { + val paymentMethod1 = createMockPaymentMethod("card", "Card", isInstant = true) + val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer", isInstant = false) + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + + val quotes = listOf( + createMockQuote(paymentMethod1, provider1, BigDecimal("90.0")), + createMockQuote(paymentMethod2, provider2, BigDecimal("100.0")), + ) + + val transactions = listOf( + createMockTransaction("provider1", "card", 1000L), + ) + + coEvery { settingsRepository.isGooglePayAvailability() } returns false + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + transactions, + ) + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(2) + + val recentBlock = offers.find { it.category == OnrampOfferCategory.Recent } + Truth.assertThat(recentBlock).isNotNull() + Truth.assertThat(recentBlock?.offers).hasSize(1) + Truth.assertThat(recentBlock?.offers?.first()?.advantages).isEqualTo(OnrampOfferAdvantages.Fastest) + + val recommendedBlock = offers.find { it.category == OnrampOfferCategory.Recommended } + Truth.assertThat(recommendedBlock).isNotNull() + Truth.assertThat(recommendedBlock?.offers).hasSize(1) + Truth.assertThat(recommendedBlock?.offers?.first()?.advantages) + .isEqualTo(OnrampOfferAdvantages.BestRate) + }, + ) + } + } + + @Test + fun `invoke should find best rate offer correctly`() = runTest { + val paymentMethod1 = createMockPaymentMethod("card", "Card", isInstant = false) + val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer", isInstant = false) + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + + val quotes = listOf( + createMockQuote(paymentMethod1, provider1, BigDecimal("90.0")), + createMockQuote(paymentMethod2, provider2, BigDecimal("100.0")), + createMockQuote(paymentMethod1, provider1, BigDecimal("95.0")), + ) + + val transactions = emptyList() + + coEvery { settingsRepository.isGooglePayAvailability() } returns false + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + transactions, + ) + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(1) + + val recommendedBlock = offers.find { it.category == OnrampOfferCategory.Recommended } + Truth.assertThat(recommendedBlock).isNotNull() + Truth.assertThat(recommendedBlock?.offers).hasSize(1) + + val bestRateOffer = recommendedBlock?.offers?.first() + Truth.assertThat(bestRateOffer?.advantages).isEqualTo(OnrampOfferAdvantages.BestRate) + + when (val quote = bestRateOffer?.quote) { + is OnrampQuote.Data -> Truth.assertThat(quote.toAmount.value).isEqualTo(BigDecimal("100.0")) + else -> Truth.assertThat(false).isTrue() + } + }, + ) + } + } + + @Test + fun `invoke should find fastest offer correctly`() = runTest { + val instantPaymentMethod = createMockPaymentMethod("card", "Card", isInstant = true) + val slowPaymentMethod = createMockPaymentMethod("bank", "Bank Transfer", isInstant = false) + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + + val quotes = listOf( + createMockQuote(instantPaymentMethod, provider1, BigDecimal("90.0")), + createMockQuote(slowPaymentMethod, provider2, BigDecimal("100.0")), + ) + + val transactions = emptyList() + + coEvery { settingsRepository.isGooglePayAvailability() } returns false + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + transactions, + ) + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(1) + + val recommendedBlock = offers.find { it.category == OnrampOfferCategory.Recommended } + Truth.assertThat(recommendedBlock).isNotNull() + Truth.assertThat(recommendedBlock?.offers).hasSize(2) + + val bestRateOffer = recommendedBlock + ?.offers + ?.find { it.advantages == OnrampOfferAdvantages.BestRate } + val fastestOffer = recommendedBlock + ?.offers + ?.find { it.advantages == OnrampOfferAdvantages.Fastest } + + Truth.assertThat(bestRateOffer).isNotNull() + Truth.assertThat(fastestOffer).isNotNull() + + when (val quote = bestRateOffer?.quote) { + is OnrampQuote.Data -> Truth.assertThat(quote.toAmount.value).isEqualTo(BigDecimal("100.0")) + else -> Truth.assertThat(false).isTrue() + } + + when (val quote = fastestOffer?.quote) { + is OnrampQuote.Data -> Truth.assertThat(quote.toAmount.value).isEqualTo(BigDecimal("90.0")) + else -> Truth.assertThat(false).isTrue() + } + }, + ) + } + } + + @Test + fun `invoke should not show recommended block when only one method and provider`() = runTest { + val paymentMethod = createMockPaymentMethod("card", "Card", isInstant = false) + val provider = createMockProvider("provider1", "Provider 1") + + val quotes = listOf( + createMockQuote(paymentMethod, provider, BigDecimal("100.0")), + ) + + val transactions = emptyList() + + coEvery { settingsRepository.isGooglePayAvailability() } returns false + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + transactions, + ) + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).isEmpty() + }, + ) + } + } + + private fun createMockPaymentMethod(id: String, name: String, isInstant: Boolean): OnrampPaymentMethod { + return mockk { + every { this@mockk.id } returns id + every { this@mockk.name } returns name + every { this@mockk.type } returns mockk { + every { isInstant() } returns isInstant + every { getProcessingSpeed() } returns mockk { + every { speed } returns if (isInstant) 1 else 3 + } + } + } + } + + private fun createMockProvider(id: String, name: String): OnrampProvider { + return mockk { + every { this@mockk.id } returns id + every { this@mockk.info.name } returns name + } + } + + private fun createMockQuote( + paymentMethod: OnrampPaymentMethod, + provider: OnrampProvider, + toAmount: BigDecimal, + ): OnrampQuote.Data { + return mockk { + every { this@mockk.paymentMethod } returns paymentMethod + every { this@mockk.provider } returns provider + every { this@mockk.toAmount } returns mockk { + every { value } returns toAmount + } + } + } + + private fun createMockTransaction(providerType: String, paymentMethod: String, timestamp: Long): OnrampTransaction { + return mockk { + every { this@mockk.providerType } returns providerType + every { this@mockk.paymentMethod } returns paymentMethod + every { this@mockk.timestamp } returns timestamp + } + } +} \ No newline at end of file diff --git a/domain/promo/models/src/main/java/com/tangem/domain/promo/models/PromoBanner.kt b/domain/promo/models/src/main/java/com/tangem/domain/promo/models/PromoBanner.kt index 81f592f146..7605016350 100644 --- a/domain/promo/models/src/main/java/com/tangem/domain/promo/models/PromoBanner.kt +++ b/domain/promo/models/src/main/java/com/tangem/domain/promo/models/PromoBanner.kt @@ -27,4 +27,5 @@ data class PromoBanner( enum class PromoId { Referral, + Sepa, } \ No newline at end of file diff --git a/domain/promo/src/main/java/com/tangem/domain/promo/ShouldShowPromoWalletUseCase.kt b/domain/promo/src/main/java/com/tangem/domain/promo/ShouldShowPromoWalletUseCase.kt index d3496cb02e..0fd7070d88 100644 --- a/domain/promo/src/main/java/com/tangem/domain/promo/ShouldShowPromoWalletUseCase.kt +++ b/domain/promo/src/main/java/com/tangem/domain/promo/ShouldShowPromoWalletUseCase.kt @@ -2,18 +2,46 @@ package com.tangem.domain.promo import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.promo.models.PromoId +import com.tangem.domain.settings.repositories.SettingsRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import java.util.Calendar -class ShouldShowPromoWalletUseCase(private val promoRepository: PromoRepository) { +class ShouldShowPromoWalletUseCase( + private val promoRepository: PromoRepository, + private val settingsRepository: SettingsRepository, +) { operator fun invoke(userWalletId: UserWalletId, promoId: PromoId): Flow { return flow { emit(false) - emitAll(promoRepository.isReadyToShowWalletPromo(userWalletId, promoId)) + + val promoFlow = promoRepository.isReadyToShowWalletPromo(userWalletId, promoId) + .map { applyWalletFirstUsageCondition(promoId, it) } + + emitAll(promoFlow) } } + private suspend fun applyWalletFirstUsageCondition(promoId: PromoId, isReady: Boolean): Boolean { + if (!isReady) return false + + return when (promoId) { + PromoId.Referral -> true + PromoId.Sepa -> { + val walletFirstUsageDate = settingsRepository.getWalletFirstUsageDate() + if (walletFirstUsageDate == 0L) return false + + val currentDate = Calendar.getInstance().timeInMillis + currentDate - walletFirstUsageDate > ONE_DAY_IN_MILLIS + } + } + } suspend fun neverToShow(promoId: PromoId) = promoRepository.setNeverToShowWalletPromo(promoId) + + private companion object { + const val ONE_DAY_IN_MILLIS = 1 * 24 * 60 * 60 * 1000L + } } \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/SetSaveWalletScreenShownUseCase.kt b/domain/settings/src/main/java/com/tangem/domain/settings/SetAskBiometryShownUseCase.kt similarity index 71% rename from domain/settings/src/main/java/com/tangem/domain/settings/SetSaveWalletScreenShownUseCase.kt rename to domain/settings/src/main/java/com/tangem/domain/settings/SetAskBiometryShownUseCase.kt index 593b82a683..760ec572c6 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/SetSaveWalletScreenShownUseCase.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/SetAskBiometryShownUseCase.kt @@ -3,13 +3,13 @@ package com.tangem.domain.settings import arrow.core.Either import com.tangem.domain.settings.repositories.SettingsRepository -class SetSaveWalletScreenShownUseCase( +class SetAskBiometryShownUseCase( private val settingsRepository: SettingsRepository, ) { suspend operator fun invoke(): Either { return Either.catch { - settingsRepository.setShouldShowSaveUserWalletScreen(value = false) + settingsRepository.setShouldShowAskBiometry(value = false) } } } \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSaveWalletScreenUseCase.kt b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowAskBiometryUseCase.kt similarity index 57% rename from domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSaveWalletScreenUseCase.kt rename to domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowAskBiometryUseCase.kt index 872f62c157..0cab3f56d4 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSaveWalletScreenUseCase.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowAskBiometryUseCase.kt @@ -2,7 +2,7 @@ package com.tangem.domain.settings import com.tangem.domain.settings.repositories.SettingsRepository -class ShouldShowSaveWalletScreenUseCase(private val settingsRepository: SettingsRepository) { +class ShouldShowAskBiometryUseCase(private val settingsRepository: SettingsRepository) { - suspend operator fun invoke(): Boolean = settingsRepository.shouldShowSaveUserWalletScreen() + suspend operator fun invoke(): Boolean = settingsRepository.shouldShowAskBiometry() } \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt index e39efd87a2..0a8f854790 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt @@ -7,9 +7,9 @@ import kotlinx.coroutines.flow.StateFlow @Suppress("TooManyFunctions") interface SettingsRepository { - suspend fun shouldShowSaveUserWalletScreen(): Boolean + suspend fun shouldShowAskBiometry(): Boolean - suspend fun setShouldShowSaveUserWalletScreen(value: Boolean) + suspend fun setShouldShowAskBiometry(value: Boolean) suspend fun isWalletScrollPreviewEnabled(): Boolean diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapDataModel.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapDataModel.kt index 098e6cd413..1394bebe86 100644 --- a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapDataModel.kt +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapDataModel.kt @@ -12,7 +12,7 @@ sealed class SwapDataTransactionModel { abstract val fromAmount: BigDecimal abstract val toAmount: BigDecimal - abstract val txValue: String + abstract val txValue: String? abstract val txId: String abstract val txTo: String abstract val txExtraId: String? @@ -23,7 +23,7 @@ sealed class SwapDataTransactionModel { data class DEX( override val fromAmount: BigDecimal, override val toAmount: BigDecimal, - override val txValue: String, + override val txValue: String?, override val txId: String, override val txTo: String, override val txExtraId: String?, @@ -36,7 +36,7 @@ sealed class SwapDataTransactionModel { data class CEX( override val fromAmount: BigDecimal, override val toAmount: BigDecimal, - override val txValue: String, + override val txValue: String?, override val txId: String, override val txTo: String, override val txExtraId: String?, diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenSwapPromoAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenSwapPromoAnalyticsEvent.kt index 7ed6c36cc2..f460d229ff 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenSwapPromoAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenSwapPromoAnalyticsEvent.kt @@ -9,24 +9,24 @@ sealed class TokenSwapPromoAnalyticsEvent( ) : AnalyticsEvent(category = "Promotion", event = event, params = params) { class NoticePromotionBanner( source: AnalyticsParam.ScreensSources, - programName: ProgramName, + program: Program, ) : TokenSwapPromoAnalyticsEvent( event = "Notice - Promotion Banner", params = mapOf( AnalyticsParam.SOURCE to source.value, - "Program Name" to programName.name, + "Program Name" to program.programName, ), ) class PromotionBannerClicked( source: AnalyticsParam.ScreensSources, - programName: ProgramName, + program: Program, action: BannerAction, ) : TokenSwapPromoAnalyticsEvent( event = "Promo Banner Clicked", params = mapOf( AnalyticsParam.SOURCE to source.value, - "Program Name" to programName.name, + "Program Name" to program.programName, "Action" to action.action, ), ) { @@ -37,7 +37,8 @@ sealed class TokenSwapPromoAnalyticsEvent( } // Use it on new promo action - enum class ProgramName { - Empty, + enum class Program(val programName: String) { + Empty("Empty"), + Sepa("Sepa"), } } \ 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 06b498b2ab..7223a6faae 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 @@ -33,7 +33,6 @@ class AddCryptoCurrenciesUseCase( private val singleYieldBalanceFetcher: SingleYieldBalanceFetcher, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val stakingIdFactory: StakingIdFactory, - private val tokensFeatureToggles: TokensFeatureToggles, ) { /** @@ -92,15 +91,11 @@ class AddCryptoCurrenciesUseCase( ): Either = either { val existingCurrencies = catch( block = { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - .toList() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() + .toList() }, catch = ::raise, ) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCase.kt index 8fbd310f2f..1bad208906 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCase.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.withContext class ApplyTokenListSortingUseCase( private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val dispatchers: CoroutineDispatcherProvider, ) { @@ -88,14 +87,10 @@ class ApplyTokenListSortingUseCase( private suspend fun Raise.getCurrencies(userWalletId: UserWalletId): List { val tokens = catch( block = { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId, refresh = false) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() }, catch = { raise(TokenListSortingError.DataError(it)) }, ) 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 e500c445fb..5c7ce2a444 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 @@ -35,7 +35,6 @@ class FetchCurrencyStatusUseCase( private val singleYieldBalanceFetcher: SingleYieldBalanceFetcher, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val stakingIdFactory: StakingIdFactory, - private val tokensFeatureToggles: TokensFeatureToggles, ) { /** @@ -97,15 +96,11 @@ class FetchCurrencyStatusUseCase( ): CryptoCurrency { return catch( block = { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.firstOrNull { it.id == id } - ?: error("Unable to find currency with ID: $id") - } else { - currenciesRepository.getMultiCurrencyWalletCurrency(userWalletId = userWalletId, id = id) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.firstOrNull { it.id == id } + ?: error("Unable to find currency with ID: $id") }, ) { raise(CurrencyStatusError.DataError(it)) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt index bc8e4fa72d..26ed8b9867 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt @@ -26,7 +26,6 @@ import java.math.BigDecimal class GetBalanceNotEnoughForFeeWarningUseCase( private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val dispatchers: CoroutineDispatcherProvider, ) { suspend operator fun invoke( @@ -71,14 +70,10 @@ class GetBalanceNotEnoughForFeeWarningUseCase( tokenStatus: CryptoCurrencyStatus, feePaidToken: FeePaidCurrency.Token, ): CryptoCurrencyWarning { - val tokens = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + val tokens = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() val token = tokens.find { it is CryptoCurrency.Token && diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt index 67780eb049..15b876dd8f 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt @@ -5,16 +5,15 @@ import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.error.CurrencyStatusError -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.tokens.error.CurrencyStatusError +import com.tangem.domain.tokens.repository.CurrenciesRepository class GetCryptoCurrencyUseCase( private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { /** @@ -55,15 +54,11 @@ class GetCryptoCurrencyUseCase( ): CryptoCurrency { return catch( block = { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.firstOrNull { it.id.value == id } - ?: error("Unable to find currency with ID: $id") - } else { - currenciesRepository.getMultiCurrencyWalletCurrency(userWalletId, id) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.firstOrNull { it.id.value == id } + ?: error("Unable to find currency with ID: $id") }, catch = { raise(CurrencyStatusError.DataError(it)) }, ) 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 6b64410367..d4611e9d40 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 @@ -29,7 +29,6 @@ class GetCurrencyWarningsUseCase( private val currencyChecksRepository: CurrencyChecksRepository, private val currencyStatusOperations: BaseCurrencyStatusOperations, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke( @@ -152,14 +151,10 @@ class GetCurrencyWarningsUseCase( tokenStatus: CryptoCurrencyStatus, feePaidToken: FeePaidCurrency.Token, ): CryptoCurrencyWarning { - val tokens = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + val tokens = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() val token = tokens.find { it is CryptoCurrency.Token && diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsCryptoCurrencyCoinCouldHideUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsCryptoCurrencyCoinCouldHideUseCase.kt index 5a0da52389..5ea612a931 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsCryptoCurrencyCoinCouldHideUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsCryptoCurrencyCoinCouldHideUseCase.kt @@ -1,27 +1,17 @@ package com.tangem.domain.tokens import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.models.wallet.UserWalletId class IsCryptoCurrencyCoinCouldHideUseCase( - private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke(userWalletId: UserWalletId, cryptoCurrencyCoin: CryptoCurrency.Coin): Boolean { - return if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync( - userWalletId = userWalletId, - refresh = false, - ) - } + return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() .none { it is CryptoCurrency.Token && it.network == cryptoCurrencyCoin.network } } } \ No newline at end of file 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 ce4c325973..2fc30f9448 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,19 +5,16 @@ 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.models.wallet.UserWalletId 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.models.wallet.UserWalletId import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope class RefreshMultiCurrencyWalletQuotesUseCase( - private val currenciesRepository: CurrenciesRepository, private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke(userWalletId: UserWalletId): Either { @@ -41,15 +38,11 @@ class RefreshMultiCurrencyWalletQuotesUseCase( return either { catch( block = { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - .toList() - } else { - currenciesRepository.getMultiCurrencyWalletCachedCurrenciesSync(userWalletId) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() + .toList() }, catch = ::raise, ) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt index 95fec7162c..59381c5e65 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt @@ -4,16 +4,15 @@ import arrow.core.Either import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.remove.RemoveCurrencyError import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.models.wallet.UserWalletId class RemoveCurrencyUseCase( private val currenciesRepository: CurrenciesRepository, private val walletManagersFacade: WalletManagersFacade, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke( @@ -46,17 +45,10 @@ class RemoveCurrencyUseCase( suspend fun hasLinkedTokens(userWalletId: UserWalletId, currency: CryptoCurrency): Boolean { return when (currency) { is CryptoCurrency.Coin -> { - val walletCurrencies = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync( - userWalletId = userWalletId, - refresh = false, - ) - } + val walletCurrencies = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() walletCurrencies.any { it is CryptoCurrency.Token && it.network == currency.network } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt index 5add2d0b65..a2eeb1e0a6 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt @@ -5,7 +5,4 @@ package com.tangem.domain.tokens * [REDACTED_AUTHOR] */ -interface TokensFeatureToggles { - - val isWalletBalanceFetcherEnabled: Boolean -} \ No newline at end of file +interface TokensFeatureToggles \ No newline at end of file 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 3a258c4cf6..55d53e54c8 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 @@ -28,7 +28,6 @@ import com.tangem.domain.staking.single.SingleYieldBalanceProducer import com.tangem.domain.staking.single.SingleYieldBalanceSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -53,7 +52,6 @@ abstract class BaseCurrencyStatusOperations( private val multiYieldBalanceSupplier: MultiYieldBalanceSupplier, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val stakingIdFactory: StakingIdFactory, - private val tokensFeatureToggles: TokensFeatureToggles, ) { protected val currencyStatusProxyCreator = CurrencyStatusProxyCreator() @@ -62,13 +60,6 @@ abstract class BaseCurrencyStatusOperations( protected abstract fun getQuotes(id: CryptoCurrency.RawID): Flow>> - protected abstract suspend fun fetchComponents( - userWalletId: UserWalletId, - networks: Set, - currenciesIds: Set, - currencies: List, - ): Either - suspend fun getCurrencyStatusFlow( userWalletId: UserWalletId, currencyId: CryptoCurrency.ID, @@ -262,14 +253,10 @@ abstract class BaseCurrencyStatusOperations( return either { catch( block = { - val nonEmptyCurrencies = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.toNonEmptyListOrNull() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId).toNonEmptyListOrNull() - } + val nonEmptyCurrencies = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.toNonEmptyListOrNull() ?: return emptyList().right() val (_, currenciesIds) = getIds(nonEmptyCurrencies) @@ -332,15 +319,11 @@ abstract class BaseCurrencyStatusOperations( currencyId: CryptoCurrency.ID, ): CryptoCurrency { return Either.catch { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.firstOrNull { it.id == currencyId } - ?: error("Unable to find currency with ID: $currencyId") - } else { - currenciesRepository.getMultiCurrencyWalletCurrency(userWalletId = userWalletId, id = currencyId) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.firstOrNull { it.id == currencyId } + ?: error("Unable to find currency with ID: $currencyId") } .mapLeft(Error::DataError) .bind() @@ -382,16 +365,12 @@ abstract class BaseCurrencyStatusOperations( derivationPath: Network.DerivationPath, ): CryptoCurrency { return Either.catch { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.filterIsInstance() - ?.firstOrNull { it.network.id == networkId } - ?: error("Unable to create network coin with ID: $networkId") - } else { - currenciesRepository.getNetworkCoin(userWalletId, networkId, derivationPath) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.filterIsInstance() + ?.firstOrNull { it.network.id == networkId && it.network.derivationPath == derivationPath } + ?: error("Unable to create network coin with ID: $networkId") } .mapLeft { Error.DataError(it) } .bind() 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 70700beea8..d261a865d9 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 @@ -1,7 +1,6 @@ package com.tangem.domain.tokens.operations import arrow.core.* -import arrow.core.raise.either import arrow.core.raise.recover import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.LceFlow @@ -14,35 +13,31 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.network.getAddress import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.staking.YieldBalance import com.tangem.domain.models.wallet.UserWalletId -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.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.StakingIdFactory import com.tangem.domain.staking.model.StakingIntegrationID -import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier import com.tangem.domain.staking.single.SingleYieldBalanceProducer import com.tangem.domain.staking.single.SingleYieldBalanceSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.tokens.utils.extractAddress import com.tangem.utils.extensions.addOrReplace -import com.tangem.utils.extensions.isSingleItem -import kotlinx.coroutines.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch @Suppress("LongParameterList", "LargeClass") class CachedCurrenciesStatusesOperations( @@ -50,16 +45,11 @@ class CachedCurrenciesStatusesOperations( quotesRepository: QuotesRepository, private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, - private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, - private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, private val singleYieldBalanceSupplier: SingleYieldBalanceSupplier, multiYieldBalanceSupplier: MultiYieldBalanceSupplier, - private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val stakingIdFactory: StakingIdFactory, - private val tokensFeatureToggles: TokensFeatureToggles, ) : BaseCurrencyStatusOperations( currenciesRepository = currenciesRepository, quotesRepository = quotesRepository, @@ -70,7 +60,6 @@ class CachedCurrenciesStatusesOperations( multiYieldBalanceSupplier = multiYieldBalanceSupplier, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, stakingIdFactory = stakingIdFactory, - tokensFeatureToggles = tokensFeatureToggles, ) { override fun getCurrenciesStatuses( @@ -90,20 +79,6 @@ class CachedCurrenciesStatusesOperations( ): LceFlow> = lceFlow { val prevStatuses = MutableStateFlow(value = emptyList()) - val nonEmptyCurrencies = currenciesFlow.mapNotNull { it.getOrNull() }.firstOrNull()?.toNonEmptyListOrNull() - - if (!tokensFeatureToggles.isWalletBalanceFetcherEnabled && !isFetchingStarted(userWalletId) && - nonEmptyCurrencies != null - ) { - launch { - setFetchStarted(userWalletId) - - val (networks, currenciesIds) = getIds(nonEmptyCurrencies) - fetchComponents(userWalletId, networks, currenciesIds, nonEmptyCurrencies) - } - .invokeOnCompletion { setFetchFinished(userWalletId) } - } - currenciesFlow.flatMapLatest { maybeCurrencies -> val currencies = maybeCurrencies .getOrElse { return@flatMapLatest flowOf(it.lceError()) } @@ -154,15 +129,6 @@ class CachedCurrenciesStatusesOperations( ) } - if (!tokensFeatureToggles.isWalletBalanceFetcherEnabled && !isFetchingStarted(userWalletId)) { - launch { - setFetchStarted(userWalletId) - - fetchComponents(userWalletId, networks, currenciesIds, currencies) - } - .invokeOnCompletion { setFetchFinished(userWalletId) } - } - val networksStatusesUpdates = getNetworkStatusesUpdates(userWalletId, networks) combine( @@ -177,17 +143,13 @@ class CachedCurrenciesStatusesOperations( currencies.associate { currency -> val networkStatus = networksStatuses.firstOrNull { it.network == currency.network } - currency.id to extractAddress(networkStatus) + currency.id to networkStatus.getAddress() } } getYieldsBalancesUpdates(userWalletId, currenciesAddresses) }, - flow4 = fetchingState.map { - val state = it[userWalletId] ?: return@map false - - !state.isFinished() - }, + flow4 = flowOf(value = false), transform = ::createCurrenciesStatuses, ) .distinctUntilChanged() @@ -200,55 +162,6 @@ class CachedCurrenciesStatusesOperations( .launchIn(scope = this) } - override suspend fun fetchComponents( - userWalletId: UserWalletId, - networks: Set, - currenciesIds: Set, - currencies: List, - ): Either = either { - coroutineScope { - awaitAll( - async { - if (networks.isSingleItem()) { - singleNetworkStatusFetcher( - params = SingleNetworkStatusFetcher.Params( - userWalletId = userWalletId, - network = networks.first(), - ), - ) - } else { - multiNetworkStatusFetcher( - params = MultiNetworkStatusFetcher.Params( - userWalletId = userWalletId, - networks = networks, - ), - ) - } - }, - async { - val rawCurrenciesIds = currenciesIds.mapNotNullTo(mutableSetOf()) { it.rawCurrencyId } - - multiQuoteStatusFetcher( - params = MultiQuoteStatusFetcher.Params(currenciesIds = rawCurrenciesIds, appCurrencyId = null), - ) - }, - async { - val stakingIds = currencies.mapNotNullTo(hashSetOf()) { - stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() - } - - multiYieldBalanceFetcher( - params = MultiYieldBalanceFetcher.Params( - userWalletId = userWalletId, - stakingIds = stakingIds, - ), - ) - }, - ) - } - .map { } - } - private fun createCurrenciesStatuses( currencies: NonEmptyList, maybeQuotes: Either>?, @@ -295,7 +208,7 @@ class CachedCurrenciesStatusesOperations( if (yieldBalances.isNullOrEmpty()) return null val supportedIntegration = StakingIntegrationID.create(currencyId = currency.id)?.value - val address = extractAddress(networkStatus) + val address = networkStatus.getAddress() return if (supportedIntegration != null && address != null) { val stakingId = StakingID(integrationId = supportedIntegration, address = address) @@ -428,36 +341,4 @@ class CachedCurrenciesStatusesOperations( } .distinctUntilChanged() } - - private fun isFetchingStarted(userWalletId: UserWalletId): Boolean { - return fetchingState.value[userWalletId]?.let { it.isStarted() || it.isFinished() } == true - } - - private fun setFetchStarted(userWalletId: UserWalletId) { - fetchingState.update { - it.toMutableMap().apply { - put(key = userWalletId, value = FetchingState.STARTED) - } - } - } - - private fun setFetchFinished(userWalletId: UserWalletId) { - fetchingState.update { - it.toMutableMap().apply { - put(key = userWalletId, value = FetchingState.FINISHED) - } - } - } - - enum class FetchingState { - STARTED, FINISHED; - - fun isStarted() = this == STARTED - fun isFinished() = this == FINISHED - } - - companion object { - - private val fetchingState = MutableStateFlow(value = emptyMap()) - } } \ No newline at end of file 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 c1dc1ffb33..5a6576e4b3 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 @@ -94,6 +94,7 @@ internal class CurrencyStatusOperations( } else { null } + val yieldSupplyStatus = networkStatusValue.yieldSupplyStatuses[currency.id] val quoteValue = quoteStatus?.value @@ -108,6 +109,7 @@ internal class CurrencyStatusOperations( pendingTransactions = currentTransactions, networkAddress = networkStatusValue.address, yieldBalance = currentYieldBalance, + yieldSupplyStatus = yieldSupplyStatus, sources = CryptoCurrencyStatus.Sources( networkSource = networkStatusValue.source, quoteSource = quoteStatus?.value?.source ?: StatusSource.ACTUAL, @@ -120,6 +122,7 @@ internal class CurrencyStatusOperations( pendingTransactions = currentTransactions, networkAddress = networkStatusValue.address, yieldBalance = currentYieldBalance, + yieldSupplyStatus = yieldSupplyStatus, sources = CryptoCurrencyStatus.Sources( networkSource = networkStatusValue.source, quoteSource = quoteStatus?.value?.source ?: StatusSource.ACTUAL, @@ -135,6 +138,7 @@ internal class CurrencyStatusOperations( pendingTransactions = currentTransactions, networkAddress = networkStatusValue.address, yieldBalance = currentYieldBalance, + yieldSupplyStatus = yieldSupplyStatus, sources = CryptoCurrencyStatus.Sources( networkSource = networkStatusValue.source, quoteSource = quoteValue.source, 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 bad702ce42..215e06104f 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 @@ -7,6 +7,7 @@ import arrow.core.toNonEmptySetOrNull import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.network.getAddress import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.staking.YieldBalance @@ -72,7 +73,7 @@ class CurrencyStatusProxyCreator { currencies.map { currency -> val quote = quoteStatuses?.firstOrNull { it.rawCurrencyId == currency.id.rawCurrencyId } val networkStatus = networksStatuses?.firstOrNull { it.network == currency.network } - val address = extractAddress(networkStatus) + val address = networkStatus.getAddress() val supportedIntegration = StakingIntegrationID.create(currencyId = currency.id)?.value diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/utils/NetworkAddressExt.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/utils/NetworkAddressExt.kt deleted file mode 100644 index 1a20e0c8bc..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/utils/NetworkAddressExt.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.domain.tokens.utils - -import com.tangem.domain.models.network.NetworkStatus - -/** Extract address from [networkStatus] */ -internal fun extractAddress(networkStatus: NetworkStatus?): String? { - return when (val value = networkStatus?.value) { - is NetworkStatus.NoAccount -> value.address.defaultAddress.value - is NetworkStatus.Unreachable -> value.address?.defaultAddress?.value - is NetworkStatus.Verified -> value.address.defaultAddress.value - else -> null - } -} \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCaseTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCaseTest.kt index b0025a5e46..be67de0762 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCaseTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCaseTest.kt @@ -188,7 +188,6 @@ internal class ApplyTokenListSortingUseCaseTest { currenciesRepository = tokensRepository, dispatchers = TestingCoroutineDispatcherProvider(), multiWalletCryptoCurrenciesSupplier = mockk(), - tokensFeatureToggles = mockk(), ) private fun getTokensRepository( diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt index 8f3529b210..d856cd5af9 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt @@ -97,6 +97,7 @@ internal object MockNetworks { defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), ), source = StatusSource.ACTUAL, + yieldSupplyStatuses = mapOf(), ), ) @@ -113,6 +114,7 @@ internal object MockNetworks { defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), ), source = StatusSource.ACTUAL, + yieldSupplyStatuses = mapOf(), ), ) @@ -130,6 +132,7 @@ internal object MockNetworks { defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), ), source = StatusSource.ACTUAL, + yieldSupplyStatuses = mapOf(), ), ) } \ No newline at end of file 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 555289ed3d..a9b9b31d66 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 @@ -156,6 +156,7 @@ internal object MockTokensStates { ).address, yieldBalance = null, sources = CryptoCurrencyStatus.Sources(), + yieldSupplyStatus = null, ) is QuoteStatus.Data -> CryptoCurrencyStatus.Loaded( amount = amount, @@ -167,6 +168,7 @@ internal object MockTokensStates { networkAddress = requireNotNull(networkStatus.value as? NetworkStatus.Verified).address, yieldBalance = null, sources = CryptoCurrencyStatus.Sources(), + yieldSupplyStatus = null, ) } status.copy(value = value) @@ -185,6 +187,7 @@ internal object MockTokensStates { ).address, yieldBalance = null, sources = CryptoCurrencyStatus.Sources(), + yieldSupplyStatus = null, ), ) } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt index a5e7e04e36..f2b3a91661 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt @@ -1,10 +1,21 @@ package com.tangem.domain.transaction import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet interface FeeRepository { /** Returns if fee is approximate for current [networkId] */ fun isFeeApproximate(networkId: Network.ID, amountType: AmountType): Boolean + + /** Returns fee calculated for the transaction [transactionData] */ + suspend fun calculateFee( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + transactionData: TransactionData, + ): TransactionFee } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/FeeErrorResolver.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/FeeErrorResolver.kt new file mode 100644 index 0000000000..058cfa752a --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/FeeErrorResolver.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.transaction.error + +interface FeeErrorResolver { + + fun resolve(throwable: Throwable): GetFeeError +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt index 322f65b335..f528a1c75a 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt @@ -12,8 +12,6 @@ import com.tangem.domain.networks.single.SingleNetworkStatusProducer import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.error.AssociateAssetError import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.isNullOrZero @@ -22,10 +20,8 @@ import kotlinx.coroutines.flow.firstOrNull class AssociateAssetUseCase( private val cardSdkConfigRepository: CardSdkConfigRepository, private val walletManagersFacade: WalletManagersFacade, - private val currenciesRepository: CurrenciesRepository, private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke( @@ -33,25 +29,19 @@ class AssociateAssetUseCase( currency: CryptoCurrency, ): Either { return either { - val networkCoin = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.firstOrNull { - val network = currency.network - it.network.id == network.id && it.network.derivationPath == network.derivationPath - } - ?: error("Unable to create network coin for currencyID: ${currency.id}") - } else { - currenciesRepository.getNetworkCoin( - userWalletId = userWalletId, - networkId = currency.network.id, - derivationPath = currency.network.derivationPath, - ) - } + val networkCoin = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.firstOrNull { + val network = currency.network + it.network.id == network.id && it.network.derivationPath == network.derivationPath + } + ?: error("Unable to create network coin for currencyID: ${currency.id}") + if (isBalanceZero(userWalletId, networkCoin)) { raise(AssociateAssetError.NotEnoughBalance(networkCoin)) } + val signer = cardSdkConfigRepository.getCommonSigner( cardId = null, twinKey = null, // use null here because no assets support for Twin cards diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareAndSignUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareAndSignUseCase.kt index 4fecd3147f..004f33b3bc 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareAndSignUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareAndSignUseCase.kt @@ -11,13 +11,13 @@ import com.tangem.domain.card.models.TwinKey import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.error.SendTransactionError class PrepareAndSignUseCase( private val transactionRepository: TransactionRepository, private val cardSdkConfigRepository: CardSdkConfigRepository, + private val getHotTransactionSigner: (UserWallet.Hot) -> TransactionSigner, ) { suspend operator fun invoke( @@ -57,7 +57,13 @@ class PrepareAndSignUseCase( } private fun createSigner(userWallet: UserWallet): TransactionSigner { - userWallet.requireColdWallet() // TODO [REDACTED_TASK_KEY] + return when (userWallet) { + is UserWallet.Hot -> getHotTransactionSigner(userWallet) + is UserWallet.Cold -> createColdSigner(userWallet) + } + } + + private fun createColdSigner(userWallet: UserWallet.Cold): TransactionSigner { val card = userWallet.scanResponse.card val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendLargeSolanaTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendLargeSolanaTransactionUseCase.kt new file mode 100644 index 0000000000..819faacd7b --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendLargeSolanaTransactionUseCase.kt @@ -0,0 +1,55 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.blockchain.blockchains.solana.SolanaWalletManager +import com.tangem.blockchain.extensions.Result +import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins +import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.walletmanager.WalletManagersFacade + +/** + * Use case for sending large Solana transactions that cannot be signed directly by the card. + * It handles the transaction creating alt tables and sending the transaction through the Solana network. + * + * @property cardSdkConfigRepository Repository to access card SDK configurations and signers. + * @property walletManagersFacade Facade to manage and retrieve wallet managers. + */ +class SendLargeSolanaTransactionUseCase( + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val walletManagersFacade: WalletManagersFacade, +) { + + suspend operator fun invoke( + userWallet: UserWallet.Cold, + network: Network, + txHash: ByteArray, + ): Either { + val card = userWallet.scanResponse.card + val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins + + val signer = cardSdkConfigRepository.getCommonSigner( + cardId = card.cardId.takeIf { isCardNotBackedUp }, + twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + ) + + val walletManager = walletManagersFacade + .getOrCreateWalletManager(userWallet.walletId, network) + ?: error("WalletManager is null") + + if (walletManager !is SolanaWalletManager) return SendTransactionError.UnknownError().left() + val result = walletManager.handleLargeLegacyTransaction(signer, txHash) + return when (result) { + is Result.Failure -> SendTransactionError.BlockchainSdkError( + code = result.error.code, + message = result.error.customMessage, + ).left() + is Result.Success -> Unit.right() + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt new file mode 100644 index 0000000000..05725f0183 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.pay.datasource + +import arrow.core.Either +import com.tangem.domain.visa.model.VisaAuthTokens + +interface TangemPayAuthDataSource { + + suspend fun generateNewAuthTokens(address: String, cardId: String): Either + + suspend fun refreshAuthTokens(refreshToken: String): Either +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt new file mode 100644 index 0000000000..b018ac10da --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.pay.model + +private const val APPROVED_KYC_STATUS = "APPROVED" +private const val ACTIVE_PI_STATUS = "active" + +data class CustomerInfo( + val productInstance: ProductInstance?, + val kycStatus: String?, +) { + + fun isKycApproved() = kycStatus == APPROVED_KYC_STATUS + + fun isProductInstanceActive() = productInstance?.status == ACTIVE_PI_STATUS +} + +data class ProductInstance( + val id: String, + val status: String, +) \ 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 index 7d46ff2d52..01d3a268d7 100644 --- 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 @@ -6,9 +6,8 @@ import com.tangem.domain.pay.KycStartInfo interface KycRepository { - suspend fun getKycStartInfo(address: String, cardId: String): Either - - interface Factory { - fun create(): KycRepository - } + /** + * Returns KYC data to start or continue the survey + */ + suspend fun getKycStartInfo(): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt new file mode 100644 index 0000000000..4838f46c53 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.pay.repository + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.pay.model.CustomerInfo + +interface OnboardingRepository { + + suspend fun validateDeeplink(link: String): Either + + suspend fun getCustomerInfo(): Either +} \ 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/datasource/VisaAuthRemoteDataSource.kt similarity index 80% rename from domain/visa/src/main/kotlin/com/tangem/domain/visa/repository/VisaAuthRepository.kt rename to domain/visa/src/main/kotlin/com/tangem/domain/visa/datasource/VisaAuthRemoteDataSource.kt index 098ca44c00..251532eeea 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/visa/repository/VisaAuthRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/visa/datasource/VisaAuthRemoteDataSource.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.visa.repository +package com.tangem.domain.visa.datasource import arrow.core.Either import com.tangem.domain.visa.error.VisaApiError @@ -6,7 +6,7 @@ import com.tangem.domain.visa.model.VisaAuthChallenge import com.tangem.domain.visa.model.VisaAuthSignedChallenge import com.tangem.domain.visa.model.VisaAuthTokens -interface VisaAuthRepository { +interface VisaAuthRemoteDataSource { suspend fun getCardAuthChallenge( cardId: String, @@ -26,7 +26,11 @@ interface VisaAuthRepository { sessionId: String, signature: String, nonce: String, - ): Either + ): Either + + suspend fun refreshCustomerWalletAuthTokens( + refreshToken: VisaAuthTokens.RefreshToken, + ): Either suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): Either diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt index 572d3aeb9a..15ccc11fc1 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt @@ -11,4 +11,15 @@ data class WcSessionDTO( val url: String?, val securityStatus: CheckDAppResult = CheckDAppResult.FAILED_TO_VERIFY, val connectingTime: Long? = null, +) + +/** + * keep in mind that [WcSessionDTO.topic] will be empty + * you must associate it with SdkSession by [pairingTopic] + */ +@JsonClass(generateAdapter = true) +data class WcPendingApprovalSessionDTO( + val pairingTopic: String, + val session: WcSessionDTO, + val expiredTime: Long, ) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Account.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Account.kt deleted file mode 100644 index 8c370b9fbd..0000000000 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Account.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tangem.domain.walletconnect.model.legacy - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -@JsonClass(generateAdapter = true) -data class Account( - @Json(name = "chainId") - val chainId: String, - - @Json(name = "walletAddress") - val walletAddress: String, - - @Json(name = "derivationPath") - val derivationPath: String?, -) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Session.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Session.kt deleted file mode 100644 index 5ca9f80ed4..0000000000 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Session.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.domain.walletconnect.model.legacy - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -@JsonClass(generateAdapter = true) -data class Session( - @Json(name = "topic") - val topic: String, - - @Json(name = "accounts") - val accounts: List, -) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/WalletConnectSessionsRepository.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/WalletConnectSessionsRepository.kt deleted file mode 100644 index dce7f7f080..0000000000 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/WalletConnectSessionsRepository.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tangem.domain.walletconnect.model.legacy - -interface WalletConnectSessionsRepository { - suspend fun loadSessions(userWallet: String): List - - suspend fun saveSession(userWallet: String, session: Session) - - suspend fun removeSession(userWallet: String, topic: String) -} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/repository/WcSessionsManager.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/repository/WcSessionsManager.kt index c2c2af4914..d74c2e9960 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/repository/WcSessionsManager.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/repository/WcSessionsManager.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.Flow interface WcSessionsManager { val sessions: Flow>> - suspend fun saveSession(session: WcSession) suspend fun removeSession(session: WcSession): Either suspend fun findSessionByTopic(topic: String): WcSession? } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/BlockAidTransactionCheck.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/BlockAidTransactionCheck.kt index 925877b063..4447132e96 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/BlockAidTransactionCheck.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/BlockAidTransactionCheck.kt @@ -1,7 +1,6 @@ package com.tangem.domain.walletconnect.usecase.method import com.domain.blockaid.models.transaction.CheckTransactionResult -import com.domain.blockaid.models.transaction.simultation.TokenInfo import com.tangem.domain.core.lce.LceFlow interface BlockAidTransactionCheck { @@ -13,14 +12,6 @@ interface BlockAidTransactionCheck { data class Plain(override val result: CheckTransactionResult) : Result - data class Approval( - override val result: CheckTransactionResult, - val approval: WcApproval, - val tokenInfo: TokenInfo, - val isMutable: Boolean, - ) : Result { - - suspend fun approvalAmount() = approval.getAmount() - } + data class Approval(override val result: CheckTransactionResult) : Result } } \ 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 af7e9c585f..150fd03424 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 @@ -46,4 +46,11 @@ interface WcMutableFee { interface WcApproval { fun getAmount(): WcApprovedAmount? fun updateAmount(amount: WcApprovedAmount?) +} + +/** + * Defines if multiple signatures are required for the transaction + */ +interface SignRequirements { + fun isMultipleSignRequired(): Boolean } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairUseCase.kt index 1097cfc7a5..a306af513f 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairUseCase.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairUseCase.kt @@ -2,6 +2,7 @@ package com.tangem.domain.walletconnect.usecase.pair import arrow.core.Either import com.tangem.domain.walletconnect.model.* +import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData import kotlinx.coroutines.flow.Flow interface WcPairUseCase { @@ -27,7 +28,7 @@ sealed interface WcPairState { data class Loading(override val session: WcSessionApprove) : Approving data class Result( override val session: WcSessionApprove, - val result: Either, + val result: Either, ) : Approving } } \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt new file mode 100644 index 0000000000..ca71e8b7a6 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.wallets.hot + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.hot.sdk.model.* + +interface HotWalletAccessor { + + suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List): List + + suspend fun derivePublicKeys(hotWalletId: HotWalletId, request: DeriveWalletRequest): DerivedPublicKeyResponse + + suspend fun exportSeedPhrase(hotWalletId: HotWalletId): SeedPhrasePrivateInfo + + suspend fun unlockContextual(hotWalletId: HotWalletId): UnlockHotWallet + + fun getContextualUnlock(hotWalletId: HotWalletId): UnlockHotWallet? + + fun clearContextualUnlock(hotWalletId: HotWalletId) + + fun clearContextualUnlock(userWalletId: UserWalletId) + + fun clearAllContextualUnlock() +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt index 6e58c523ae..7513e6e3bf 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt @@ -59,6 +59,10 @@ interface WalletsRepository { suspend fun setNotificationsEnabled(userWalletId: UserWalletId, isEnabled: Boolean) + fun isUpgradeWalletNotificationEnabled(userWalletId: UserWalletId): Flow + + suspend fun dismissUpgradeWalletNotification(userWalletId: UserWalletId) + @Throws suspend fun setWalletName(walletId: String, walletName: String) diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearAllHotWalletContextualUnlockUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearAllHotWalletContextualUnlockUseCase.kt new file mode 100644 index 0000000000..0dddccd934 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearAllHotWalletContextualUnlockUseCase.kt @@ -0,0 +1,16 @@ + +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import com.tangem.domain.wallets.hot.HotWalletAccessor + +class ClearAllHotWalletContextualUnlockUseCase( + private val hotWalletAccessor: HotWalletAccessor, +) { + + operator fun invoke(): Either { + return Either.catch { + hotWalletAccessor.clearAllContextualUnlock() + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearHotWalletContextualUnlockUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearHotWalletContextualUnlockUseCase.kt new file mode 100644 index 0000000000..caf1ea5457 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearHotWalletContextualUnlockUseCase.kt @@ -0,0 +1,24 @@ + +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.hot.HotWalletAccessor +import com.tangem.hot.sdk.model.HotWalletId + +class ClearHotWalletContextualUnlockUseCase( + private val hotWalletAccessor: HotWalletAccessor, +) { + + operator fun invoke(hotWalletId: HotWalletId): Either { + return Either.catch { + hotWalletAccessor.clearContextualUnlock(hotWalletId) + } + } + + operator fun invoke(userWalletId: UserWalletId): Either { + return Either.catch { + hotWalletAccessor.clearContextualUnlock(userWalletId) + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ColdWalletAndHasMissedDerivationsUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ColdWalletAndHasMissedDerivationsUseCase.kt new file mode 100644 index 0000000000..ff715f8d69 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ColdWalletAndHasMissedDerivationsUseCase.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.wallets.usecase + +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.derivations.DerivationsRepository + +/** + * Helps to determine whether the tangem icon should be displayed on the buttons for interacting with the wallet. + */ +class ColdWalletAndHasMissedDerivationsUseCase( + private val derivationsRepository: DerivationsRepository, + private val userWalletUseCase: GetUserWalletUseCase, +) { + suspend operator fun invoke( + userWalletId: UserWalletId, + networksWithDerivationPath: Map, + ): Boolean { + val userWallet = userWalletUseCase.invoke(userWalletId).getOrNull() ?: return false + return userWallet is UserWallet.Cold && + derivationsRepository.hasMissedDerivations(userWalletId, networksWithDerivationPath) + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt new file mode 100644 index 0000000000..2aeb755fba --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.wallets.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.repository.WalletsRepository + +class DismissUpgradeWalletNotificationUseCase( + private val walletsRepository: WalletsRepository, +) { + suspend operator fun invoke(userWalletId: UserWalletId) { + walletsRepository.dismissUpgradeWalletNotification(userWalletId) + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ExportSeedPhraseUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ExportSeedPhraseUseCase.kt new file mode 100644 index 0000000000..465a2560c3 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ExportSeedPhraseUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import com.tangem.domain.wallets.hot.HotWalletAccessor +import com.tangem.hot.sdk.model.HotWalletId +import com.tangem.hot.sdk.model.SeedPhrasePrivateInfo + +class ExportSeedPhraseUseCase( + private val hotWalletAccessor: HotWalletAccessor, +) { + + suspend operator fun invoke(hotWalletId: HotWalletId): Either { + return Either.catch { + hotWalletAccessor.exportSeedPhrase(hotWalletId) + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetHotWalletContextualUnlockUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetHotWalletContextualUnlockUseCase.kt new file mode 100644 index 0000000000..629c4cad06 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetHotWalletContextualUnlockUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import com.tangem.domain.wallets.hot.HotWalletAccessor +import com.tangem.hot.sdk.model.HotWalletId +import com.tangem.hot.sdk.model.UnlockHotWallet + +class GetHotWalletContextualUnlockUseCase( + private val hotWalletAccessor: HotWalletAccessor, +) { + + suspend operator fun invoke(hotWalletId: HotWalletId): Either { + return Either.catch { + hotWalletAccessor.getContextualUnlock(hotWalletId) + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt new file mode 100644 index 0000000000..d38fec4fe2 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.wallets.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.repository.WalletsRepository +import kotlinx.coroutines.flow.Flow + +class IsUpgradeWalletNotificationEnabledUseCase( + private val walletsRepository: WalletsRepository, +) { + operator fun invoke(userWalletId: UserWalletId): Flow { + return walletsRepository.isUpgradeWalletNotificationEnabled(userWalletId) + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsWalletAlreadySavedUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsWalletAlreadySavedUseCase.kt new file mode 100644 index 0000000000..c620ca93ce --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsWalletAlreadySavedUseCase.kt @@ -0,0 +1,32 @@ +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.domain.core.wallets.UserWalletsListRepository +import com.tangem.domain.core.wallets.error.SaveWalletError +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.legacy.UserWalletsListManager + +class IsWalletAlreadySavedUseCase( + private val userWalletsListManager: UserWalletsListManager, + private val userWalletsListRepository: UserWalletsListRepository, + private val useNewRepository: Boolean, +) { + + suspend operator fun invoke( + userWallet: UserWallet, + canOverride: Boolean = false, + ): Either { + return if (useNewRepository) { + either { + userWalletsListRepository.userWalletsSync() + .any { it.walletId == userWallet.walletId } + } + } else { + either { + userWalletsListManager.userWalletsSync + .any { it.walletId == userWallet.walletId } + } + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockHotWalletContextualUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockHotWalletContextualUseCase.kt new file mode 100644 index 0000000000..982821ce5b --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockHotWalletContextualUseCase.kt @@ -0,0 +1,18 @@ + +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import com.tangem.domain.wallets.hot.HotWalletAccessor +import com.tangem.hot.sdk.model.HotWalletId +import com.tangem.hot.sdk.model.UnlockHotWallet + +class UnlockHotWalletContextualUseCase( + private val hotWalletAccessor: HotWalletAccessor, +) { + + suspend operator fun invoke(hotWalletId: HotWalletId): Either { + return Either.catch { + hotWalletAccessor.unlockContextual(hotWalletId) + } + } +} \ No newline at end of file diff --git a/domain/yield-supply/build.gradle.kts b/domain/yield-supply/build.gradle.kts new file mode 100644 index 0000000000..e10c9dba42 --- /dev/null +++ b/domain/yield-supply/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.domain.yield.supply" +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +dependencies { + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.transaction.models) + implementation(projects.domain.transaction) + implementation(projects.domain.legacy) + + /** Tandem SDK */ + implementation(tangemDeps.blockchain) + + /** Other */ + implementation(deps.arrow.core) + + /** tests */ + testImplementation(projects.common.test) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt new file mode 100644 index 0000000000..34a17c1905 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.yield.supply + +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus + +interface YieldSupplyTransactionRepository { + + suspend fun createEnterTransactions( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): List + + suspend fun createExitTransaction( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + yieldSupplyStatus: YieldSupplyStatus, + fee: Fee?, + ): TransactionData.Uncompiled +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt new file mode 100644 index 0000000000..23e3efbe1d --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt @@ -0,0 +1,78 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplyEnterCallData +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.transaction.error.FeeErrorResolver +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.utils.extensions.isSingleItem + +class YieldSupplyEstimateEnterFeeUseCase( + private val feeRepository: FeeRepository, + private val feeErrorResolver: FeeErrorResolver, +) { + suspend operator fun invoke( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + transactionDataList: List, + ): Either> = Either.catch { + val withCalculatedFee = transactionDataList.filter { + (it.extras as? EthereumTransactionExtras)?.callData !is EthereumYieldSupplyEnterCallData + }.map { transaction -> + transaction.copy( + fee = feeRepository.calculateFee( + userWallet = userWallet, + cryptoCurrency = cryptoCurrency, + transactionData = transaction, + ).normal, + ) + } + + val withEstimatedCalculatedFee = transactionDataList.filter { + (it.extras as? EthereumTransactionExtras)?.callData is EthereumYieldSupplyEnterCallData + }.map { transaction -> + if (transactionDataList.isSingleItem()) { + transaction.copy( + fee = feeRepository.calculateFee( + userWallet = userWallet, + cryptoCurrency = cryptoCurrency, + transactionData = transaction, + ).normal, + ) + } else { + transaction.copy(fee = withCalculatedFee.firstOrNull()?.fee?.fixFee(cryptoCurrency)) + } + } + + // Transactions order must be preserved + withCalculatedFee + withEstimatedCalculatedFee + }.mapLeft(feeErrorResolver::resolve) + + private fun Fee.fixFee(cryptoCurrency: CryptoCurrency) = when (this) { + is Fee.Ethereum.Legacy -> copy( + gasLimit = ETHEREUM_CONSTANT_GAS_LIMIT, + amount = amount.copy( + value = gasPrice.multiply(ETHEREUM_CONSTANT_GAS_LIMIT) + .toBigDecimal().movePointLeft(cryptoCurrency.decimals), + ), + ) + is Fee.Ethereum.EIP1559 -> copy( + gasLimit = ETHEREUM_CONSTANT_GAS_LIMIT, + amount = amount.copy( + value = maxFeePerGas.multiply(ETHEREUM_CONSTANT_GAS_LIMIT) + .toBigDecimal().movePointLeft(cryptoCurrency.decimals), + ), + ) + else -> this + } + + private companion object { + // Using constant gas limit to avoid fee calculation errors when contract address is not deployed yet + val ETHEREUM_CONSTANT_GAS_LIMIT = 350_000.toBigInteger() + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStartEarningUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStartEarningUseCase.kt new file mode 100644 index 0000000000..b5e73e680f --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStartEarningUseCase.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.tangem.blockchain.common.TransactionData +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository + +class YieldSupplyStartEarningUseCase( + private val yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): Either> = Either.catch { + yieldSupplyTransactionRepository.createEnterTransactions( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStopEarningUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStopEarningUseCase.kt new file mode 100644 index 0000000000..aab058196b --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStopEarningUseCase.kt @@ -0,0 +1,29 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository + +class YieldSupplyStopEarningUseCase( + private val yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + fee: Fee?, + ): Either = Either.catch { + val yieldTokenStatus = cryptoCurrencyStatus.value.yieldSupplyStatus ?: error("") + val cryptoCurrency = cryptoCurrencyStatus.currency + + yieldSupplyTransactionRepository.createExitTransaction( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + yieldSupplyStatus = yieldTokenStatus, + fee = null, + ) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt new file mode 100644 index 0000000000..a38b1d1f15 --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt @@ -0,0 +1,172 @@ +package com.tangem.domain.yield.supply + +import arrow.core.Either +import com.google.common.truth.Truth +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.transaction.error.FeeErrorResolver +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.math.BigInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class YieldSupplyEstimateEnterFeeUseCaseTest { + private val feeRepository: FeeRepository = mockk() + private val feeErrorResolver: FeeErrorResolver = mockk() + private val useCase = YieldSupplyEstimateEnterFeeUseCase(feeRepository, feeErrorResolver) + + private val userWallet: UserWallet = mockk() + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) { + every { decimals } returns 18 + } + + private fun ethLegacyFee( + gasPrice: BigInteger = BigInteger.valueOf(100_000_000_000L), + gasLimit: BigInteger = BigInteger.valueOf(21_000), + ) = Fee.Ethereum.Legacy( + gasPrice = gasPrice, + gasLimit = gasLimit, + amount = BigDecimal.ONE.convertToSdkAmount(cryptoCurrency), + ) + + private fun ethEip1559Fee( + maxFeePerGas: BigInteger = BigInteger.valueOf(100_000_000_000L), + gasLimit: BigInteger = BigInteger.valueOf(21_000), + ) = Fee.Ethereum.EIP1559( + maxFeePerGas = maxFeePerGas, + priorityFee = BigInteger.ONE, + gasLimit = gasLimit, + amount = BigDecimal.ONE.convertToSdkAmount(cryptoCurrency), + ) + + private fun uncompiled(fee: Fee) = TransactionData.Uncompiled( + fee = fee, + amount = BigDecimal.ONE.convertToSdkAmount(cryptoCurrency), + contractAddress = null, + sourceAddress = "0x1234567890123456789012345678901234567890", + destinationAddress = "0x1234567890123456789012345678901234567890", + ) + + @Test + fun `test 1 transaction uses constant gas limit Legacy`() = runTest { + val fee = TransactionFee.Single(ethLegacyFee()) + val tx = uncompiled(ethLegacyFee()) + + coEvery { feeRepository.calculateFee(any(), any(), any()) } returns fee + + val result = useCase(userWallet, cryptoCurrency, listOf(tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(1) + + val lastFee = txs.last().fee as Fee.Ethereum.Legacy + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 2 transactions, only last uses constant gas limit Legacy`() = runTest { + val fee = TransactionFee.Single(ethLegacyFee()) + val tx = uncompiled(ethLegacyFee()) + + coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee) + + val result = useCase(userWallet, cryptoCurrency, listOf(tx, tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(2) + + val firstFee = txs.first().fee as Fee.Ethereum.Legacy + val lastFee = txs.last().fee as Fee.Ethereum.Legacy + Truth.assertThat(firstFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 3 transactions, only last uses constant gas limit Legacy`() = runTest { + val fee = TransactionFee.Single(ethLegacyFee()) + val tx = uncompiled(ethLegacyFee()) + coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee, fee) + + val result = useCase(userWallet, cryptoCurrency, listOf(tx, tx, tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(3) + + val firstFee = txs[0].fee as Fee.Ethereum.Legacy + val secondFee = txs[1].fee as Fee.Ethereum.Legacy + val lastFee = txs[2].fee as Fee.Ethereum.Legacy + Truth.assertThat(firstFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(secondFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 1 transaction uses constant gas limit Eip1559`() = runTest { + val fee = TransactionFee.Single(ethEip1559Fee()) + val tx = uncompiled(ethEip1559Fee()) + + coEvery { feeRepository.calculateFee(any(), any(), any()) } returns fee + + val result = useCase(userWallet, cryptoCurrency, listOf(tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(1) + + val lastFee = txs.last().fee as Fee.Ethereum.EIP1559 + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 2 transactions, only last uses constant gas limit Eip1559`() = runTest { + val fee = TransactionFee.Single(ethEip1559Fee()) + val tx = uncompiled(ethEip1559Fee()) + + coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee) + + val result = useCase(userWallet, cryptoCurrency, listOf(tx, tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(2) + + val firstFee = txs.first().fee as Fee.Ethereum.EIP1559 + val lastFee = txs.last().fee as Fee.Ethereum.EIP1559 + Truth.assertThat(firstFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 3 transactions, only last uses constant gas limit Eip1559`() = runTest { + val fee = TransactionFee.Single(ethEip1559Fee()) + val tx = uncompiled(ethEip1559Fee()) + coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee, fee) + + val result = useCase(userWallet, cryptoCurrency, listOf(tx, tx, tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(3) + + val firstFee = txs[0].fee as Fee.Ethereum.EIP1559 + val secondFee = txs[1].fee as Fee.Ethereum.EIP1559 + val lastFee = txs[2].fee as Fee.Ethereum.EIP1559 + Truth.assertThat(firstFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(secondFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } +} \ No newline at end of file diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0dd13a52a2..871b050803 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -29,43 +29,9 @@ platform :android do FileUtils.cp("../app/src/main/assets/tangem-app-config/android/google-services/dev/google-services.json", "../app") FileUtils.cp("../tangem-android-tools/CI/gradle_properties/tests_ci_gradle.properties", "../gradle.properties") puts File.read("../gradle.properties") - gradle(task: "testDebugUnitTest") + gradle(task: "testGoogleDebugUnitTest") end - - 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("../tangem-android-tools/CI/gradle_properties/build_ci_gradle.properties", "../gradle.properties") - puts File.read("../gradle.properties") - - gradle( - task: "bundle", - build_type: "Release", - properties: { - 'versionCode' => options[:versionCode], - 'versionName' => options[:versionName], - "android.injected.signing.store.file" => options[:keystore], - "android.injected.signing.store.password" => options[:store_password], - "android.injected.signing.key.alias" => options[:key_alias], - "android.injected.signing.key.password" => options[:key_password], - } - ) - gradle( - task: "assemble", - build_type: "Release", - properties: { - 'versionCode' => options[:versionCode], - 'versionName' => options[:versionName], - "android.injected.signing.store.file" => options[:keystore], - "android.injected.signing.store.password" => options[:store_password], - "android.injected.signing.key.alias" => options[:key_alias], - "android.injected.signing.key.password" => options[:key_password], - } - ) - end - - 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") @@ -74,7 +40,7 @@ platform :android do gradle( task: "assemble", - build_type: "Internal", + build_type: "GoogleInternal", properties: { 'versionCode' => ENV['version_code'], 'versionName' => ENV['version_name'], diff --git a/features/account/api/build.gradle.kts b/features/account/api/build.gradle.kts index e3e35ac4cd..5e9cc443b6 100644 --- a/features/account/api/build.gradle.kts +++ b/features/account/api/build.gradle.kts @@ -13,7 +13,12 @@ dependencies { /* Project - Core */ implementation(projects.core.decompose) implementation(projects.core.ui) + implementation(projects.common.ui) /* Project - Domain */ implementation(projects.domain.models) + implementation(projects.domain.core) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) } \ No newline at end of file diff --git a/features/account/api/src/main/java/com/tangem/features/account/AccountSelectorComponent.kt b/features/account/api/src/main/java/com/tangem/features/account/AccountSelectorComponent.kt new file mode 100644 index 0000000000..4e751499ed --- /dev/null +++ b/features/account/api/src/main/java/com/tangem/features/account/AccountSelectorComponent.kt @@ -0,0 +1,24 @@ +package com.tangem.features.account + +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.account.Account +import kotlinx.coroutines.flow.StateFlow + +interface AccountSelectorComponent : ComposableBottomSheetComponent { + + data class Params( + val onDismiss: () -> Unit, + val accountsBalanceFetcher: AccountsBalanceFetcher, + val controller: AccountSelectorController, + ) + + interface Factory { + fun create(appComponentContext: AppComponentContext, params: Params): AccountSelectorComponent + } +} + +interface AccountSelectorController { + val selectedAccount: StateFlow + fun selectAccount(account: Account?) +} \ No newline at end of file diff --git a/features/account/api/src/main/java/com/tangem/features/account/AccountsBalanceFetcher.kt b/features/account/api/src/main/java/com/tangem/features/account/AccountsBalanceFetcher.kt new file mode 100644 index 0000000000..77283e9678 --- /dev/null +++ b/features/account/api/src/main/java/com/tangem/features/account/AccountsBalanceFetcher.kt @@ -0,0 +1,39 @@ +package com.tangem.features.account + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.error.TokenListError +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface AccountsBalanceFetcher { + + val data: Flow + + val mode: StateFlow + fun updateMode(mode: Mode) + + data class Data( + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val balances: Map>, + ) + + data class AccountBalance( + val balance: Lce, + ) + + sealed interface Mode { + data class All(val onlyMultiCurrency: Boolean) : Mode + data class Wallet(val walletId: UserWalletId) : Mode + } + + interface Factory { + fun create(mode: Mode, scope: CoroutineScope): AccountsBalanceFetcher + } +} \ No newline at end of file diff --git a/features/account/impl/build.gradle.kts b/features/account/impl/build.gradle.kts index dc77a38d28..924e0c0cdc 100644 --- a/features/account/impl/build.gradle.kts +++ b/features/account/impl/build.gradle.kts @@ -29,6 +29,15 @@ dependencies { /** Domain */ implementation(projects.domain.models) implementation(projects.domain.account) + implementation(projects.domain.core) + implementation(projects.domain.appCurrency) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.balanceHiding) + implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) /** Common */ implementation(projects.common.ui) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt index a93cdcca2b..f1c4734092 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt @@ -1,43 +1,87 @@ package com.tangem.features.account.archived +import com.tangem.common.ui.account.toUM import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.res.R -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.account.usecase.ArchivedAccountList +import com.tangem.domain.account.usecase.GetArchivedAccountsUseCase import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase -import com.tangem.domain.models.account.Account +import com.tangem.domain.core.lce.Lce import com.tangem.domain.models.account.AccountId import com.tangem.features.account.ArchivedAccountListComponent import com.tangem.features.account.archived.entity.AccountArchivedUM +import com.tangem.features.account.archived.entity.AccountArchivedUMBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject -@Suppress("UnusedPrivateMember") // todo account +@Suppress("LongParameterList") internal class ArchivedAccountListModel @Inject constructor( paramsContainer: ParamsContainer, private val messageSender: UiMessageSender, private val router: Router, override val dispatchers: CoroutineDispatcherProvider, private val recoverCryptoPortfolioUseCase: RecoverCryptoPortfolioUseCase, + private val getArchivedAccountsUseCase: GetArchivedAccountsUseCase, + private val umBuilder: AccountArchivedUMBuilder, ) : Model() { private val params = paramsContainer.require() + private val onCloseClick = { router.pop() } val uiState: StateFlow get() = _uiState private val _uiState: MutableStateFlow = MutableStateFlow(getInitialState()) + private var getArchivedAccountsJob = JobHolder() - private fun confirmRecoverDialog(accountId: AccountId) { - val account: Account? = null // todo account find - account ?: return + init { + getArchivedAccounts() + } + + private fun getArchivedAccounts() { + getArchivedAccountsUseCase(params.userWalletId) + .conflate() + .distinctUntilChanged() + .onEach { lce -> + val newState = when (lce) { + is Lce.Content -> umBuilder.mapContent( + accounts = lce.content, + onCloseClick = onCloseClick, + confirmRecoverDialog = { confirmRecoverDialog(it) }, + ) + is Lce.Error -> umBuilder.mapError( + throwable = lce.error, + onCloseClick = onCloseClick, + getArchivedAccounts = { getArchivedAccounts() }, + ) + is Lce.Loading -> lce.partialContent?.let { content -> + umBuilder.mapContent( + accounts = content, + onCloseClick = onCloseClick, + confirmRecoverDialog = { confirmRecoverDialog(it) }, + ) + } + } + newState?.let { _uiState.value = newState } + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + .saveIn(getArchivedAccountsJob) + } + + private fun confirmRecoverDialog(account: ArchivedAccount) { val secondAction = EventMessageAction( title = resourceReference(R.string.common_cancel), onClick = {}, @@ -48,8 +92,11 @@ internal class ArchivedAccountListModel @Inject constructor( ) messageSender.send( DialogMessage( - title = stringReference(account.accountName.value), - message = TextReference.EMPTY, + title = resourceReference(R.string.account_archived_recover_dialog_title), + message = resourceReference( + id = R.string.account_archived_recover_dialog_description, + formatArgs = wrappedList(account.name.toUM().value), + ), firstActionBuilder = { firstAction }, secondActionBuilder = { secondAction }, ), @@ -58,11 +105,19 @@ internal class ArchivedAccountListModel @Inject constructor( private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch { recoverCryptoPortfolioUseCase(accountId) + .onLeft { Timber.e(it.toString()) } + .onRight { showSuccessRecoverMessage() } + router.pop() + } + + private fun showSuccessRecoverMessage() { + val message = resourceReference(R.string.account_recover_success_message) + messageSender.send(ToastMessage(message = message)) } private fun getInitialState(): AccountArchivedUM { return AccountArchivedUM.Loading( - onCloseClick = { router.pop() }, + onCloseClick = onCloseClick, ) } } \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUM.kt index bd72960062..a29481e093 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUM.kt @@ -23,5 +23,6 @@ internal data class ArchivedAccountUM( val accountName: TextReference, val accountIconUM: CryptoPortfolioIconUM, val tokensInfo: TextReference, - val onClick: (accountId: String) -> Unit, + val networksInfo: TextReference, + val onClick: () -> Unit, ) \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUMBuilder.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUMBuilder.kt new file mode 100644 index 0000000000..1b7b801cf3 --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUMBuilder.kt @@ -0,0 +1,55 @@ +package com.tangem.features.account.archived.entity + +import com.tangem.common.ui.account.toUM +import com.tangem.core.res.R +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.account.usecase.ArchivedAccountList +import kotlinx.collections.immutable.toImmutableList +import timber.log.Timber +import javax.inject.Inject + +internal class AccountArchivedUMBuilder @Inject constructor() { + + fun mapContent( + accounts: ArchivedAccountList, + onCloseClick: () -> Unit, + confirmRecoverDialog: (account: ArchivedAccount) -> Unit, + ) = AccountArchivedUM.Content( + onCloseClick = onCloseClick, + accounts = accounts + .map { account -> account.mapArchivedAccountUM(confirmRecoverDialog) } + .toImmutableList(), + ) + + fun ArchivedAccount.mapArchivedAccountUM(confirmRecoverDialog: (account: ArchivedAccount) -> Unit) = + ArchivedAccountUM( + accountId = accountId.value, + accountName = name.toUM().value, + accountIconUM = icon.toUM(), + tokensInfo = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + networksInfo = pluralReference( + R.plurals.common_networks_count, + count = networksCount, + formatArgs = wrappedList(networksCount), + ), + onClick = { confirmRecoverDialog(this) }, + ) + + fun mapError( + throwable: Throwable, + onCloseClick: () -> Unit, + getArchivedAccounts: () -> Unit, + ): AccountArchivedUM.Error { + Timber.e(throwable) + return AccountArchivedUM.Error( + onCloseClick = onCloseClick, + onRetryClick = { getArchivedAccounts() }, + ) + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt index a563f952d3..fca08ec492 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt @@ -21,6 +21,7 @@ import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe @@ -125,14 +126,21 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod Row( modifier = modifier .fillMaxWidth() - .clickable(onClick = { item.onClick(item.accountId) }) + .clickable(onClick = item.onClick) .padding(all = TangemTheme.dimens.spacing12), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { + val subtitle = stringResourceSafe( + id = R.string.account_label_tokens_info, + formatArgs = arrayOf( + item.tokensInfo.resolveReference(), + item.networksInfo.resolveReference(), + ), + ) AccountRow( title = item.accountName, - subtitle = item.tokensInfo, + subtitle = stringReference(subtitle), icon = item.accountIconUM, modifier = Modifier.weight(1f), ) @@ -140,7 +148,7 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod SecondarySmallButton( config = SmallButtonConfig( text = resourceReference(R.string.account_archived_recover), - onClick = { item.onClick(item.accountId) }, + onClick = item.onClick, ), ) } @@ -166,7 +174,8 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider isValidName is AccountCreateEditComponent.Params.Edit -> { - val isNewName = this.account.name != params.account.accountName.value + val oldName = params.account.accountName.toUM() + + val isNewName = this.account.name != oldName val isNewIcon = this.account.portfolioIcon != params.account.portfolioIcon isValidName && (isNewName || isNewIcon) } @@ -170,10 +202,14 @@ internal class AccountCreateEditModel @Inject constructor( it.updateDerivationIndex(derivationIndex = derivationIndex.value) } } - .onLeft { + .onLeft { cause -> handleError( error = AccountFeatureError.CreateAccount.UnableToGetDerivationIndex, - params = mapOf("userWalletId" to userWalletId.stringValue), + message = cause.toString(), + params = mapOf( + "userWalletId" to userWalletId.stringValue, + "cause" to cause.toString(), + ), ) return@launch @@ -181,8 +217,12 @@ internal class AccountCreateEditModel @Inject constructor( } } - private fun handleError(error: AccountFeatureError, params: Map = mapOf()) { - val exception = IllegalStateException(error.toString()) + private fun handleError( + error: AccountFeatureError, + message: String? = null, + params: Map = mapOf(), + ) { + val exception = IllegalStateException("$error. Cause: $message") Timber.e(exception) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt index 330fc2352f..c8b95db8c0 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt @@ -1,11 +1,12 @@ package com.tangem.features.account.createedit.entity +import com.tangem.common.ui.account.AccountNameUM import com.tangem.common.ui.account.CryptoPortfolioIconUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.account.CryptoPortfolioIcon import kotlinx.collections.immutable.ImmutableList -data class AccountCreateEditUM( +internal data class AccountCreateEditUM( val title: TextReference, val account: Account, val colorsState: Colors, @@ -15,11 +16,11 @@ data class AccountCreateEditUM( ) { data class Account( - val name: String, + val name: AccountNameUM, val portfolioIcon: CryptoPortfolioIconUM, val derivationInfo: DerivationInfo, val inputPlaceholder: TextReference, - val onNameChange: (String) -> Unit, + val onNameChange: (AccountNameUM) -> Unit, ) sealed interface DerivationInfo { @@ -48,6 +49,7 @@ data class AccountCreateEditUM( data class Button( val isButtonEnabled: Boolean, + val showProgress: Boolean, val onConfirmClick: () -> Unit, val text: TextReference, ) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt index 24b69681c9..3b710ed06d 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt @@ -1,5 +1,6 @@ package com.tangem.features.account.createedit.entity +import com.tangem.common.ui.account.AccountNameUM import com.tangem.common.ui.account.toUM import com.tangem.core.res.R import com.tangem.core.ui.extensions.TextReference @@ -24,17 +25,17 @@ internal class AccountCreateEditUMBuilder( is AccountCreateEditComponent.Params.Edit -> resourceReference(R.string.account_form_title_edit) } - fun initAccountUM(onNameChange: (String) -> Unit): AccountCreateEditUM.Account { + fun initAccountUM(onNameChange: (AccountNameUM) -> Unit): AccountCreateEditUM.Account { return when (params) { is AccountCreateEditComponent.Params.Create -> AccountCreateEditUM.Account( - name = "", + name = AccountNameUM.Custom(raw = ""), portfolioIcon = createIcon, derivationInfo = AccountCreateEditUM.DerivationInfo.Empty, inputPlaceholder = resourceReference(R.string.account_form_placeholder_new_account), onNameChange = onNameChange, ) is AccountCreateEditComponent.Params.Edit -> AccountCreateEditUM.Account( - name = params.account.accountName.value, + name = params.account.accountName.toUM(), portfolioIcon = params.account.portfolioIcon.toUM(), derivationInfo = createAccountDerivationInfo( index = (params.account as Account.CryptoPortfolio).derivationIndex.value, @@ -76,6 +77,7 @@ internal class AccountCreateEditUMBuilder( } return AccountCreateEditUM.Button( isButtonEnabled = false, + showProgress = false, onConfirmClick = onConfirmClick, text = text, ) @@ -108,7 +110,7 @@ internal class AccountCreateEditUMBuilder( ) } - fun AccountCreateEditUM.updateName(name: String): AccountCreateEditUM { + fun AccountCreateEditUM.updateName(name: AccountNameUM): AccountCreateEditUM { return this.copy(account = this.account.copy(name = name)) } @@ -116,6 +118,10 @@ internal class AccountCreateEditUMBuilder( return this.copy(buttonState = this.buttonState.copy(isButtonEnabled = isButtonEnabled)) } + fun AccountCreateEditUM.toggleProgress(showProgress: Boolean): AccountCreateEditUM { + return this.copy(buttonState = this.buttonState.copy(showProgress = showProgress)) + } + fun AccountCreateEditUM.updateDerivationIndex(derivationIndex: Int): AccountCreateEditUM { return this.copy( account = this.account.copy( diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt index 639b44419e..0e92bc9233 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -24,16 +25,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import com.tangem.common.ui.R -import com.tangem.common.ui.account.AccountIcon -import com.tangem.common.ui.account.AccountIconPreviewData -import com.tangem.common.ui.account.AccountIconSize -import com.tangem.common.ui.account.getResId -import com.tangem.common.ui.account.getUiColor +import com.tangem.common.ui.account.* import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH24 import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.fields.AutoSizeTextField import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme @@ -43,7 +41,6 @@ import com.tangem.features.account.createedit.entity.AccountCreateEditUM import com.tangem.features.account.createedit.entity.AccountCreateEditUM.Account import kotlinx.collections.immutable.toImmutableList -@Suppress("LongMethod", "MagicNumber") @Composable internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modifier = Modifier) { Column( @@ -65,7 +62,6 @@ internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modi modifier = Modifier .padding(horizontal = 16.dp) .weight(1f), - ) { AccountSummary(state.account) SpacerH24() @@ -85,6 +81,7 @@ internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modi .fillMaxWidth() .padding(16.dp), enabled = state.buttonState.isButtonEnabled, + showProgress = state.buttonState.showProgress, text = state.buttonState.text.resolveReference(), onClick = state.buttonState.onConfirmClick, ) @@ -103,7 +100,7 @@ private fun AccountSummary(account: Account) { Spacer(modifier = Modifier.height(24.dp)) AccountIcon( - name = stringReference(account.name), + name = account.name.value, icon = account.portfolioIcon, size = AccountIconSize.Large, ) @@ -116,13 +113,27 @@ private fun AccountSummary(account: Account) { ) Spacer(modifier = Modifier.height(2.dp)) + val wasDefault = remember { account.name is AccountNameUM.DefaultMain } + val defaultAccountName = AccountNameUM.DefaultMain.value.resolveReference() AutoSizeTextField( centered = true, textStyle = TangemTheme.typography.head, placeholder = account.inputPlaceholder, - value = account.name, + value = account.name.value.resolveReference(), singleLine = true, - onValueChange = account.onNameChange, + onValueChange = { + /* + * If the user had the default main account name and enters the same name during renaming, + * we should use the default value instead of custom to avoid breaking the name validation process. + */ + val newName = if (wasDefault && it == defaultAccountName) { + AccountNameUM.DefaultMain + } else { + AccountNameUM.Custom(raw = it) + } + + account.onNameChange(newName) + }, ) SpacerH(20.dp) } @@ -279,7 +290,7 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider when (account.isMainAccount) { + true -> AccountDetailsUM.ArchiveMode.None + false -> AccountDetailsUM.ArchiveMode.Available( + onArchiveAccountClick = ::onArchiveAccountClick, + ) + } + } return AccountDetailsUM( - accountName = params.account.accountName.value, + accountName = params.account.accountName.toUM().value, accountIcon = params.account.portfolioIcon.toUM(), onCloseClick = { router.pop() }, onAccountEditClick = ::onEditAccountClick, onManageTokensClick = ::onManageTokensClick, - onArchiveAccountClick = ::onArchiveAccountClick, + archiveMode = archiveMode, ) } } \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt index c68a1f098a..3938e757fb 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt @@ -1,12 +1,21 @@ package com.tangem.features.account.details.entity import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.core.ui.extensions.TextReference -data class AccountDetailsUM( - val accountName: String, +internal data class AccountDetailsUM( + val accountName: TextReference, val accountIcon: CryptoPortfolioIconUM, + val archiveMode: ArchiveMode, val onCloseClick: () -> Unit, val onAccountEditClick: () -> Unit, val onManageTokensClick: () -> Unit, - val onArchiveAccountClick: () -> Unit, -) \ No newline at end of file +) { + + sealed interface ArchiveMode { + data object None : ArchiveMode + data class Available( + val onArchiveAccountClick: () -> Unit, + ) : ArchiveMode + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt index 7f3c86da48..bd4a0e319f 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt @@ -65,21 +65,26 @@ internal fun AccountDetailsContent(state: AccountDetailsUM, modifier: Modifier = AccountRow(state) SpacerH16() ManageTokensRow(state) - SpacerH16() - ArchiveAccountRow(state) - SpacerH(8.dp) - Text( - modifier = Modifier.padding(horizontal = 12.dp), - text = stringResourceSafe(R.string.account_details_archive_description), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) + when (state.archiveMode) { + is AccountDetailsUM.ArchiveMode.Available -> { + SpacerH16() + ArchiveAccountRow(state.archiveMode) + SpacerH(8.dp) + Text( + modifier = Modifier.padding(horizontal = 12.dp), + text = stringResourceSafe(R.string.account_details_archive_description), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + AccountDetailsUM.ArchiveMode.None -> Unit + } } } } @Composable -private fun ArchiveAccountRow(state: AccountDetailsUM) { +private fun ArchiveAccountRow(state: AccountDetailsUM.ArchiveMode.Available) { Row( modifier = Modifier .fillMaxWidth() @@ -143,7 +148,7 @@ private fun AccountRow(state: AccountDetailsUM) { horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { AccountRow( - title = stringReference(state.accountName), + title = state.accountName, subtitle = resourceReference(R.string.account_form_name), icon = state.accountIcon, modifier = Modifier.weight(1f), @@ -176,12 +181,15 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val data: Flow + get() = _data.distinctUntilChanged() + override val mode: StateFlow + get() = _mode + + init { + _mode.flatMapLatest(::combineUseCases) + .flowOn(dispatchers.default) + .onEach { _data.emit(it) } + .launchIn(scope) + } + + override fun updateMode(mode: Mode) { + _mode.value = mode + } + + private fun combineUseCases(mode: Mode) = combine( + flow = getWallets() + .map { it.filterWallets(mode) } + .distinctUntilChanged() + .flatMapLatest { wallets -> balancesForWallets(wallets) }, + flow2 = appCurrencyFlow(), + flow3 = balanceHidingFlow(), + ) { balances, appCurrency, isBalanceHiding -> + Data( + appCurrency = appCurrency, + isBalanceHidden = isBalanceHiding, + balances = balances, + ) + } + + private fun List.filterWallets(mode: Mode): List = this.filter { wallet -> + when (mode) { + is Mode.All -> if (mode.onlyMultiCurrency) wallet.isMultiCurrency else true + is Mode.Wallet -> wallet.walletId == mode.walletId + } + } + + private fun balancesForWallets(wallets: List): Flow>> = + wallets.asFlow() + .map { walletAccountsBalancesFlow(it) } + .mapLatest { accountsBalances -> combine(accountsBalances) { pairs -> pairs.toMap() } } + .flattenConcat() + + private fun walletAccountsBalancesFlow(wallet: UserWallet): Flow>> = + walletAccounts(wallet) + .distinctUntilChanged() + .map { list -> list.map(::accountBalanceFlow) } + .mapLatest { balanceFlows -> combine(balanceFlows) { balances -> balances.toMap() } } + .flattenConcat() + .map { accountBalance -> wallet to accountBalance } + + private fun walletAccounts(wallet: UserWallet): Flow> = flow { + // todo account load accounts + val accounts: List = Account.CryptoPortfolio + .createMainAccount(wallet.walletId) + .let(::listOf) + emit(accounts) + } + + private fun accountBalanceFlow(account: Account): Flow> = flow { + // todo account load balance + val balance = AccountBalance(balance = Lce.Content(TotalFiatBalance.Loading)) + emit(account to balance) + } + + private fun appCurrencyFlow(): Flow { + return getSelectedAppCurrencyUseCase() + .map { it.getOrElse { AppCurrency.Default } } + .distinctUntilChanged() + } + + private fun balanceHidingFlow(): Flow { + return getBalanceHidingSettingsUseCase() + .map { it.isBalanceHidden } + .distinctUntilChanged() + } + + @AssistedFactory + interface Factory : AccountsBalanceFetcher.Factory { + override fun create(mode: Mode, scope: CoroutineScope): DefaultAccountsBalanceFetcher + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/AccountSelectorModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/AccountSelectorModel.kt new file mode 100644 index 0000000000..5012e93d14 --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/AccountSelectorModel.kt @@ -0,0 +1,87 @@ +package com.tangem.features.account.selector + +import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.core.utils.getOrElse +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.features.account.AccountSelectorComponent +import com.tangem.features.account.AccountsBalanceFetcher +import com.tangem.features.account.selector.entity.AccountSelectorItemUM +import com.tangem.features.account.selector.entity.AccountSelectorUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@ModelScoped +internal class AccountSelectorModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = paramsContainer.require() + private val balanceFetcher get() = params.accountsBalanceFetcher + private val selectorController get() = params.controller + + internal val state: StateFlow + field = MutableStateFlow(emptyState()) + + init { + balanceFetcher.data + .map { data -> + AccountSelectorUM( + isSingleWallet = balanceFetcher.mode.value is AccountsBalanceFetcher.Mode.Wallet, + items = buildUiList(data).toImmutableList(), + ) + } + .launchIn(modelScope) + } + + private fun buildUiList(data: AccountsBalanceFetcher.Data) = buildList { + fun Account.accountItemState(balance: Lce): TokenItemState { + val totalFiatBalance = balance.getOrElse( + ifError = { TotalFiatBalance.Failed }, + ifLoading = { TotalFiatBalance.Loading }, + ) + return when (this) { + is Account.CryptoPortfolio -> AccountCryptoPortfolioItemStateConverter( + appCurrency = data.appCurrency, + account = this, + onItemClick = { selectorController.selectAccount(it) }, + ).convert(totalFiatBalance) + } + } + + data.balances.forEach { wallet, accounts -> + if (wallet.isLocked) return@forEach + AccountSelectorItemUM.Wallet( + id = wallet.walletId.stringValue, + name = stringReference(wallet.name), + ).let(::add) + + accounts.forEach { account, balance -> + AccountSelectorItemUM.Account( + account = account.accountItemState(balance.balance), + isBalanceHidden = data.isBalanceHidden, + ).let(::add) + } + } + } + + private fun emptyState() = AccountSelectorUM( + items = persistentListOf(), + balanceFetcher.mode.value is AccountsBalanceFetcher.Mode.Wallet, + ) +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorComponent.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorComponent.kt new file mode 100644 index 0000000000..bc53b7bfb3 --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorComponent.kt @@ -0,0 +1,69 @@ +package com.tangem.features.account.selector + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.account.AccountSelectorComponent +import com.tangem.features.account.impl.R +import com.tangem.features.account.selector.ui.AccountSelectorContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAccountSelectorComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: AccountSelectorComponent.Params, +) : AppComponentContext by appComponentContext, AccountSelectorComponent { + + private val model: AccountSelectorModel = getOrCreateModel(params) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.state.collectAsStateWithLifecycle() + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = ::dismiss, + containerColor = TangemTheme.colors.background.primary, + title = { + TangemModalBottomSheetTitle( + title = resourceReference(R.string.common_choose_wallet), + startIconRes = R.drawable.ic_back_24, + onStartClick = ::dismiss, + ) + }, + content = { + AccountSelectorContent( + state = state, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) + }, + ) + } + + @AssistedFactory + interface Factory : AccountSelectorComponent.Factory { + override fun create( + appComponentContext: AppComponentContext, + params: AccountSelectorComponent.Params, + ): DefaultAccountSelectorComponent + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorController.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorController.kt new file mode 100644 index 0000000000..9be5ae3197 --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorController.kt @@ -0,0 +1,18 @@ +package com.tangem.features.account.selector + +import com.tangem.domain.models.account.Account +import com.tangem.features.account.AccountSelectorController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +internal class DefaultAccountSelectorController @Inject constructor() : AccountSelectorController { + + private val _selectedAccount: MutableStateFlow = MutableStateFlow(null) + override val selectedAccount: StateFlow get() = _selectedAccount + + override fun selectAccount(account: Account?) { + _selectedAccount.update { account } + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/AccountSelectorUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/AccountSelectorUM.kt new file mode 100644 index 0000000000..d8d05f0fea --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/AccountSelectorUM.kt @@ -0,0 +1,28 @@ +package com.tangem.features.account.selector.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +data class AccountSelectorUM( + val items: ImmutableList, + val isSingleWallet: Boolean, +) + +@Immutable +sealed interface AccountSelectorItemUM { + val id: String + + data class Wallet( + override val id: String, + val name: TextReference, + ) : AccountSelectorItemUM + + data class Account( + val account: TokenItemState, + val isBalanceHidden: Boolean, + ) : AccountSelectorItemUM { + override val id: String = account.id + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorBS.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorBS.kt new file mode 100644 index 0000000000..ec1cad3c9e --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorBS.kt @@ -0,0 +1,61 @@ +package com.tangem.features.account.selector.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +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.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.account.impl.R +import com.tangem.features.account.selector.entity.AccountSelectorUM + +@Composable +internal fun AccountSelectorBS(state: AccountSelectorUM, onDismiss: () -> Unit, modifier: Modifier = Modifier) { + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = onDismiss, + scrollableContent = false, + containerColor = TangemTheme.colors.background.secondary, + title = { + TangemModalBottomSheetTitle( + title = resourceReference(R.string.common_choose_account), + startIconRes = R.drawable.ic_back_24, + onStartClick = onDismiss, + ) + }, + content = { + AccountSelectorContent( + state = state, + contentPadding = PaddingValues(bottom = 16.dp), + modifier = modifier.padding(horizontal = 16.dp), + ) + }, + ) +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(AccountSelectorPreviewStateProvider::class) params: AccountSelectorUM) { + TangemThemePreview { + AccountSelectorBS( + state = params, + onDismiss = {}, + modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + ) + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorContent.kt new file mode 100644 index 0000000000..a8f54b000d --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorContent.kt @@ -0,0 +1,146 @@ +package com.tangem.features.account.selector.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.token.AccountItemPreviewData +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.account.selector.entity.AccountSelectorItemUM +import com.tangem.features.account.selector.entity.AccountSelectorUM +import com.tangem.features.account.selector.ui.AccountSelectorPreviewData.firstList +import kotlinx.collections.immutable.toImmutableList +import java.util.UUID + +@Composable +internal fun AccountSelectorContent( + state: AccountSelectorUM, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), +) { + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + ) { + val items = state.items + itemsIndexed( + items = items, + key = { _, item -> item.id }, + ) { index, item -> + val previewItem = items.getOrNull(index.dec()) + val offsetModifier = when { + previewItem == null -> Modifier + state.isSingleWallet -> Modifier.padding( + top = TangemTheme.dimens.spacing16, + ) + item is AccountSelectorItemUM.Wallet -> Modifier.padding( + top = TangemTheme.dimens.spacing16, + ) + else -> Modifier.padding( + top = TangemTheme.dimens.spacing8, + ) + } + + when (item) { + is AccountSelectorItemUM.Account -> TokenItem( + state = item.account, + isBalanceHidden = item.isBalanceHidden, + modifier = offsetModifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.primary), + ) + is AccountSelectorItemUM.Wallet -> WalletNameRow( + model = item, + modifier = offsetModifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + ) + } + } + } +} + +@Composable +private fun WalletNameRow(model: AccountSelectorItemUM.Wallet, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = model.name.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AccountSelectorContentPreview( + @PreviewParameter(AccountSelectorPreviewStateProvider::class) params: AccountSelectorUM, +) { + TangemThemePreview { + AccountSelectorContent( + state = params, + modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + ) + } +} + +internal object AccountSelectorPreviewData { + val firstList + get() = buildList { + AccountSelectorItemUM.Wallet( + id = UUID.randomUUID().toString(), + name = stringReference("Tangem 2.0"), + ).let(::add) + AccountItemPreviewData.accountItem + .let { AccountSelectorItemUM.Account(it, false) } + .let(::add) + AccountItemPreviewData.accountItem.copy(iconState = AccountItemPreviewData.accountLetterIcon) + .let { AccountSelectorItemUM.Account(it, false) } + .let(::add) + AccountSelectorItemUM.Wallet( + id = UUID.randomUUID().toString(), + name = stringReference("Tangem White"), + ).let(::add) + AccountItemPreviewData.accountItem.copy(iconState = AccountItemPreviewData.accountLetterIcon) + .let { AccountSelectorItemUM.Account(it, false) } + .let(::add) + } +} + +internal class AccountSelectorPreviewStateProvider : CollectionPreviewParameterProvider( + buildList { + val secondList = listOf( + AccountItemPreviewData.accountItem, + AccountItemPreviewData.accountItem.copy(iconState = AccountItemPreviewData.accountLetterIcon), + ).map { AccountSelectorItemUM.Account(it, false) } + + val first = AccountSelectorUM( + items = firstList.toImmutableList(), + isSingleWallet = false, + ) + val second = AccountSelectorUM( + items = secondList.toImmutableList(), + isSingleWallet = true, + ) + add(first) + add(second) + }, +) \ No newline at end of file diff --git a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt index 614b462e24..0d53779766 100644 --- a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt +++ b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt @@ -15,7 +15,7 @@ import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.settings.SetSaveWalletScreenShownUseCase +import com.tangem.domain.settings.SetAskBiometryShownUseCase import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase @@ -39,7 +39,7 @@ import javax.inject.Inject internal class AskBiometryModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, paramsContainer: ParamsContainer, - private val setSaveWalletScreenShownUseCase: SetSaveWalletScreenShownUseCase, + private val setAskBiometryShownUseCase: SetAskBiometryShownUseCase, private val settingsRepository: SettingsRepository, private val tangemSdkManager: TangemSdkManager, private val getSelectedWalletUseCase: GetSelectedWalletUseCase, @@ -66,7 +66,7 @@ internal class AskBiometryModel @Inject constructor( init { modelScope.launch { - setSaveWalletScreenShownUseCase() + setAskBiometryShownUseCase() } } @@ -111,7 +111,6 @@ internal class AskBiometryModel @Inject constructor( private suspend fun handleSuccessAllowing(userWallet: UserWallet) { walletsRepository.saveShouldSaveUserWallets(item = true) - settingsRepository.setShouldSaveAccessCodes(value = true) if (hotWalletFeatureToggles.isHotWalletEnabled) { walletsRepository.setUseBiometricAuthentication(value = true) @@ -120,6 +119,7 @@ internal class AskBiometryModel @Inject constructor( isBiometricsRequestPolicy = walletsRepository.requireAccessCode().not(), ) } else { + settingsRepository.setShouldSaveAccessCodes(value = true) if (userWallet is UserWallet.Cold) { cardSdkConfigRepository.setAccessCodeRequestPolicy( isBiometricsRequestPolicy = userWallet.hasAccessCode, diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt index 23d94a6c78..d5c27dd004 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt @@ -23,11 +23,11 @@ import com.tangem.domain.card.analytics.ParamCardCurrencyConverter import com.tangem.domain.card.analytics.Shop import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.core.wallets.error.SaveWalletError import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.builder.ColdUserWalletBuilder -import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.features.createwalletselection.entity.CreateWalletSelectionUM @@ -56,7 +56,7 @@ internal class CreateWalletSelectionModel @Inject constructor( private val saveWalletUseCase: SaveWalletUseCase, private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val urlOpener: UrlOpener, - private val userWalletsListManager: UserWalletsListManager, + private val userWalletsListRepository: UserWalletsListRepository, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { @@ -70,6 +70,17 @@ internal class CreateWalletSelectionModel @Inject constructor( ), ) + init { + showAlreadyHaveWalletWithDelay() + } + + private fun showAlreadyHaveWalletWithDelay() { + modelScope.launch { + delay(SHOW_ALREADY_HAVE_WALLET_DELAY) + uiState.update { it.copy(showAlreadyHaveWallet = true) } + } + } + private fun onMobileWalletClick() { router.push(AppRoute.CreateMobileWallet) } @@ -129,13 +140,20 @@ internal class CreateWalletSelectionModel @Inject constructor( return } - saveWalletUseCase(userWallet).fold( + saveWalletUseCase(userWallet = userWallet).fold( ifLeft = { delay(HIDE_PROGRESS_DELAY) setLoading(false) when (it) { is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") - is SaveWalletError.WalletAlreadySaved -> appRouter.replaceAll(AppRoute.Wallet) + is SaveWalletError.WalletAlreadySaved -> { + userWalletsListRepository.unlock( + userWalletId = userWallet.walletId, + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), + ).onRight { + appRouter.replaceAll(AppRoute.Wallet) + } + } } }, ifRight = { @@ -146,7 +164,7 @@ internal class CreateWalletSelectionModel @Inject constructor( ) } - private fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { + private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) if (currency != null) { analyticsEventHandler.send( @@ -154,7 +172,7 @@ internal class CreateWalletSelectionModel @Inject constructor( currency = currency, batch = scanResponse.card.batchId, signInType = SignInType.Card, - walletsCount = userWalletsListManager.walletsCount.toString(), + walletsCount = userWalletsListRepository.userWalletsSync().size.toString(), hasBackup = scanResponse.card.backupStatus?.isActive, ), ) @@ -181,4 +199,8 @@ internal class CreateWalletSelectionModel @Inject constructor( ), ) } + + companion object { + private const val SHOW_ALREADY_HAVE_WALLET_DELAY = 3000L + } } \ 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 index 38affd1226..ef14600b7a 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt @@ -3,6 +3,7 @@ package com.tangem.features.createwalletselection.entity internal data class CreateWalletSelectionUM( val isScanInProgress: Boolean = false, val hardwareWalletPrice: String = "$54.90", + val showAlreadyHaveWallet: Boolean = false, val onBackClick: () -> Unit, val onMobileWalletClick: () -> Unit, val onHardwareWalletClick: () -> Unit, diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt index 4106c3d8c6..cc4e15fb2a 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt @@ -1,6 +1,7 @@ package com.tangem.features.createwalletselection.ui import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -128,10 +129,12 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi onClick = state.onHardwareWalletClick, ) } - AlreadyHaveTangemWalletBlock( - onScanClick = state.onScanClick, - isScanInProgress = state.isScanInProgress, - ) + AnimatedVisibility(state.showAlreadyHaveWallet) { + AlreadyHaveTangemWalletBlock( + onScanClick = state.onScanClick, + isScanInProgress = state.isScanInProgress, + ) + } } } @@ -149,7 +152,7 @@ private fun WalletBlock( .padding(top = 8.dp) .clip(TangemTheme.shapes.roundedCornersXMedium) .background( - color = TangemTheme.colors.background.secondary, + color = TangemTheme.colors.field.primary, shape = TangemTheme.shapes.roundedCornersXMedium, ) .clickable(onClick = onClick) @@ -238,6 +241,7 @@ private fun PreviewCreateWalletContent() { TangemThemePreview { CreateWalletSelectionContent( state = CreateWalletSelectionUM( + showAlreadyHaveWallet = true, onBackClick = {}, onMobileWalletClick = {}, onHardwareWalletClick = {}, diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index e02206355e..cf7047bb68 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -11,16 +11,15 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.domain.card.common.TapWorkarounds.isVisa -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase -import com.tangem.domain.feedback.models.CardInfo +import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.repository.FeedbackFeatureToggles import com.tangem.domain.redux.LegacyAction import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.details.component.DetailsComponent @@ -56,7 +55,7 @@ internal class DetailsModel @Inject constructor( paramsContainer: ParamsContainer, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val appStateHolder: ReduxStateHolder, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getWalletsUseCase: GetWalletsUseCase, private val feedbackFeatureToggles: FeedbackFeatureToggles, @@ -121,20 +120,15 @@ internal class DetailsModel @Inject constructor( val selectedUserWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null") - if (selectedUserWallet is UserWallet.Hot) { - return@launch // TODO [REDACTED_TASK_KEY] [Hot Wallet] Send feedback - } - - val scanResponse = selectedUserWallet.requireColdWallet().scanResponse - val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch + val metaInfo = getWalletMetaInfoUseCase(selectedUserWallet.walletId).getOrNull() ?: return@launch val feedbackType = when { userWallets.all { it is UserWallet.Cold && it.scanResponse.card.isVisa } -> - FeedbackEmailType.Visa.DirectUserRequest(cardInfo) + FeedbackEmailType.Visa.DirectUserRequest(metaInfo) userWallets.all { it !is UserWallet.Cold || it.scanResponse.card.isVisa.not() } -> - FeedbackEmailType.DirectUserRequest(cardInfo) + FeedbackEmailType.DirectUserRequest(metaInfo) else -> { - showFeedbackEmailTypeOptionBS(cardInfo) + showFeedbackEmailTypeOptionBS(metaInfo) return@launch } } @@ -144,18 +138,14 @@ internal class DetailsModel @Inject constructor( } private fun openUseDesk() { - val userWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null") - - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] UseDesk + modelScope.launch { + val userWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null") + val metaInfo = getWalletMetaInfoUseCase.invoke(userWallet.walletId).getOrNull() ?: return@launch + router.push(AppRoute.Usedesk(metaInfo)) } - - val cardInfo = getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return - - router.push(AppRoute.Usedesk(cardInfo)) } - private fun showFeedbackEmailTypeOptionBS(selectedCardInfo: CardInfo) { + private fun showFeedbackEmailTypeOptionBS(selectedWalletMetaInfo: WalletMetaInfo) { state.update { it.copy( selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig( @@ -171,7 +161,7 @@ internal class DetailsModel @Inject constructor( content = SelectEmailFeedbackTypeBS( onOptionClick = { option -> onEmailFeedbackTypeOptionSelected( - selectedCardInfo = selectedCardInfo, + selectedWalletMetaInfo = selectedWalletMetaInfo, option = option, ) @@ -189,32 +179,33 @@ internal class DetailsModel @Inject constructor( } private fun onEmailFeedbackTypeOptionSelected( - selectedCardInfo: CardInfo, + selectedWalletMetaInfo: WalletMetaInfo, option: SelectEmailFeedbackTypeBS.Option, ) { modelScope.launch { val feedbackType = when (option) { SelectEmailFeedbackTypeBS.Option.General -> { - if (selectedCardInfo.isVisa.not()) { - FeedbackEmailType.DirectUserRequest(selectedCardInfo) + if (selectedWalletMetaInfo.isVisa == false) { + FeedbackEmailType.DirectUserRequest(selectedWalletMetaInfo) } else { - val scanResponse = getWalletsUseCase.invokeSync() - .firstOrNull { it is UserWallet.Cold && it.scanResponse.card.isVisa.not() } - ?.requireColdWallet()?.scanResponse ?: return@launch + val userWallet = getWalletsUseCase.invokeSync() + .firstOrNull { + it is UserWallet.Hot || it is UserWallet.Cold && it.scanResponse.card.isVisa.not() + } ?: return@launch - val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch - FeedbackEmailType.DirectUserRequest(cardInfo) + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch + FeedbackEmailType.DirectUserRequest(metaInfo) } } SelectEmailFeedbackTypeBS.Option.Visa -> { - if (selectedCardInfo.isVisa) { - FeedbackEmailType.Visa.DirectUserRequest(selectedCardInfo) + if (selectedWalletMetaInfo.isVisa == true) { + FeedbackEmailType.Visa.DirectUserRequest(selectedWalletMetaInfo) } else { - val scanResponse = getWalletsUseCase.invokeSync() + val userWallet = getWalletsUseCase.invokeSync() .firstOrNull { it is UserWallet.Cold && it.scanResponse.card.isVisa } - ?.requireColdWallet()?.scanResponse ?: return@launch - val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch - FeedbackEmailType.Visa.DirectUserRequest(cardInfo) + ?: return@launch + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch + FeedbackEmailType.Visa.DirectUserRequest(metaInfo) } } } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt index bfdc9b284b..9ced337ddf 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt @@ -48,7 +48,7 @@ internal class UserWalletListModel @Inject constructor( private val userWalletsFetcher = userWalletsFetcherFactory.create( messageSender = messageSender, onlyMultiCurrency = false, - authMode = false, + isAuthMode = false, onWalletClick = { userWalletId -> router.push(AppRoute.WalletSettings(userWalletId)) }, ) diff --git a/features/disclaimer/impl/src/main/java/com/tangem/features/disclaimer/impl/model/DisclaimerModel.kt b/features/disclaimer/impl/src/main/java/com/tangem/features/disclaimer/impl/model/DisclaimerModel.kt index 50fe65280b..02d4cd7765 100644 --- a/features/disclaimer/impl/src/main/java/com/tangem/features/disclaimer/impl/model/DisclaimerModel.kt +++ b/features/disclaimer/impl/src/main/java/com/tangem/features/disclaimer/impl/model/DisclaimerModel.kt @@ -7,7 +7,6 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.domain.card.repository.CardRepository -import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.settings.NeverRequestPermissionUseCase import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase @@ -29,8 +28,6 @@ internal class DisclaimerModel @Inject constructor( private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase, private val appFinisher: AppFinisher, private val notificationsRepository: NotificationsRepository, - private val getIsHuaweiDeviceWithoutGoogleServicesUseCase: GetIsHuaweiDeviceWithoutGoogleServicesUseCase, - paramsContainer: ParamsContainer, ) : Model() { @@ -51,8 +48,7 @@ internal class DisclaimerModel @Inject constructor( } else { cardRepository.acceptTangemTOS() val shouldAskPushPermission = notificationsRepository.shouldShowSubscribeOnNotificationsAfterUpdate() - val isHuaweiDevice = getIsHuaweiDeviceWithoutGoogleServicesUseCase() - if (shouldAskPushPermission && !isHuaweiDevice) { + if (shouldAskPushPermission) { router.push(AppRoute.PushNotification(AppRoute.PushNotification.Source.Stories)) } else { neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION) diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt index 10ae9abbf8..5c73a404c1 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt @@ -26,6 +26,7 @@ import com.tangem.domain.card.analytics.ParamCardCurrencyConverter import com.tangem.domain.card.analytics.Shop import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.core.wallets.error.SaveWalletError import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.redux.ReduxStateHolder @@ -41,6 +42,7 @@ import com.tangem.features.home.api.HomeComponent import com.tangem.features.home.impl.ui.state.HomeUM import com.tangem.features.home.impl.ui.state.Stories import com.tangem.features.home.impl.ui.state.getRestrictedStories +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay @@ -76,6 +78,8 @@ internal class HomeModel @Inject constructor( private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val urlOpener: UrlOpener, private val userWalletsListManager: UserWalletsListManager, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val userWalletsListRepository: UserWalletsListRepository, private val reduxStateHolder: ReduxStateHolder, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { @@ -216,7 +220,7 @@ internal class HomeModel @Inject constructor( ) } - private fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { + private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) if (currency != null) { analyticsEventHandler.send( @@ -224,13 +228,21 @@ internal class HomeModel @Inject constructor( currency = currency, batch = scanResponse.card.batchId, signInType = SignInType.Card, - walletsCount = userWalletsListManager.walletsCount.toString(), + walletsCount = getWalletsCount().toString(), hasBackup = scanResponse.card.backupStatus?.isActive, ), ) } } + private suspend fun getWalletsCount(): Int { + return if (hotWalletFeatureToggles.isHotWalletEnabled) { + userWalletsListRepository.userWalletsSync().size + } else { + userWalletsListManager.walletsCount + } + } + private fun setLoading(isLoading: Boolean) { _uiState.update { it.copy(scanInProgress = isLoading) } } diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpgradeWalletComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpgradeWalletComponent.kt new file mode 100644 index 0000000000..8a9b7f6c14 --- /dev/null +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpgradeWalletComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.hotwallet + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +interface UpgradeWalletComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/ViewPhraseComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/ViewPhraseComponent.kt new file mode 100644 index 0000000000..99e7dcf23f --- /dev/null +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/ViewPhraseComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.hotwallet + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +interface ViewPhraseComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/hot-wallet/impl/build.gradle.kts b/features/hot-wallet/impl/build.gradle.kts index 2f25fc3478..481078432a 100644 --- a/features/hot-wallet/impl/build.gradle.kts +++ b/features/hot-wallet/impl/build.gradle.kts @@ -34,6 +34,8 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.models) implementation(projects.domain.settings) + implementation(projects.domain.feedback) + implementation(projects.domain.feedback.models) /** Common */ implementation(projects.common.ui) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeComponent.kt index 5a044f85ef..62fdd324b1 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeComponent.kt @@ -25,7 +25,9 @@ internal class AccessCodeComponent @AssistedInject constructor( override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() - DisableScreenshotsDisposableEffect() + if (!state.isConfirmMode) { + DisableScreenshotsDisposableEffect() + } AccessCode( modifier = modifier, @@ -34,12 +36,11 @@ internal class AccessCodeComponent @AssistedInject constructor( } interface ModelCallbacks { - fun onAccessCodeSet(userWalletId: UserWalletId, accessCode: String) - fun onAccessCodeConfirmed(userWalletId: UserWalletId) + fun onNewAccessCodeInput(userWalletId: UserWalletId, accessCode: String) + fun onAccessCodeUpdated(userWalletId: UserWalletId) } data class Params( - val isConfirmMode: Boolean, val accessCodeToConfirm: String? = null, val userWalletId: UserWalletId, val callbacks: ModelCallbacks, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt index 2db69f48ee..40ef51ebc0 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt @@ -5,32 +5,52 @@ 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.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.settings.CanUseBiometryUseCase +import com.tangem.domain.settings.SetAskBiometryShownUseCase +import com.tangem.domain.settings.ShouldShowAskBiometryUseCase import com.tangem.domain.wallets.repository.WalletsRepository +import com.tangem.domain.wallets.usecase.ClearHotWalletContextualUnlockUseCase +import com.tangem.domain.wallets.usecase.GetHotWalletContextualUnlockUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.hotwallet.accesscode.entity.AccessCodeUM +import com.tangem.features.hotwallet.impl.R import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.HotAuth import com.tangem.hot.sdk.model.UnlockHotWallet import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import timber.log.Timber import javax.inject.Inject +import kotlin.coroutines.resume +@Suppress("LongParameterList") @Stable @ModelScoped internal class AccessCodeModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val getUserWalletUseCase: GetUserWalletUseCase, + private val getHotWalletContextualUnlockUseCase: GetHotWalletContextualUnlockUseCase, + private val clearHotWalletContextualUnlockUseCase: ClearHotWalletContextualUnlockUseCase, private val userWalletsListRepository: UserWalletsListRepository, private val walletsRepository: WalletsRepository, private val tangemHotSdk: TangemHotSdk, + private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase, + private val setAskBiometryShownUseCase: SetAskBiometryShownUseCase, + private val canUseBiometryUseCase: CanUseBiometryUseCase, + private val uiMessageSender: UiMessageSender, ) : Model() { private val params = paramsContainer.require() @@ -41,7 +61,7 @@ internal class AccessCodeModel @Inject constructor( private fun getInitialState() = AccessCodeUM( accessCode = "", onAccessCodeChange = ::onAccessCodeChange, - isConfirmMode = params.isConfirmMode, + isConfirmMode = params.accessCodeToConfirm != null, buttonEnabled = false, buttonInProgress = false, onButtonClick = ::onButtonClick, @@ -51,7 +71,7 @@ internal class AccessCodeModel @Inject constructor( uiState.update { it.copy( accessCode = value, - buttonEnabled = if (params.isConfirmMode) { + buttonEnabled = if (params.accessCodeToConfirm != null) { value == params.accessCodeToConfirm } else { value.length == uiState.value.accessCodeLength @@ -61,12 +81,10 @@ internal class AccessCodeModel @Inject constructor( } private fun onButtonClick() { - if (!params.isConfirmMode) { - params.callbacks.onAccessCodeSet(params.userWalletId, uiState.value.accessCode) + if (params.accessCodeToConfirm == null) { + params.callbacks.onNewAccessCodeInput(params.userWalletId, uiState.value.accessCode) } else { - params.accessCodeToConfirm?.let { - setCode(params.userWalletId, it) - } + setCode(params.userWalletId, params.accessCodeToConfirm) } } @@ -81,12 +99,16 @@ internal class AccessCodeModel @Inject constructor( .getOrElse { error("User wallet with id $userWalletId not found") } if (userWallet !is UserWallet.Hot) return@launch - val unlockHotWallet = UnlockHotWallet(userWallet.hotWalletId, HotAuth.NoAuth) + val unlockHotWallet = getHotWalletContextualUnlockUseCase(userWallet.hotWalletId) + .getOrNull() + ?: UnlockHotWallet(userWallet.hotWalletId, HotAuth.NoAuth) var updatedHotWalletId = tangemHotSdk.changeAuth( unlockHotWallet = unlockHotWallet, auth = HotAuth.Password(accessCode.toCharArray()), ) + tryToAskForBiometry() + if (walletsRepository.requireAccessCode().not()) { updatedHotWalletId = tangemHotSdk.changeAuth( unlockHotWallet = UnlockHotWallet( @@ -117,7 +139,7 @@ internal class AccessCodeModel @Inject constructor( ) } - params.callbacks.onAccessCodeConfirmed(params.userWalletId) + params.callbacks.onAccessCodeUpdated(params.userWalletId) }.onFailure { Timber.e(it) @@ -127,4 +149,62 @@ internal class AccessCodeModel @Inject constructor( } } } + + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun tryToAskForBiometry() { + if (!shouldAskForBiometry()) return + + suspendCancellableCoroutine { continuation -> + var buttonClicked = false + uiMessageSender.send( + DialogMessage( + title = resourceReference(R.string.common_attention), + message = resourceReference(R.string.hot_access_code_set_biometric_ask), + firstAction = EventMessageAction( + title = resourceReference(R.string.common_allow), + onClick = { + buttonClicked = true + modelScope.launch { + setAskBiometryShownUseCase() + walletsRepository.setUseBiometricAuthentication(true) + walletsRepository.setRequireAccessCode(false) + if (continuation.isActive) { + continuation.resume(Unit) + } + } + }, + ), + secondAction = EventMessageAction( + title = resourceReference(R.string.save_user_wallet_agreement_dont_allow), + onClick = { + buttonClicked = true + modelScope.launch { + setAskBiometryShownUseCase() + if (continuation.isActive) { + continuation.resume(Unit) + } + } + }, + ), + onDismissRequest = { + if (continuation.isActive && buttonClicked.not()) { + continuation.resume(Unit) + } + }, + ), + ) + } + } + + private suspend fun shouldAskForBiometry(): Boolean { + val canUseBiometry = canUseBiometryUseCase() + val shouldShowAskBiometry = shouldShowAskBiometryUseCase() + + return canUseBiometry && shouldShowAskBiometry + } + + override fun onDestroy() { + clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId) + super.onDestroy() + } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt index 3924d978c7..4596985307 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt @@ -74,7 +74,7 @@ internal fun AccessCode(state: AccessCodeUM, modifier: Modifier = Modifier) { ) { PinTextField( length = state.accessCodeLength, - isPasswordVisual = !state.isConfirmMode, + isPasswordVisual = state.isConfirmMode, value = state.accessCode, pinTextColor = PinTextColor.Primary, onValueChange = state.onAccessCodeChange, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt index 328e74cc8c..423b382025 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt @@ -2,9 +2,15 @@ package com.tangem.features.hotwallet.addexistingwallet.entry import com.arkivanov.decompose.router.stack.* import com.tangem.common.routing.AppRoute +import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.settings.ShouldAskPermissionUseCase import com.tangem.features.hotwallet.addexistingwallet.entry.routing.AddExistingWalletRoute @@ -26,6 +32,7 @@ internal class AddExistingWalletModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, + @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { val hotWalletStepperComponentModelCallback = HotWalletStepperComponentModelCallback() @@ -56,9 +63,7 @@ internal class AddExistingWalletModel @Inject constructor( modelScope.launch { val shouldRequestPush = shouldAskPermissionUseCase(PUSH_PERMISSION) if (shouldRequestPush) { - // is yet blocked by [REDACTED_TASK_KEY] - // stackNavigation.replaceAll(AddExistingWalletRoute.PushNotifications) - stackNavigation.replaceAll(AddExistingWalletRoute.SetupFinished) + stackNavigation.replaceAll(AddExistingWalletRoute.PushNotifications) } else { stackNavigation.replaceAll(AddExistingWalletRoute.SetupFinished) } @@ -69,13 +74,31 @@ internal class AddExistingWalletModel @Inject constructor( stackNavigation.replaceAll(AddExistingWalletRoute.SetupFinished) } + private fun showSkipAccessCodeWarningDialog() { + uiMessageSender.send( + DialogMessage( + message = resourceReference(R.string.access_code_alert_skip_description), + title = resourceReference(R.string.access_code_alert_skip_title), + firstAction = EventMessageAction( + title = resourceReference(R.string.common_cancel), + onClick = {}, + ), + secondAction = EventMessageAction( + title = resourceReference(R.string.access_code_alert_skip_ok), + onClick = { navigateToPushNotificationsOrNext() }, + ), + dismissOnFirstAction = true, + ), + ) + } + inner class HotWalletStepperComponentModelCallback : HotWalletStepperComponent.ModelCallback { override fun onBackClick() { onChildBack() } override fun onSkipClick() { - navigateToPushNotificationsOrNext() + showSkipAccessCodeWarningDialog() } } @@ -102,11 +125,11 @@ internal class AddExistingWalletModel @Inject constructor( } inner class AccessCodeModelCallbacks : AccessCodeComponent.ModelCallbacks { - override fun onAccessCodeSet(userWalletId: UserWalletId, accessCode: String) { + override fun onNewAccessCodeInput(userWalletId: UserWalletId, accessCode: String) { stackNavigation.push(AddExistingWalletRoute.ConfirmAccessCode(userWalletId, accessCode)) } - override fun onAccessCodeConfirmed(userWalletId: UserWalletId) { + override fun onAccessCodeUpdated(userWalletId: UserWalletId) { navigateToPushNotificationsOrNext() } } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt index de7e7a76f2..6393ff0d6d 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt @@ -46,7 +46,6 @@ internal class AddExistingWalletChildFactory @Inject constructor( is AddExistingWalletRoute.SetAccessCode -> accessCodeComponentFactory.create( context = childContext, params = AccessCodeComponent.Params( - isConfirmMode = false, userWalletId = route.userWalletId, callbacks = model.accessCodeModelCallbacks, ), @@ -54,7 +53,6 @@ internal class AddExistingWalletChildFactory @Inject constructor( is AddExistingWalletRoute.ConfirmAccessCode -> accessCodeComponentFactory.create( context = childContext, params = AccessCodeComponent.Params( - isConfirmMode = true, accessCodeToConfirm = route.accessCode, userWalletId = route.userWalletId, callbacks = model.accessCodeModelCallbacks, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt index dab15c9946..e8d5c52060 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt @@ -23,11 +23,11 @@ import com.tangem.domain.card.analytics.ParamCardCurrencyConverter import com.tangem.domain.card.analytics.Shop import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.core.wallets.error.SaveWalletError import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.builder.ColdUserWalletBuilder -import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.features.hotwallet.addexistingwallet.start.entity.AddExistingWalletStartUM @@ -56,7 +56,7 @@ internal class AddExistingWalletStartModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val appRouter: AppRouter, private val urlOpener: UrlOpener, - private val userWalletsListManager: UserWalletsListManager, + private val userWalletsListRepository: UserWalletsListRepository, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { @@ -65,6 +65,7 @@ internal class AddExistingWalletStartModel @Inject constructor( internal val uiState: StateFlow field = MutableStateFlow( AddExistingWalletStartUM( + showWantToPurchaseBlock = false, isScanInProgress = false, onBackClick = params.callbacks::onBackClick, onImportPhraseClick = params.callbacks::onImportPhraseClick, @@ -73,6 +74,17 @@ internal class AddExistingWalletStartModel @Inject constructor( ), ) + init { + showWantToPurchaseBlockWithDelay() + } + + private fun showWantToPurchaseBlockWithDelay() { + modelScope.launch { + delay(SHOW_WANT_TO_PURCHASE_BLOCK_DELAY) + uiState.update { it.copy(showWantToPurchaseBlock = true) } + } + } + private fun onShopClick() { analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards) analyticsEventHandler.send(Shop.ScreenOpened) @@ -134,7 +146,14 @@ internal class AddExistingWalletStartModel @Inject constructor( setLoading(false) when (it) { is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") - is SaveWalletError.WalletAlreadySaved -> appRouter.replaceAll(AppRoute.Wallet) + is SaveWalletError.WalletAlreadySaved -> { + userWalletsListRepository.unlock( + userWalletId = userWallet.walletId, + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), + ).onRight { + appRouter.replaceAll(AppRoute.Wallet) + } + } } }, ifRight = { @@ -145,7 +164,7 @@ internal class AddExistingWalletStartModel @Inject constructor( ) } - private fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { + private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) if (currency != null) { analyticsEventHandler.send( @@ -153,7 +172,7 @@ internal class AddExistingWalletStartModel @Inject constructor( currency = currency, batch = scanResponse.card.batchId, signInType = SignInType.Card, - walletsCount = userWalletsListManager.walletsCount.toString(), + walletsCount = userWalletsListRepository.userWalletsSync().size.toString(), hasBackup = scanResponse.card.backupStatus?.isActive, ), ) @@ -180,4 +199,8 @@ internal class AddExistingWalletStartModel @Inject constructor( ), ) } + + companion object { + private const val SHOW_WANT_TO_PURCHASE_BLOCK_DELAY = 3000L + } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/entity/AddExistingWalletStartUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/entity/AddExistingWalletStartUM.kt index 37a5113f35..c448d2c471 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/entity/AddExistingWalletStartUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/entity/AddExistingWalletStartUM.kt @@ -1,6 +1,7 @@ package com.tangem.features.hotwallet.addexistingwallet.start.entity internal data class AddExistingWalletStartUM( + val showWantToPurchaseBlock: Boolean, val isScanInProgress: Boolean, val onBackClick: () -> Unit, val onImportPhraseClick: () -> Unit, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt index 6bf693e36b..42588c81de 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt @@ -1,6 +1,7 @@ package com.tangem.features.hotwallet.addexistingwallet.start.ui import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material3.CircularProgressIndicator @@ -62,7 +63,7 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi ) OptionBlock( modifier = Modifier - .padding(top = 24.dp), + .padding(top = 32.dp), backgroundColor = TangemTheme.colors.background.secondary, title = stringResourceSafe(R.string.wallet_import_seed_title), description = stringResourceSafe(R.string.wallet_import_seed_description), @@ -71,6 +72,8 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi enabled = true, ) OptionBlock( + modifier = Modifier + .padding(top = 8.dp), backgroundColor = TangemTheme.colors.background.secondary, title = stringResourceSafe(R.string.wallet_import_scan_title), description = stringResourceSafe(R.string.wallet_import_scan_description), @@ -99,6 +102,8 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi enabled = true, ) OptionBlock( + modifier = Modifier + .padding(top = 8.dp), backgroundColor = TangemTheme.colors.background.secondary, title = stringResourceSafe(R.string.wallet_import_google_drive_title), description = stringResourceSafe(R.string.wallet_import_google_drive_description), @@ -123,9 +128,11 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi enabled = false, ) } - BuyTangemWalletBlock( - onScanClick = state.onBuyCardClick, - ) + AnimatedVisibility(state.showWantToPurchaseBlock) { + BuyTangemWalletBlock( + onScanClick = state.onBuyCardClick, + ) + } } } @@ -172,6 +179,7 @@ private fun PreviewCreateWalletContent() { TangemThemePreview { AddExistingWalletStartContent( state = AddExistingWalletStartUM( + showWantToPurchaseBlock = true, isScanInProgress = true, onBackClick = {}, onImportPhraseClick = {}, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt index 8e90402e09..f76ab84370 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt @@ -1,6 +1,5 @@ package com.tangem.features.hotwallet.common.ui -import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -8,8 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -30,38 +29,16 @@ internal fun OptionBlock( backgroundColor: Color, modifier: Modifier = Modifier, ) { - val backgroundColor by animateColorAsState( - targetValue = if (enabled) { - backgroundColor - } else { - backgroundColor.copy(alpha = DISABLED_COLORS_ALPHA) - }, - ) - val titleColor by animateColorAsState( - targetValue = if (enabled) { - TangemTheme.colors.text.primary1 - } else { - TangemTheme.colors.text.primary1.copy(alpha = DISABLED_COLORS_ALPHA) - }, - ) - val descriptionColor by animateColorAsState( - targetValue = if (enabled) { - TangemTheme.colors.text.tertiary - } else { - TangemTheme.colors.text.tertiary.copy(alpha = DISABLED_COLORS_ALPHA) - }, - ) - Column( modifier = modifier .fillMaxWidth() - .padding(top = 8.dp) .clip(TangemTheme.shapes.roundedCornersXMedium) + .alpha(if (enabled) 1f else DISABLED_COLORS_ALPHA) .background( color = backgroundColor, shape = TangemTheme.shapes.roundedCornersXMedium, ) - .conditional(onClick != null) { + .conditional(onClick != null && enabled) { onClick?.let { clickableSingle(onClick = it) } ?: Modifier } .padding(16.dp), @@ -73,7 +50,7 @@ internal fun OptionBlock( .padding(end = 4.dp), text = title, style = TangemTheme.typography.subtitle1, - color = titleColor, + color = TangemTheme.colors.text.primary1, ) badge?.invoke() } @@ -82,7 +59,7 @@ internal fun OptionBlock( .padding(top = 4.dp), text = description, style = TangemTheme.typography.body2, - color = descriptionColor, + color = TangemTheme.colors.text.tertiary, ) } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ManualBackupPhraseComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ManualBackupPhraseComponent.kt index 62d7b79ed4..b66835823a 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ManualBackupPhraseComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ManualBackupPhraseComponent.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.security.DisableScreenshotsDisposableEffect import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.hotwallet.manualbackup.phrase.model.ManualBackupPhraseModel import com.tangem.features.hotwallet.manualbackup.phrase.ui.ManualBackupPhraseContent @@ -22,6 +23,7 @@ internal class ManualBackupPhraseComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + DisableScreenshotsDisposableEffect() ManualBackupPhraseContent( state = state, modifier = modifier, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/entity/ManualBackupPhraseUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/entity/ManualBackupPhraseUM.kt index 3a799f4f31..a2b29d0220 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/entity/ManualBackupPhraseUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/entity/ManualBackupPhraseUM.kt @@ -1,14 +1,10 @@ package com.tangem.features.hotwallet.manualbackup.phrase.entity +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf internal data class ManualBackupPhraseUM( val onContinueClick: () -> Unit, - val words: ImmutableList = persistentListOf(), -) { - data class MnemonicGridItem( - val index: Int, - val mnemonic: String, - ) -} \ No newline at end of file + val words: ImmutableList = persistentListOf(), +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/model/ManualBackupPhraseModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/model/ManualBackupPhraseModel.kt index e9625260ad..0c99cd5606 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/model/ManualBackupPhraseModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/model/ManualBackupPhraseModel.kt @@ -5,6 +5,7 @@ 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.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.hotwallet.manualbackup.phrase.ManualBackupPhraseComponent @@ -51,7 +52,7 @@ internal class ManualBackupPhraseModel @Inject constructor( uiState.update { it.copy( words = seedPhrasePrivateInfo.mnemonic.mnemonicComponents.mapIndexed { index, s -> - ManualBackupPhraseUM.MnemonicGridItem(index + 1, s) + EnumeratedTwoColumnGridItem(index + 1, s) }.toImmutableList(), ) } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ui/ManualBackupPhraseContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ui/ManualBackupPhraseContent.kt index 3bba1be463..86458b8395 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ui/ManualBackupPhraseContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ui/ManualBackupPhraseContent.kt @@ -7,20 +7,18 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.grid.EnumeratedTwoColumnGrid +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.hotwallet.impl.R import com.tangem.features.hotwallet.manualbackup.phrase.entity.ManualBackupPhraseUM -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @Composable @@ -41,8 +39,8 @@ internal fun ManualBackupPhraseContent(state: ManualBackupPhraseUM, modifier: Mo modifier = Modifier.padding(top = 20.dp), ) - SeedPhraseGridBlock( - mnemonicGridItems = state.words, + EnumeratedTwoColumnGrid( + items = state.words, modifier = Modifier .fillMaxWidth() .padding(top = 20.dp, bottom = 32.dp), @@ -97,70 +95,6 @@ private fun TitleBlock(state: ManualBackupPhraseUM, modifier: Modifier = Modifie } } -@Composable -private fun SeedPhraseGridBlock( - mnemonicGridItems: ImmutableList, - modifier: Modifier = Modifier, -) { - VerticalGrid( - modifier = modifier, - items = mnemonicGridItems, - ) { item -> - Row( - modifier = Modifier.padding(all = TangemTheme.dimens.size8), - verticalAlignment = Alignment.CenterVertically, - ) { - if (LocalLayoutDirection.current == LayoutDirection.Ltr) { - Text( - modifier = Modifier.width(TangemTheme.dimens.size40), - text = "${item.index}.", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - Text( - text = item.mnemonic, - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - ) - } else { - Text( - text = item.mnemonic, - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - ) - Text( - modifier = Modifier.width(TangemTheme.dimens.size40), - text = "${item.index}.", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - } - } - } -} - -@Composable -private inline fun VerticalGrid( - items: ImmutableList, - modifier: Modifier = Modifier, - crossinline content: @Composable (T) -> Unit, -) { - val columnLength = items.size / 2 - Row( - modifier = modifier, - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - repeat(2) { index -> - Column { - for (i in 0 until columnLength) { - val item = items[index * columnLength + i] - content(item) - } - } - } - } -} - @Preview(showBackground = true, widthDp = 360, heightDp = 640) @Preview(showBackground = true, widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -170,7 +104,7 @@ private fun Preview() { state = ManualBackupPhraseUM( onContinueClick = {}, words = List(12) { - ManualBackupPhraseUM.MnemonicGridItem( + EnumeratedTwoColumnGridItem( index = it + 1, mnemonic = "word${it + 1}", ) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeModel.kt index 19f8947793..5cf4e05649 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeModel.kt @@ -35,11 +35,11 @@ internal class UpdateAccessCodeModel @Inject constructor( } } - override fun onAccessCodeSet(userWalletId: UserWalletId, accessCode: String) { + override fun onNewAccessCodeInput(userWalletId: UserWalletId, accessCode: String) { stackNavigation.push(UpdateAccessCodeRoute.ConfirmAccessCode(userWalletId, accessCode)) } - override fun onAccessCodeConfirmed(userWalletId: UserWalletId) { + override fun onAccessCodeUpdated(userWalletId: UserWalletId) { router.pop() } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeChildFactory.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeChildFactory.kt index 354156a4c0..968560b8b3 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeChildFactory.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeChildFactory.kt @@ -19,7 +19,6 @@ internal class UpdateAccessCodeChildFactory @Inject constructor( is UpdateAccessCodeRoute.SetAccessCode -> accessCodeComponentFactory.create( context = childContext, params = AccessCodeComponent.Params( - isConfirmMode = false, userWalletId = route.userWalletId, callbacks = model, ), @@ -27,7 +26,6 @@ internal class UpdateAccessCodeChildFactory @Inject constructor( is UpdateAccessCodeRoute.ConfirmAccessCode -> accessCodeComponentFactory.create( context = childContext, params = AccessCodeComponent.Params( - isConfirmMode = true, accessCodeToConfirm = route.accessCode, userWalletId = route.userWalletId, callbacks = model, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeRoute.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeRoute.kt index a414995364..31d9113158 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeRoute.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeRoute.kt @@ -7,8 +7,13 @@ import kotlinx.serialization.Serializable internal sealed class UpdateAccessCodeRoute : Route { @Serializable - data class SetAccessCode(val userWalletId: UserWalletId) : UpdateAccessCodeRoute() + data class SetAccessCode( + val userWalletId: UserWalletId, + ) : UpdateAccessCodeRoute() @Serializable - data class ConfirmAccessCode(val userWalletId: UserWalletId, val accessCode: String) : UpdateAccessCodeRoute() + data class ConfirmAccessCode( + val userWalletId: UserWalletId, + val accessCode: String, + ) : UpdateAccessCodeRoute() } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt new file mode 100644 index 0000000000..2f0e91d1de --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt @@ -0,0 +1,39 @@ +package com.tangem.features.hotwallet.upgradewallet + +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.UpgradeWalletComponent +import com.tangem.features.hotwallet.upgradewallet.ui.UpgradeWalletContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("UnusedPrivateMember") +internal class DefaultUpgradeWalletComponent @AssistedInject constructor( + @Assisted private val context: AppComponentContext, + @Assisted private val params: UpgradeWalletComponent.Params, +) : UpgradeWalletComponent, AppComponentContext by context { + + private val model: UpgradeWalletModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + UpgradeWalletContent( + state = state, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : UpgradeWalletComponent.Factory { + override fun create( + context: AppComponentContext, + params: UpgradeWalletComponent.Params, + ): DefaultUpgradeWalletComponent + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt new file mode 100644 index 0000000000..00ca37071d --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt @@ -0,0 +1,146 @@ +package com.tangem.features.hotwallet.upgradewallet + +import com.tangem.common.core.TangemError +import com.tangem.common.core.TangemSdkError +import com.tangem.common.doOnFailure +import com.tangem.common.doOnSuccess +import com.tangem.common.routing.AppRoute +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.toWrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.wallets.usecase.ClearHotWalletContextualUnlockUseCase +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.features.hotwallet.UpgradeWalletComponent +import com.tangem.features.hotwallet.upgradewallet.entity.UpgradeWalletUM +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.sdk.extensions.localizedDescriptionRes +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@ModelScoped +internal class UpgradeWalletModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, + private val urlOpener: UrlOpener, + private val settingsRepository: SettingsRepository, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val uiMessageSender: UiMessageSender, + private val clearHotWalletContextualUnlockUseCase: ClearHotWalletContextualUnlockUseCase, + private val tangemSdkManager: TangemSdkManager, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, +) : Model() { + private val params = paramsContainer.require() + + private val _uiState = MutableStateFlow( + UpgradeWalletUM( + onBackClick = { router.pop() }, + onBuyTangemWalletClick = ::onBuyTangemWalletClick, + onScanDeviceClick = ::onScanDeviceClick, + ), + ) + internal val uiState: StateFlow = _uiState + + override fun onDestroy() { + clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId) + super.onDestroy() + } + + private fun onBuyTangemWalletClick() { + modelScope.launch { + generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) } + } + } + + private fun onScanDeviceClick() { + scanCard() + } + + private fun scanCard() { + modelScope.launch { + setLoading(true) + + val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes() + cardSdkConfigRepository.setAccessCodeRequestPolicy( + isBiometricsRequestPolicy = shouldSaveAccessCodes, + ) + + tangemSdkManager + .scanProduct() + .doOnSuccess { + delay(DELAY_SDK_DIALOG_CLOSE) + tangemSdkManager.changeDisplayedCardIdNumbersCount(it) + navigateToUpgradeFlow(it) + } + .doOnFailure { + showCardVerificationFailedDialog(it) + } + } + } + + private fun setLoading(isLoading: Boolean) { + _uiState.update { it.copy(isLoading = isLoading) } + } + + private fun showCardVerificationFailedDialog(error: TangemError) { + if (error !is TangemSdkError.CardVerificationFailed) return + + // TODO [REDACTED_TASK_KEY] track error + + val resource = error.localizedDescriptionRes() + val resId = resource.resId ?: R.string.common_unknown_error + val resArgs = resource.args.map { it.value } + + uiMessageSender.send( + DialogMessage( + message = resourceReference(id = resId, resArgs.toWrappedList()), + title = resourceReference(id = R.string.security_alert_title), + isDismissable = false, + firstActionBuilder = { + EventMessageAction( + title = resourceReference(id = R.string.alert_button_request_support), + onClick = { + modelScope.launch { + sendFeedbackEmailUseCase(type = FeedbackEmailType.CardAttestationFailed) + } + }, + ) + }, + secondActionBuilder = { cancelAction(onClick = {}) }, + ), + ) + } + + private fun navigateToUpgradeFlow(scanResponse: ScanResponse) { + setLoading(false) + router.push( + AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.UpgradeHotWallet( + userWalletId = params.userWalletId, + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/di/UpgradeWalletModule.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/di/UpgradeWalletModule.kt new file mode 100644 index 0000000000..3beb13bcab --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/di/UpgradeWalletModule.kt @@ -0,0 +1,25 @@ +package com.tangem.features.hotwallet.upgradewallet.di + +import com.tangem.core.decompose.model.Model +import com.tangem.features.hotwallet.UpgradeWalletComponent +import com.tangem.features.hotwallet.upgradewallet.DefaultUpgradeWalletComponent +import com.tangem.features.hotwallet.upgradewallet.UpgradeWalletModel +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 UpgradeWalletModule { + + @Binds + fun bindUpgradeWalletComponentFactory(impl: DefaultUpgradeWalletComponent.Factory): UpgradeWalletComponent.Factory + + @Binds + @IntoMap + @ClassKey(UpgradeWalletModel::class) + fun bindUpgradeWalletModel(model: UpgradeWalletModel): Model +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt new file mode 100644 index 0000000000..ff2ae172d9 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt @@ -0,0 +1,8 @@ +package com.tangem.features.hotwallet.upgradewallet.entity + +internal data class UpgradeWalletUM( + val onBackClick: () -> Unit, + val onBuyTangemWalletClick: () -> Unit, + val onScanDeviceClick: () -> Unit, + val isLoading: Boolean = false, +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt new file mode 100644 index 0000000000..2ae6ae5d0c --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt @@ -0,0 +1,161 @@ +package com.tangem.features.hotwallet.upgradewallet.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.hotwallet.upgradewallet.entity.UpgradeWalletUM + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun UpgradeWalletContent(state: UpgradeWalletUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background(TangemTheme.colors.background.primary) + .fillMaxSize() + .systemBarsPadding(), + ) { + TangemTopAppBar( + modifier = Modifier + .statusBarsPadding(), + startButton = TopAppBarButtonUM.Back(state.onBackClick), + title = TextReference.EMPTY, + ) + Column( + modifier = Modifier + .weight(1f) + .padding( + start = 16.dp, + top = 24.dp, + end = 16.dp, + ), + ) { + Icon( + modifier = Modifier + .fillMaxWidth(), + painter = painterResource(R.drawable.ic_tangem_64), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 20.dp, + end = 16.dp, + ), + text = stringResourceSafe(R.string.hw_upgrade_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 32.dp), + title = stringResourceSafe(R.string.hw_upgrade_key_migration_title), + description = stringResourceSafe(R.string.hw_upgrade_key_migration_description), + iconRes = R.drawable.ic_mobile_security_24, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 24.dp), + title = stringResourceSafe(R.string.hw_upgrade_funds_access_title), + description = stringResourceSafe(R.string.hw_upgrade_funds_access_description), + iconRes = R.drawable.ic_knight_shield_24, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 24.dp), + title = stringResourceSafe(R.string.hw_upgrade_general_security_title), + description = stringResourceSafe(R.string.hw_upgrade_general_security_description), + iconRes = R.drawable.ic_protect_24, + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SecondaryButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResourceSafe(R.string.details_buy_wallet), + onClick = state.onBuyTangemWalletClick, + ) + PrimaryButtonIconEnd( + modifier = Modifier + .fillMaxWidth(), + text = stringResourceSafe(R.string.hw_upgrade_scan_device), + onClick = state.onScanDeviceClick, + iconResId = R.drawable.ic_tangem_24, + ) + } + } +} + +@Composable +private fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + ) { + Icon( + modifier = Modifier + .padding(horizontal = 12.dp), + painter = painterResource(iconRes), + contentDescription = null, + tint = TangemTheme.colors.icon.primary1, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) { + Text( + text = title, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier + .padding(top = 4.dp), + text = description, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewUpgradeWalletContent() { + TangemThemePreview { + UpgradeWalletContent( + state = UpgradeWalletUM( + onBackClick = {}, + onBuyTangemWalletClick = {}, + onScanDeviceClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/DefaultViewPhraseComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/DefaultViewPhraseComponent.kt new file mode 100644 index 0000000000..d82fbd968e --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/DefaultViewPhraseComponent.kt @@ -0,0 +1,38 @@ +package com.tangem.features.hotwallet.viewphrase + +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.ViewPhraseComponent +import com.tangem.features.hotwallet.viewphrase.model.ViewPhraseModel +import com.tangem.features.hotwallet.viewphrase.ui.ViewPhraseContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultViewPhraseComponent @AssistedInject constructor( + @Assisted private val context: AppComponentContext, + @Assisted private val params: ViewPhraseComponent.Params, +) : ViewPhraseComponent, AppComponentContext by context { + private val model: ViewPhraseModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + ViewPhraseContent( + state = state, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : ViewPhraseComponent.Factory { + override fun create( + context: AppComponentContext, + params: ViewPhraseComponent.Params, + ): DefaultViewPhraseComponent + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/di/ViewPhraseModule.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/di/ViewPhraseModule.kt new file mode 100644 index 0000000000..e6b6d7d4ab --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/di/ViewPhraseModule.kt @@ -0,0 +1,25 @@ +package com.tangem.features.hotwallet.viewphrase.di + +import com.tangem.core.decompose.model.Model +import com.tangem.features.hotwallet.ViewPhraseComponent +import com.tangem.features.hotwallet.viewphrase.DefaultViewPhraseComponent +import com.tangem.features.hotwallet.viewphrase.model.ViewPhraseModel +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 ViewPhraseModule { + + @Binds + @IntoMap + @ClassKey(ViewPhraseModel::class) + fun bindViewPhraseModel(model: ViewPhraseModel): Model + + @Binds + fun bindViewPhraseComponentFactory(factory: DefaultViewPhraseComponent.Factory): ViewPhraseComponent.Factory +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/entity/ViewPhraseUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/entity/ViewPhraseUM.kt new file mode 100644 index 0000000000..072bed311d --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/entity/ViewPhraseUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.hotwallet.viewphrase.entity + +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal data class ViewPhraseUM( + val onBackClick: () -> Unit, + val words: ImmutableList = persistentListOf(), +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/model/ViewPhraseModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/model/ViewPhraseModel.kt new file mode 100644 index 0000000000..ede4241680 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/model/ViewPhraseModel.kt @@ -0,0 +1,72 @@ +package com.tangem.features.hotwallet.viewphrase.model + +import androidx.compose.runtime.Stable +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.navigation.Router +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.ClearHotWalletContextualUnlockUseCase +import com.tangem.domain.wallets.usecase.ExportSeedPhraseUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.hotwallet.ViewPhraseComponent +import com.tangem.features.hotwallet.viewphrase.entity.ViewPhraseUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class ViewPhraseModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val exportSeedPhraseUseCase: ExportSeedPhraseUseCase, + private val clearHotWalletContextualUnlockUseCase: ClearHotWalletContextualUnlockUseCase, +) : Model() { + + private val params = paramsContainer.require() + + internal val uiState: StateFlow + field = MutableStateFlow( + ViewPhraseUM( + onBackClick = { router.pop() }, + ), + ) + + init { + loadSeedPhrase() + } + + private fun loadSeedPhrase() { + val userWallet = getUserWalletUseCase(params.userWalletId) + .getOrElse { error("User wallet with id ${params.userWalletId} not found") } + if (userWallet is UserWallet.Hot) { + modelScope.launch { + val words = exportSeedPhraseUseCase.invoke(userWallet.hotWalletId) + .getOrElse { error("Unable to export seed phrase for wallet with id ${params.userWalletId}") } + .mnemonic + .mnemonicComponents + uiState.update { + it.copy( + words = words.mapIndexed { index, s -> + EnumeratedTwoColumnGridItem(index + 1, s) + }.toImmutableList(), + ) + } + } + } + } + + override fun onDestroy() { + clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId) + super.onDestroy() + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/ui/ViewPhraseContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/ui/ViewPhraseContent.kt new file mode 100644 index 0000000000..8768e09a55 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/ui/ViewPhraseContent.kt @@ -0,0 +1,103 @@ +package com.tangem.features.hotwallet.viewphrase.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.components.grid.EnumeratedTwoColumnGrid +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.hotwallet.impl.R +import com.tangem.features.hotwallet.viewphrase.entity.ViewPhraseUM +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun ViewPhraseContent(state: ViewPhraseUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background(TangemTheme.colors.background.primary) + .fillMaxSize() + .systemBarsPadding(), + ) { + AppBarWithBackButton( + text = stringResourceSafe(R.string.common_backup), + onBackClick = state.onBackClick, + ) + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .imePadding() + .weight(1f), + ) { + TitleBlock( + state = state, + modifier = Modifier.padding(top = 20.dp), + ) + + EnumeratedTwoColumnGrid( + items = state.words, + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp, bottom = 32.dp), + ) + } + } +} + +@Composable +private fun TitleBlock(state: ViewPhraseUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding(horizontal = TangemTheme.dimens.size36) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResourceSafe(R.string.backup_seed_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringResourceSafe( + R.string.backup_seed_caution, + state.words.size, + ), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Preview(showBackground = true, widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + ViewPhraseContent( + state = ViewPhraseUM( + onBackClick = {}, + words = List(12) { + EnumeratedTwoColumnGridItem( + index = it + 1, + mnemonic = "word${it + 1}", + ) + }.toImmutableList(), + ), + ) + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt index f163ba6e18..e5cd76ac2f 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt @@ -4,10 +4,16 @@ import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.push import com.arkivanov.decompose.router.stack.replaceAll +import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.settings.ShouldAskPermissionUseCase import com.tangem.features.hotwallet.manualbackup.check.ManualBackupCheckComponent @@ -32,6 +38,7 @@ internal class WalletActivationModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, + @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { val params = paramsContainer.require() @@ -66,9 +73,7 @@ internal class WalletActivationModel @Inject constructor( modelScope.launch { val shouldRequestPush = shouldAskPermissionUseCase(PUSH_PERMISSION) if (shouldRequestPush) { - // is yet blocked by [REDACTED_TASK_KEY] - // stackNavigation.replaceAll(AddExistingWalletRoute.PushNotifications) - stackNavigation.replaceAll(WalletActivationRoute.SetupFinished) + stackNavigation.replaceAll(WalletActivationRoute.PushNotifications) } else { stackNavigation.replaceAll(WalletActivationRoute.SetupFinished) } @@ -79,13 +84,31 @@ internal class WalletActivationModel @Inject constructor( stackNavigation.replaceAll(WalletActivationRoute.SetupFinished) } + private fun showSkipAccessCodeWarningDialog() { + uiMessageSender.send( + DialogMessage( + message = resourceReference(R.string.access_code_alert_skip_description), + title = resourceReference(R.string.access_code_alert_skip_title), + firstAction = EventMessageAction( + title = resourceReference(R.string.common_cancel), + onClick = {}, + ), + secondAction = EventMessageAction( + title = resourceReference(R.string.access_code_alert_skip_ok), + onClick = { navigateToPushNotificationsOrNext() }, + ), + dismissOnFirstAction = true, + ), + ) + } + inner class HotWalletStepperComponentModelCallback : HotWalletStepperComponent.ModelCallback { override fun onBackClick() { onChildBack() } override fun onSkipClick() { - navigateToPushNotificationsOrNext() + showSkipAccessCodeWarningDialog() } } @@ -116,11 +139,11 @@ internal class WalletActivationModel @Inject constructor( } inner class AccessCodeModelCallbacks : AccessCodeComponent.ModelCallbacks { - override fun onAccessCodeSet(userWalletId: UserWalletId, accessCode: String) { + override fun onNewAccessCodeInput(userWalletId: UserWalletId, accessCode: String) { stackNavigation.push(WalletActivationRoute.ConfirmAccessCode(accessCode)) } - override fun onAccessCodeConfirmed(userWalletId: UserWalletId) { + override fun onAccessCodeUpdated(userWalletId: UserWalletId) { navigateToPushNotificationsOrNext() } } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/routing/WalletActivationChildFactory.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/routing/WalletActivationChildFactory.kt index e6c13bdbd9..77684cecdb 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/routing/WalletActivationChildFactory.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/routing/WalletActivationChildFactory.kt @@ -55,7 +55,6 @@ internal class WalletActivationChildFactory @Inject constructor( is WalletActivationRoute.SetAccessCode -> accessCodeComponentFactory.create( context = childContext, params = AccessCodeComponent.Params( - isConfirmMode = false, userWalletId = model.params.userWalletId, callbacks = model.accessCodeModelCallbacks, ), @@ -63,7 +62,6 @@ internal class WalletActivationChildFactory @Inject constructor( is WalletActivationRoute.ConfirmAccessCode -> accessCodeComponentFactory.create( context = childContext, params = AccessCodeComponent.Params( - isConfirmMode = true, accessCodeToConfirm = route.accessCode, userWalletId = model.params.userWalletId, callbacks = model.accessCodeModelCallbacks, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt index f3f17cd9d3..457edf278a 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt @@ -4,10 +4,12 @@ import com.tangem.core.ui.components.label.entity.LabelUM internal data class WalletBackupUM( val onBackClick: () -> Unit, - val recoveryPhraseStatus: LabelUM?, - val googleDriveStatus: LabelUM?, + val recoveryPhraseOption: LabelUM?, + val googleDriveOption: LabelUM?, + val googleDriveStatus: BackupStatus, val onRecoveryPhraseClick: () -> Unit, val onGoogleDriveClick: () -> Unit, + val onHardwareWalletClick: () -> Unit, val backedUp: Boolean, ) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt index b398ed047a..b7357044d5 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt @@ -1,37 +1,33 @@ package com.tangem.features.hotwallet.walletbackup.model -import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router -import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.R -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 -import com.tangem.core.ui.components.bottomsheets.message.icon -import com.tangem.core.ui.components.bottomsheets.message.infoBlock -import com.tangem.core.ui.components.bottomsheets.message.onClick -import com.tangem.core.ui.components.bottomsheets.message.secondaryButton import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.message.bottomSheetMessage import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.common.routing.AppRoute +import com.tangem.domain.wallets.usecase.UnlockHotWalletContextualUseCase import com.tangem.features.hotwallet.WalletBackupComponent +import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus import com.tangem.features.hotwallet.walletbackup.entity.WalletBackupUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @ModelScoped internal class WalletBackupModel @Inject constructor( paramsContainer: ParamsContainer, - getWalletUseCase: GetUserWalletUseCase, - private val router: Router, + private val getWalletUseCase: GetUserWalletUseCase, + private val unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase, override val dispatchers: CoroutineDispatcherProvider, - @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, + private val router: Router, ) : Model() { private val params: WalletBackupComponent.Params = paramsContainer.require() @@ -40,48 +36,32 @@ internal class WalletBackupModel @Inject constructor( field = MutableStateFlow( WalletBackupUM( onBackClick = { router.pop() }, - recoveryPhraseStatus = LabelUM( + recoveryPhraseOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, ), - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.common_coming_soon), style = LabelStyle.REGULAR, ), + googleDriveStatus = BackupStatus.ComingSoon, onRecoveryPhraseClick = ::onRecoveryPhraseClick, onGoogleDriveClick = { }, + onHardwareWalletClick = ::onHardwareWalletClick, backedUp = false, ), ) - private val makeBackupAtFirstAlertBS - get() = bottomSheetMessage { - infoBlock { - icon(R.drawable.ic_passcode_lock_32) { - type = MessageBottomSheetUMV2.Icon.Type.Accent - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint - } - title = resourceReference(R.string.hw_backup_need_title) - body = resourceReference(R.string.hw_backup_need_description) - } - secondaryButton { - text = resourceReference(R.string.hw_backup_need_action) - onClick { - router.push(AppRoute.CreateWalletBackup(params.userWalletId)) - closeBs() - } - } - } - init { - getWalletUseCase.invokeFlow(params.userWalletId) - .map { it.getOrNull() } - .distinctUntilChanged() - .filterNotNull() - .onEach { - updateBackupStatuses(it) - } - .launchIn(modelScope) + getWalletUseCase.invoke(params.userWalletId) + .fold( + ifLeft = { + Timber.e("Error on getting user wallet: $it") + }, + ifRight = { + updateBackupStatuses(it) + }, + ) } private fun updateBackupStatuses(userWallet: UserWallet) { @@ -95,7 +75,7 @@ internal class WalletBackupModel @Inject constructor( } private fun WalletBackupUM.updateBackupStatusesHotWallet(userWallet: UserWallet.Hot): WalletBackupUM = copy( - recoveryPhraseStatus = if (userWallet.backedUp) { + recoveryPhraseOption = if (userWallet.backedUp) { LabelUM( text = resourceReference(R.string.common_done), style = LabelStyle.ACCENT, @@ -106,7 +86,7 @@ internal class WalletBackupModel @Inject constructor( style = LabelStyle.WARNING, ) }, - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.common_coming_soon), style = LabelStyle.REGULAR, ), @@ -115,9 +95,41 @@ internal class WalletBackupModel @Inject constructor( private fun onRecoveryPhraseClick() { if (uiState.value.backedUp) { - // TODO [REDACTED_TASK_KEY] + getWalletUseCase.invoke(params.userWalletId) + .fold( + ifLeft = { + Timber.e("Error on getting user wallet: $it") + }, + ifRight = { userWallet -> + when (userWallet) { + is UserWallet.Cold -> { + val userWalletId = userWallet.walletId + Timber.e("Unexpected cold wallet when request seed phrase: $userWalletId") + } + is UserWallet.Hot -> showSeedPhrase(userWallet) + } + }, + ) } else { - uiMessageSender.send(makeBackupAtFirstAlertBS) + router.push(AppRoute.CreateWalletBackup(params.userWalletId)) } } + + private fun showSeedPhrase(hotWallet: UserWallet.Hot) { + modelScope.launch { + unlockHotWalletContextualUseCase.invoke(hotWallet.hotWalletId) + .fold( + ifLeft = { + Timber.e("Error while export seed phrase: $it") + }, + ifRight = { seedPhrasePrivateInfo -> + router.push(AppRoute.ViewPhrase(params.userWalletId)) + }, + ) + } + } + + private fun onHardwareWalletClick() { + router.push(AppRoute.UpgradeWallet(params.userWalletId)) + } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt index f5d25b7280..aa68dc3e7d 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -25,6 +26,7 @@ import com.tangem.core.ui.R import com.tangem.core.ui.components.label.Label import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.components.rows.NetworkTitle import com.tangem.core.ui.extensions.resourceReference @OptIn(ExperimentalMaterial3Api::class) @@ -47,11 +49,12 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod .padding(horizontal = 16.dp), ) { OptionBlock( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .padding(top = 8.dp), title = stringResourceSafe(R.string.hw_backup_seed_title), description = stringResourceSafe(R.string.hw_backup_seed_description), badge = { - state.recoveryPhraseStatus?.let { Label(it) } + state.recoveryPhraseOption?.let { Label(it) } }, onClick = state.onRecoveryPhraseClick, enabled = true, @@ -59,16 +62,37 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod ) OptionBlock( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .padding(top = 8.dp), title = stringResourceSafe(R.string.hw_backup_google_drive_title), description = stringResourceSafe(R.string.hw_backup_google_drive_description), badge = { - state.googleDriveStatus?.let { Label(it) } + state.googleDriveOption?.let { Label(it) } }, onClick = state.onGoogleDriveClick, enabled = state.googleDriveStatus != BackupStatus.ComingSoon, backgroundColor = TangemTheme.colors.background.primary, ) + + NetworkTitle( + title = { + Text( + modifier = Modifier, + text = stringResourceSafe(R.string.express_provider_recommended), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + }, + ) + OptionBlock( + modifier = Modifier.fillMaxWidth(), + title = stringResourceSafe(R.string.hw_backup_hardware_title), + description = stringResourceSafe(R.string.hw_backup_hardware_description), + badge = null, + onClick = state.onHardwareWalletClick, + enabled = true, + backgroundColor = TangemTheme.colors.background.primary, + ) } } } @@ -85,45 +109,51 @@ private fun WalletBackupContentPreview(@PreviewParameter(WalletBackupUMProvider: private class WalletBackupUMProvider : CollectionPreviewParameterProvider( collection = listOf( WalletBackupUM( - recoveryPhraseStatus = LabelUM( + recoveryPhraseOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, ), - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.common_coming_soon), style = LabelStyle.REGULAR, ), + googleDriveStatus = BackupStatus.ComingSoon, onBackClick = {}, onRecoveryPhraseClick = {}, onGoogleDriveClick = {}, + onHardwareWalletClick = {}, backedUp = false, ), WalletBackupUM( - recoveryPhraseStatus = LabelUM( + recoveryPhraseOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, ), - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, ), + googleDriveStatus = BackupStatus.NoBackup, onBackClick = {}, onRecoveryPhraseClick = {}, onGoogleDriveClick = {}, + onHardwareWalletClick = {}, backedUp = false, ), WalletBackupUM( - recoveryPhraseStatus = LabelUM( + recoveryPhraseOption = LabelUM( text = resourceReference(R.string.common_done), style = LabelStyle.ACCENT, ), - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.common_done), style = LabelStyle.ACCENT, ), + googleDriveStatus = BackupStatus.Done, onBackClick = {}, onRecoveryPhraseClick = {}, onGoogleDriveClick = {}, + onHardwareWalletClick = {}, backedUp = false, ), ), 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 index de05f5dc62..6a18888000 100644 --- 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 @@ -4,14 +4,9 @@ import com.tangem.core.decompose.context.AppComponentContext interface KycComponent { - fun launch(params: Params) + fun launch() interface Factory { fun create(appComponentContext: AppComponentContext): KycComponent } - - data class Params( - val targetAddress: String, - val cardId: String, - ) } \ 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 index 92d39cee70..1be34aa554 100644 --- 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 @@ -18,7 +18,7 @@ class DefaultKycComponent @AssistedInject constructor( private val model: DefaultKycModel = getOrCreateModel() - override fun launch(params: KycComponent.Params) { + override fun launch() { componentScope.launch { model.uiState.collect { it?.let { startInfo -> @@ -29,13 +29,13 @@ class DefaultKycComponent @AssistedInject constructor( .withAccessToken(accessToken = startInfo.token, onTokenExpiration = tokenExpirationHandler) .withTheme(TangemSNSTheme.theme(activity)) .withIconHandler(TangemSNSIconHandler()) - .withLocale(Locale("en")) + .withLocale(Locale(startInfo.locale)) .build() snsSdk.launch() } } } - model.getKycToken(params) + model.getKycToken() } @AssistedFactory diff --git a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt index aff81ad334..21b8e8e9bb 100644 --- a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt +++ b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt @@ -15,18 +15,15 @@ import javax.inject.Inject @ModelScoped class DefaultKycModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, - kycRepositoryFactory: KycRepository.Factory, + private val kycRepository: KycRepository, ) : Model() { - private val kycRepository = kycRepositoryFactory.create() - private val _uiState: MutableStateFlow = MutableStateFlow(null) val uiState = _uiState.asStateFlow() - fun getKycToken(params: KycComponent.Params) { + fun getKycToken() { modelScope.launch { - kycRepository.getKycStartInfo(address = params.targetAddress, cardId = params.cardId).getOrNull() - ?.let { _uiState.emit(it) } + kycRepository.getKycStartInfo().getOrNull()?.let { _uiState.emit(it) } } } } \ No newline at end of file diff --git a/domain/notifications/toggles/.gitignore b/features/kyc/mock/.gitignore similarity index 100% rename from domain/notifications/toggles/.gitignore rename to features/kyc/mock/.gitignore diff --git a/features/kyc/mock/build.gradle.kts b/features/kyc/mock/build.gradle.kts new file mode 100644 index 0000000000..a64c1f8a89 --- /dev/null +++ b/features/kyc/mock/build.gradle.kts @@ -0,0 +1,22 @@ +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.kyc.impl" +} + +dependencies { + /** Api */ + implementation(projects.features.kyc.api) + + implementation(projects.core.decompose) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/MockKycComponent.kt b/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/MockKycComponent.kt new file mode 100644 index 0000000000..71bd1f9b5d --- /dev/null +++ b/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/MockKycComponent.kt @@ -0,0 +1,23 @@ +package com.tangem.features.kyc + +import com.tangem.core.decompose.context.AppComponentContext +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Mocking it for release/external builds to exclude SumSub dependency + */ +internal class MockKycComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, +) : KycComponent, AppComponentContext by appComponentContext { + + override fun launch() { + /* no op */ + } + + @AssistedFactory + interface Factory : KycComponent.Factory { + override fun create(appComponentContext: AppComponentContext): MockKycComponent + } +} \ No newline at end of file diff --git a/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/di/FeatureModule.kt b/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/di/FeatureModule.kt new file mode 100644 index 0000000000..94de8a68b8 --- /dev/null +++ b/features/kyc/mock/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.KycComponent +import com.tangem.features.kyc.MockKycComponent +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: MockKycComponent.Factory): KycComponent.Factory +} \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt index aad2b8562d..c45a8667f6 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt @@ -2,12 +2,11 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.domain.models.wallet.UserWalletId interface AddCustomTokenComponent : ComposableBottomSheetComponent { data class Params( - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, val source: ManageTokensSource, val onDismiss: () -> Unit, val onCurrencyAdded: () -> Unit, diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt index a02c7125ff..e680fabf26 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt @@ -7,9 +7,16 @@ import com.tangem.domain.models.wallet.UserWalletId interface ManageTokensComponent : ComposableContentComponent { data class Params( - val userWalletId: UserWalletId?, + val mode: ManageTokensMode, val source: ManageTokensSource, - ) + ) { + constructor(userWalletId: UserWalletId?, source: ManageTokensSource) : this( + source = source, + mode = userWalletId + ?.let { ManageTokensMode.Wallet(userWalletId) } + ?: ManageTokensMode.None, + ) + } interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt index c1d317c3d8..3c34dd0f34 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt @@ -1,8 +1,22 @@ package com.tangem.features.managetokens.component +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId + enum class ManageTokensSource(val analyticsName: String) { STORIES(analyticsName = "Stories"), ONBOARDING(analyticsName = "Onboarding"), SETTINGS(analyticsName = "Settings"), SEND_VIA_SWAP(analyticsName = "SendViaSwap"), +} + +sealed interface ManageTokensMode { + data class Wallet(val userWalletId: UserWalletId) : ManageTokensMode + data class Account(val accountId: AccountId) : ManageTokensMode + data object None : ManageTokensMode +} + +sealed interface AddCustomTokenMode { + data class Wallet(val userWalletId: UserWalletId) : AddCustomTokenMode + data class Account(val accountId: AccountId) : AddCustomTokenMode } \ No newline at end of file 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 3b425f1d64..c7b9296386 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 @@ -22,6 +22,7 @@ import com.tangem.features.managetokens.choosetoken.entity.ChooseManageTokensBot import com.tangem.features.managetokens.choosetoken.entity.ChooseManagedTokenUM import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.managetokens.component.ChooseManagedTokensComponent.Source +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.managetokens.component.analytics.CommonManageTokensAnalyticEvents import com.tangem.features.managetokens.entity.item.CurrencyItemUM @@ -30,6 +31,7 @@ import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.utils.list.ManageTokensListManager +import com.tangem.features.managetokens.utils.list.ManageTokensUseCasesFacade import com.tangem.features.managetokens.utils.list.getLoadingItems import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.PaginationStatus @@ -51,12 +53,19 @@ internal class ChooseManagedTokensModel @Inject constructor( private val setShouldShowNotificationUseCase: SetShouldShowNotificationUseCase, private val analyticsEventHandler: AnalyticsEventHandler, paramsContainer: ParamsContainer, + manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory, manageTokensListManagerFactory: ManageTokensListManager.Factory, ) : Model() { private val params: ChooseManagedTokensComponent.Params = paramsContainer.require() + private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory + .create(mode = ManageTokensMode.None) private val manageTokensListManager = manageTokensListManagerFactory.create( + scope = modelScope, + source = ManageTokensSource.SEND_VIA_SWAP, + mode = ManageTokensMode.None, + useCasesFacade = useCasesFacade, onCurrencySelect = { token -> bottomSheetNavigation.activate( ChooseManageTokensBottomSheetConfig.SwapTokensBottomSheetConfig( @@ -86,11 +95,7 @@ internal class ChooseManagedTokensModel @Inject constructor( observeSearchQueryChanges() modelScope.launch { - manageTokensListManager.launchPagination( - source = ManageTokensSource.SEND_VIA_SWAP, - userWalletId = params.userWalletId, - isCollapsed = false, - ) + manageTokensListManager.launchPagination(isCollapsed = false) } } @@ -163,7 +168,7 @@ internal class ChooseManagedTokensModel @Inject constructor( } } .sample(periodMillis = 1_000) - .onEach { query -> manageTokensListManager.search(userWalletId = params.userWalletId, query = query) } + .onEach { query -> manageTokensListManager.search(query = query) } .launchIn(modelScope) } @@ -310,7 +315,7 @@ internal class ChooseManagedTokensModel @Inject constructor( if (state.readContent.isInitialBatchLoading || state.readContent.isNextBatchLoading) return false modelScope.launch { - manageTokensListManager.loadMore(userWalletId = params.userWalletId, query = state.readContent.search.query) + manageTokensListManager.loadMore(query = state.readContent.search.query) } return true diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/ui/ChooseManagedTokenContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/ui/ChooseManagedTokenContent.kt index 632f901ca6..615347a07f 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/ui/ChooseManagedTokenContent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/ui/ChooseManagedTokenContent.kt @@ -7,7 +7,10 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -37,6 +40,8 @@ import kotlinx.collections.immutable.toPersistentList @Composable internal fun ChooseManagedTokenContent(state: ChooseManagedTokenUM, modifier: Modifier = Modifier) { + val focusRequester = remember { FocusRequester() } + Scaffold( modifier = modifier, containerColor = TangemTheme.colors.background.tertiary, @@ -46,6 +51,7 @@ internal fun ChooseManagedTokenContent(state: ChooseManagedTokenUM, modifier: Mo modifier = Modifier.statusBarsPadding(), topBar = state.readContent.topBar, search = state.readContent.search, + focusRequester = focusRequester, ) }, content = { innerPadding -> @@ -57,6 +63,10 @@ internal fun ChooseManagedTokenContent(state: ChooseManagedTokenUM, modifier: Mo ) }, ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } } @Composable diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt index 7ec0cff9c9..56f2b08ca9 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt @@ -2,13 +2,12 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableDialogComponent -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath internal interface CustomTokenDerivationInputComponent : ComposableDialogComponent { data class Params( - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, val onConfirm: (SelectedDerivationPath) -> Unit, val onDismiss: () -> Unit, ) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt index 04a9e59d48..2baf3f982f 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt @@ -2,7 +2,6 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormValues import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork @@ -10,7 +9,7 @@ import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork internal interface CustomTokenFormComponent : ComposableContentComponent { data class Params( - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, val network: SelectedNetwork, val derivationPath: SelectedDerivationPath?, val formValues: CustomTokenFormValues, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt index 6e36ef8eb7..a4ec53c9d9 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt @@ -2,7 +2,6 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork @@ -11,13 +10,13 @@ internal interface CustomTokenSelectorComponent : ComposableContentComponent { sealed class Params { data class NetworkSelector( - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, val selectedNetwork: SelectedNetwork?, val onNetworkSelected: (SelectedNetwork) -> Unit, ) : Params() data class DerivationPathSelector( - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, val selectedNetwork: SelectedNetwork, val selectedDerivationPath: SelectedDerivationPath?, val onDerivationPathSelected: (SelectedDerivationPath) -> Unit, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt index f4ecc9ceed..1951b3cd44 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt @@ -38,7 +38,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( ) : AddCustomTokenComponent, AppComponentContext by context { private val initialConfiguration = AddCustomTokenConfig( - userWalletId = params.userWalletId, + mode = params.mode, step = AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR, ) @@ -105,7 +105,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( selectorComponentFactory.create( context = childByContext(componentContext), params = CustomTokenSelectorComponent.Params.NetworkSelector( - userWalletId = config.userWalletId, + mode = config.mode, selectedNetwork = null, onNetworkSelected = ::changeSelectedNetwork, ), @@ -115,7 +115,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( selectorComponentFactory.create( context = childByContext(componentContext), params = CustomTokenSelectorComponent.Params.NetworkSelector( - userWalletId = config.userWalletId, + mode = config.mode, selectedNetwork = config.selectedNetwork, onNetworkSelected = ::changeSelectedNetwork, ), @@ -125,7 +125,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( selectorComponentFactory.create( context = childByContext(componentContext), params = CustomTokenSelectorComponent.Params.DerivationPathSelector( - userWalletId = config.userWalletId, + mode = config.mode, selectedNetwork = requireNotNull(config.selectedNetwork) { "Network is not selected" }, @@ -138,7 +138,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( formComponentFactory.create( context = childByContext(componentContext), params = CustomTokenFormComponent.Params( - userWalletId = config.userWalletId, + mode = config.mode, network = requireNotNull(config.selectedNetwork) { "Network is not selected" }, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt index edd8f52aa9..44c781e3cc 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt @@ -41,7 +41,7 @@ internal class DefaultCustomTokenSelectorComponent @AssistedInject constructor( is CustomTokenSelectorDialogConfig.CustomDerivationInput -> customTokenDerivationInputComponentFactory.create( context = childByContext(context), params = CustomTokenDerivationInputComponent.Params( - userWalletId = config.userWalletId, + mode = config.mode, onConfirm = model::selectCustomDerivationPath, onDismiss = model.dialogNavigation::dismiss, ), diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt index 76ae54a12d..9dc5539ae4 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt @@ -13,6 +13,7 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.features.managetokens.component.AddCustomTokenComponent +import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig import com.tangem.features.managetokens.model.ManageTokensModel @@ -52,18 +53,20 @@ internal class DefaultManageTokensComponent @AssistedInject constructor( private fun bottomSheetChild( config: ManageTokensBottomSheetConfig, componentContext: ComponentContext, - ): ComposableBottomSheetComponent = when (config) { - is ManageTokensBottomSheetConfig.AddCustomToken -> { - addCustomTokenComponentFactory.create( - context = childByContext(componentContext), - params = AddCustomTokenComponent.Params( - userWalletId = config.userWalletId, - source = params.source, - onDismiss = model.bottomSheetNavigation::dismiss, - onCurrencyAdded = model::reloadList, - ), - ) + ): ComposableBottomSheetComponent { + val mode = when (config) { + is ManageTokensBottomSheetConfig.AddWalletCustomToken -> AddCustomTokenMode.Wallet(config.userWalletId) + is ManageTokensBottomSheetConfig.AddAccountCustomToken -> AddCustomTokenMode.Account(config.accountId) } + return addCustomTokenComponentFactory.create( + context = childByContext(componentContext), + params = AddCustomTokenComponent.Params( + mode = mode, + source = params.source, + onDismiss = model.bottomSheetNavigation::dismiss, + onCurrencyAdded = model::reloadList, + ), + ) } @AssistedFactory diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt index eb693e20b1..239866349d 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.component.AddCustomTokenComponent +import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.CustomTokenSelectorComponent import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenConfig import com.tangem.features.managetokens.ui.AddCustomTokenBottomSheet @@ -14,7 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow internal class PreviewAddCustomTokenComponent( initialState: AddCustomTokenConfig = AddCustomTokenConfig( - userWalletId = UserWalletId(stringValue = "321"), + mode = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321")), step = AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR, ), ) : AddCustomTokenComponent { @@ -41,7 +42,7 @@ internal class PreviewAddCustomTokenComponent( AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR -> { PreviewCustomTokenSelectorComponent( params = CustomTokenSelectorComponent.Params.NetworkSelector( - userWalletId = config.userWalletId, + mode = config.mode, selectedNetwork = null, onNetworkSelected = {}, ), @@ -50,7 +51,7 @@ internal class PreviewAddCustomTokenComponent( AddCustomTokenConfig.Step.NETWORK_SELECTOR -> { PreviewCustomTokenSelectorComponent( params = CustomTokenSelectorComponent.Params.NetworkSelector( - userWalletId = config.userWalletId, + mode = config.mode, selectedNetwork = config.selectedNetwork, onNetworkSelected = {}, ), @@ -59,7 +60,7 @@ internal class PreviewAddCustomTokenComponent( AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR -> { PreviewCustomTokenSelectorComponent( params = CustomTokenSelectorComponent.Params.DerivationPathSelector( - userWalletId = config.userWalletId, + mode = config.mode, selectedNetwork = config.selectedNetwork!!, selectedDerivationPath = config.selectedDerivationPath!!, onDerivationPathSelected = {}, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt index eca3ff5cc5..9d4485dee8 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.CustomTokenSelectorComponent import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM @@ -18,7 +19,7 @@ import kotlinx.collections.immutable.toImmutableList internal class PreviewCustomTokenSelectorComponent( private val params: Params = Params.NetworkSelector( - userWalletId = UserWalletId(stringValue = "321"), + mode = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321")), selectedNetwork = null, onNetworkSelected = {}, ), diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt index d7caaf672c..9889edc028 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt @@ -13,6 +13,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.managetokens.model.ManagedCryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.entity.item.CurrencyItemUM import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM @@ -38,8 +39,10 @@ internal class PreviewManageTokensComponent( value = ManageTokensUM.ManageContent( popBack = {}, items = items, - topBar = if (params.userWalletId != null) { - ManageTokensTopBarUM.ManageContent( + topBar = when (params.mode) { + is ManageTokensMode.Account, + is ManageTokensMode.Wallet, + -> ManageTokensTopBarUM.ManageContent( title = resourceReference(id = R.string.main_manage_tokens), onBackButtonClick = {}, endButton = TopAppBarButtonUM.Icon( @@ -47,8 +50,7 @@ internal class PreviewManageTokensComponent( onClicked = {}, ), ) - } else { - ManageTokensTopBarUM.ReadContent( + ManageTokensMode.None -> ManageTokensTopBarUM.ReadContent( title = resourceReference(R.string.common_search_tokens), onBackButtonClick = {}, ) @@ -66,7 +68,7 @@ internal class PreviewManageTokensComponent( loadMore = { false }, saveChanges = {}, isSavingInProgress = false, - needToAddDerivations = showTangemIcon, + needToInteractWithColdWallet = showTangemIcon, ), ) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/AddCustomTokenConfig.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/AddCustomTokenConfig.kt index 767e87805c..e33d46321d 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/AddCustomTokenConfig.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/AddCustomTokenConfig.kt @@ -1,13 +1,13 @@ package com.tangem.features.managetokens.entity.customtoken import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.component.AddCustomTokenMode import kotlinx.serialization.Serializable @Serializable internal data class AddCustomTokenConfig( val step: Step, - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, val selectedNetwork: SelectedNetwork? = null, val selectedDerivationPath: SelectedDerivationPath? = null, val formValues: CustomTokenFormValues = CustomTokenFormValues(), diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt index 6f8206441a..0a766b28c5 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt @@ -1,5 +1,6 @@ package com.tangem.features.managetokens.entity.customtoken +import androidx.annotation.DrawableRes import androidx.compose.foundation.text.KeyboardOptions import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference @@ -15,7 +16,7 @@ internal data class CustomTokenFormUM( val notifications: PersistentList = persistentListOf(), val canAddToken: Boolean = false, val isValidating: Boolean = false, - val needToAddDerivation: Boolean = false, + @DrawableRes val walletInteractionIcon: Int? = null, val saveToken: () -> Unit, ) { diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt index 05a447f9d1..fc34e8cdda 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt @@ -1,6 +1,6 @@ package com.tangem.features.managetokens.entity.customtoken -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.component.AddCustomTokenMode import kotlinx.serialization.Serializable @Serializable @@ -8,6 +8,6 @@ internal sealed class CustomTokenSelectorDialogConfig { @Serializable data class CustomDerivationInput( - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, ) : CustomTokenSelectorDialogConfig() } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensBottomSheetConfig.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensBottomSheetConfig.kt index 9e2153b6e5..91a4b7fba3 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensBottomSheetConfig.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensBottomSheetConfig.kt @@ -1,5 +1,6 @@ package com.tangem.features.managetokens.entity.managetokens +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWalletId import kotlinx.serialization.Serializable @@ -7,7 +8,12 @@ import kotlinx.serialization.Serializable internal sealed class ManageTokensBottomSheetConfig { @Serializable - data class AddCustomToken( + data class AddWalletCustomToken( val userWalletId: UserWalletId, ) : ManageTokensBottomSheetConfig() + + @Serializable + data class AddAccountCustomToken( + val accountId: AccountId, + ) : ManageTokensBottomSheetConfig() } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensUM.kt index 708b149aca..56323dcd9a 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensUM.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensUM.kt @@ -42,7 +42,7 @@ internal sealed class ManageTokensUM { val saveChanges: () -> Unit, val hasChanges: Boolean, val isSavingInProgress: Boolean, - val needToAddDerivations: Boolean, + val needToInteractWithColdWallet: Boolean, ) : ManageTokensUM() fun copySealed( @@ -53,7 +53,7 @@ internal sealed class ManageTokensUM { isNextBatchLoading: Boolean = this.isNextBatchLoading, isSavingInProgress: Boolean = this is ManageContent && this.isSavingInProgress, scrollToTop: StateEvent = this.scrollToTop, - needToAddDerivations: Boolean = this is ManageContent && this.needToAddDerivations, + needToInteractWithColdWallet: Boolean = this is ManageContent && this.needToInteractWithColdWallet, ): ManageTokensUM { return when (this) { is ManageContent -> copy( @@ -64,7 +64,7 @@ internal sealed class ManageTokensUM { isNextBatchLoading = isNextBatchLoading, isSavingInProgress = isSavingInProgress, scrollToTop = scrollToTop, - needToAddDerivations = needToAddDerivations, + needToInteractWithColdWallet = needToInteractWithColdWallet, ) is ReadContent -> copy( search = search, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt index 08d5f86574..ae20c719d8 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt @@ -12,9 +12,6 @@ import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase -import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase -import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent import com.tangem.features.managetokens.component.CustomTokenFormComponent import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM @@ -25,6 +22,7 @@ import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.utils.CustomCurrencyFormBuilder import com.tangem.features.managetokens.utils.CustomCurrencyValidator +import com.tangem.features.managetokens.utils.list.CustomTokenFormUseCasesFacade import com.tangem.features.managetokens.utils.mapper.mapToDomainModel import com.tangem.features.managetokens.utils.ui.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -40,18 +38,17 @@ import javax.inject.Inject @ModelScoped internal class CustomTokenFormModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, - private val customCurrencyValidator: CustomCurrencyValidator, - private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, - private val derivePublicKeysUseCase: DerivePublicKeysUseCase, - private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase, private val messageSender: UiMessageSender, private val customTokenFormManager: CustomCurrencyFormBuilder, private val analyticsEventHandler: AnalyticsEventHandler, paramsContainer: ParamsContainer, + customTokenFormUseCasesFacadeFactory: CustomTokenFormUseCasesFacade.Factory, ) : Model() { private val params: CustomTokenFormComponent.Params = paramsContainer.require() private var createdCurrency: CryptoCurrency? = null + private var useCasesFacade: CustomTokenFormUseCasesFacade = customTokenFormUseCasesFacadeFactory.create(params.mode) + private val customCurrencyValidator = CustomCurrencyValidator(useCasesFacade) val state: MutableStateFlow = MutableStateFlow( value = getInitialState(), @@ -117,7 +114,6 @@ internal class CustomTokenFormModel @Inject constructor( .drop(count = 1) // Skip initial state .onEach { formValues -> customCurrencyValidator.validateForm( - userWalletId = params.userWalletId, networkId = params.network.id, derivationPath = getDerivationPath(), formValues = formValues, @@ -156,7 +152,6 @@ internal class CustomTokenFormModel @Inject constructor( private fun validatePrefilledForm() = modelScope.launch { customCurrencyValidator.validateForm( - userWalletId = params.userWalletId, networkId = params.network.id, derivationPath = getDerivationPath(), formValues = state.value.tokenForm.mapToDomainModel(), @@ -169,9 +164,8 @@ internal class CustomTokenFormModel @Inject constructor( isAlreadyAdded: Boolean, isCustom: Boolean, ) = modelScope.launch { - val needToAddDerivation = hasMissedDerivationsUseCase( - userWalletId = params.userWalletId, - networksWithDerivationPath = mapOf(currency.network.backendId to getDerivationPath().value), + val needColdWalletInteraction = useCasesFacade.needColdWalletInteraction( + network = mapOf(currency.network.backendId to getDerivationPath().value), ) state.update { state -> @@ -183,7 +177,7 @@ internal class CustomTokenFormModel @Inject constructor( clearNotifications = true, clearFieldErrors = true, disableSecondaryFields = !isCustom, - needToAddDerivation = needToAddDerivation, + walletInteractionIcon = R.drawable.ic_tangem_24.takeIf { needColdWalletInteraction }, ) if (fillForm) { @@ -343,13 +337,13 @@ internal class CustomTokenFormModel @Inject constructor( ) analyticsEventHandler.send(event) - derivePublicKeysUseCase(params.userWalletId, listOf(currency)).getOrElse { + useCasesFacade.derivePublicKeysUseCase(listOf(currency)).getOrElse { Timber.e(it, "Failed to derive public keys") showErrorDialog() return@resource } - addCryptoCurrenciesUseCase(params.userWalletId, currency).getOrElse { + useCasesFacade.addCryptoCurrenciesUseCase(currency).getOrElse { Timber.e(it, "Failed to add currency") showErrorDialog() return@resource diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt index a919429bd1..3bb119e580 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt @@ -11,7 +11,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.managetokens.GetSupportedNetworksUseCase import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.CustomTokenSelectorComponent import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params.DerivationPathSelector import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params.NetworkSelector @@ -83,7 +83,7 @@ internal class CustomTokenSelectorModel @Inject constructor( } private suspend fun loadNetworks(selector: NetworkSelector): List { - return getSupportedNetworks(selector.userWalletId).map { network -> + return getSupportedNetworks(selector.mode).map { network -> network.toCurrencyNetworkModel( isSelected = network.id == selector.selectedNetwork?.id, onSelectedStateChange = { @@ -122,7 +122,7 @@ internal class CustomTokenSelectorModel @Inject constructor( derivationPaths.add(defaultPath) } - getSupportedNetworks(selector.userWalletId) + getSupportedNetworks(selector.mode) .mapNotNullTo(derivationPaths) { network -> if (network.id == selector.selectedNetwork.id) { return@mapNotNullTo null // Skip default path @@ -146,8 +146,9 @@ internal class CustomTokenSelectorModel @Inject constructor( return derivationPaths } - private suspend fun getSupportedNetworks(userWalletId: UserWalletId): List { - return getSupportedNetworksUseCase(userWalletId).getOrElse { e -> + private suspend fun getSupportedNetworks(mode: AddCustomTokenMode): List = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> getSupportedNetworksUseCase(mode.userWalletId).getOrElse { e -> val message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)) messageSender.send(message) @@ -158,7 +159,7 @@ internal class CustomTokenSelectorModel @Inject constructor( private fun showCustomDerivationInput() { val config = when (params) { is NetworkSelector -> return - is DerivationPathSelector -> CustomTokenSelectorDialogConfig.CustomDerivationInput(params.userWalletId) + is DerivationPathSelector -> CustomTokenSelectorDialogConfig.CustomDerivationInput(params.mode) } dialogNavigation.activate(config) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt index 26dac22d14..c34a6ba32d 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt @@ -17,12 +17,10 @@ import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.domain.managetokens.SaveManagedTokensUseCase -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.entity.item.CurrencyItemUM import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM @@ -30,6 +28,7 @@ import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.utils.list.ChangedCurrencies import com.tangem.features.managetokens.utils.list.ManageTokensListManager +import com.tangem.features.managetokens.utils.list.ManageTokensUseCasesFacade import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.PaginationStatus import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -47,18 +46,24 @@ internal class ManageTokensModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val messageSender: UiMessageSender, - private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase, - private val saveManagedTokensUseCase: SaveManagedTokensUseCase, private val analyticsEventHandler: AnalyticsEventHandler, manageTokensListManagerFactory: ManageTokensListManager.Factory, + manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory, paramsContainer: ParamsContainer, ) : Model() { private val params: ManageTokensComponent.Params = paramsContainer.require() + private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory + .create(mode = params.mode) - private val manageTokensListManager = manageTokensListManagerFactory.create() + private val manageTokensListManager = manageTokensListManagerFactory.create( + scope = modelScope, + source = params.source, + mode = params.mode, + useCasesFacade = useCasesFacade, + ) - val state: MutableStateFlow = MutableStateFlow(getInitialState(params.userWalletId)) + val state: MutableStateFlow = MutableStateFlow(getInitialState()) val bottomSheetNavigation: SlotNavigation = SlotNavigation() init { @@ -79,27 +84,24 @@ internal class ManageTokensModel @Inject constructor( observeSearchQueryChanges() modelScope.launch { - manageTokensListManager.launchPagination( - source = params.source, - userWalletId = params.userWalletId, - isCollapsed = true, - ) + manageTokensListManager.launchPagination(isCollapsed = true) } } fun reloadList() { modelScope.launch { - manageTokensListManager.reload(params.userWalletId) + manageTokensListManager.reload() } } - private fun getInitialState(userWalletId: UserWalletId?): ManageTokensUM { + private fun getInitialState(): ManageTokensUM { analyticsEventHandler.send(ManageTokensAnalyticEvent.ScreenOpened(params.source)) - return if (userWalletId == null) { - createReadContentModel() - } else { - createManageContentModel() + return when (params.mode) { + is ManageTokensMode.Wallet, + is ManageTokensMode.Account, + -> createManageContentModel() + ManageTokensMode.None -> createReadContentModel() } } @@ -148,7 +150,7 @@ internal class ManageTokensModel @Inject constructor( hasChanges = false, saveChanges = ::saveChanges, loadMore = ::loadMoreItems, - needToAddDerivations = false, + needToInteractWithColdWallet = false, isSavingInProgress = false, ) } @@ -168,7 +170,7 @@ internal class ManageTokensModel @Inject constructor( } } .sample(periodMillis = 1_000) - .onEach { query -> manageTokensListManager.search(userWalletId = params.userWalletId, query = query) } + .onEach { query -> manageTokensListManager.search(query = query) } .launchIn(modelScope) } @@ -265,19 +267,16 @@ internal class ManageTokensModel @Inject constructor( private fun updateChangedItems(currenciesToAdd: ChangedCurrencies, currenciesToRemove: ChangedCurrencies) { modelScope.launch { - val hasMissedDerivations = params.userWalletId?.let { walletId -> - val networks = currenciesToAdd.values - .flatten() - .toSet() - .associate { it.backendId to null } - - hasMissedDerivationsUseCase(walletId, networks) - } + val networks = currenciesToAdd.values + .flatten() + .toSet() + .associate { it.backendId to null } + val needToInteractWithColdWallet = useCasesFacade.needColdWalletInteraction(networks) state.update { state -> state.copySealed( hasChanges = currenciesToAdd.isNotEmpty() || currenciesToRemove.isNotEmpty(), - needToAddDerivations = hasMissedDerivations ?: false, + needToInteractWithColdWallet = needToInteractWithColdWallet, ) } } @@ -288,7 +287,7 @@ internal class ManageTokensModel @Inject constructor( if (state.isInitialBatchLoading || state.isNextBatchLoading) return false modelScope.launch { - manageTokensListManager.loadMore(userWalletId = params.userWalletId, query = state.search.query) + manageTokensListManager.loadMore(query = state.search.query) } return true @@ -296,9 +295,14 @@ internal class ManageTokensModel @Inject constructor( private fun navigateToAddCustomToken() { analyticsEventHandler.send(CustomTokenAnalyticsEvent.ButtonCustomToken(params.source)) - - params.userWalletId?.let { - bottomSheetNavigation.activate(ManageTokensBottomSheetConfig.AddCustomToken(it)) + when (val portfolio = params.mode) { + is ManageTokensMode.Wallet -> + bottomSheetNavigation + .activate(ManageTokensBottomSheetConfig.AddWalletCustomToken(portfolio.userWalletId)) + is ManageTokensMode.Account -> + bottomSheetNavigation + .activate(ManageTokensBottomSheetConfig.AddAccountCustomToken(portfolio.accountId)) + ManageTokensMode.None -> Unit } } @@ -312,8 +316,7 @@ internal class ManageTokensModel @Inject constructor( ) analyticsEventHandler.send(event) - saveManagedTokensUseCase( - userWalletId = requireNotNull(params.userWalletId), + useCasesFacade.saveManagedTokensUseCase( currenciesToAdd = manageTokensListManager.currenciesToAdd.value, currenciesToRemove = manageTokensListManager.currenciesToRemove.value, ).getOrElse { diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt index 6a517b7a29..7d7026d93c 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt @@ -13,11 +13,10 @@ import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.domain.managetokens.SaveManagedTokensUseCase import com.tangem.domain.redux.OnboardingManageTokensAction import com.tangem.domain.redux.ReduxStateHolder -import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.managetokens.component.OnboardingManageTokensComponent import com.tangem.features.managetokens.entity.item.CurrencyItemUM @@ -25,6 +24,7 @@ import com.tangem.features.managetokens.entity.managetokens.OnboardingManageToke import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.utils.list.ChangedCurrencies import com.tangem.features.managetokens.utils.list.ManageTokensListManager +import com.tangem.features.managetokens.utils.list.ManageTokensUseCasesFacade import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.PaginationStatus import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -42,15 +42,22 @@ internal class OnboardingManageTokensModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val messageSender: UiMessageSender, private val reduxStateHolder: ReduxStateHolder, - private val saveManagedTokensUseCase: SaveManagedTokensUseCase, - private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase, private val analyticsEventHandler: AnalyticsEventHandler, manageTokensListManagerFactory: ManageTokensListManager.Factory, + manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory, paramsContainer: ParamsContainer, ) : Model() { private val params: OnboardingManageTokensComponent.Params = paramsContainer.require() - private val manageTokensListManager = manageTokensListManagerFactory.create() + private val portfolio = ManageTokensMode.Wallet(params.userWalletId) + private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory + .create(mode = portfolio) + private val manageTokensListManager = manageTokensListManagerFactory.create( + scope = modelScope, + source = ManageTokensSource.ONBOARDING, + useCasesFacade = useCasesFacade, + mode = portfolio, + ) val state: MutableStateFlow = MutableStateFlow(getInitialState()) val returnToParentComponentFlow = MutableSharedFlow() @@ -73,11 +80,7 @@ internal class OnboardingManageTokensModel @Inject constructor( observeSearchQueryChanges() modelScope.launch { - manageTokensListManager.launchPagination( - source = ManageTokensSource.ONBOARDING, - userWalletId = params.userWalletId, - isCollapsed = true, - ) + manageTokensListManager.launchPagination(isCollapsed = true) } } @@ -120,7 +123,7 @@ internal class OnboardingManageTokensModel @Inject constructor( } } .sample(periodMillis = 1_000) - .onEach { query -> manageTokensListManager.search(userWalletId = params.userWalletId, query = query) } + .onEach { query -> manageTokensListManager.search(query = query) } .launchIn(modelScope) } @@ -209,18 +212,16 @@ internal class OnboardingManageTokensModel @Inject constructor( ) } } else { - val hasMissedDerivations = hasMissedDerivationsUseCase.invoke( - userWalletId = params.userWalletId, - networksWithDerivationPath = currenciesToAdd.values - .flatten() - .toSet() - .associate { it.backendId to null }, - ) + val network = currenciesToAdd.values + .flatten() + .toSet() + .associate { it.backendId to null } + val showTangemIcon = useCasesFacade.needColdWalletInteraction(network = network) state.update { state -> state.copy( actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Continue( onClick = ::saveChanges, - showTangemIcon = hasMissedDerivations, + showTangemIcon = showTangemIcon, ), ) } @@ -232,7 +233,7 @@ internal class OnboardingManageTokensModel @Inject constructor( if (state.isInitialBatchLoading || state.isNextBatchLoading) return false modelScope.launch { - manageTokensListManager.loadMore(userWalletId = params.userWalletId, query = state.search.query) + manageTokensListManager.loadMore(query = state.search.query) } return true @@ -256,8 +257,7 @@ internal class OnboardingManageTokensModel @Inject constructor( ) analyticsEventHandler.send(event) - saveManagedTokensUseCase( - userWalletId = requireNotNull(params.userWalletId), + useCasesFacade.saveManagedTokensUseCase( currenciesToAdd = manageTokensListManager.currenciesToAdd.value, currenciesToRemove = manageTokensListManager.currenciesToRemove.value, ).getOrElse { @@ -282,8 +282,7 @@ internal class OnboardingManageTokensModel @Inject constructor( ) { analyticsEventHandler.send(ManageTokensAnalyticEvent.ButtonLater) - saveManagedTokensUseCase( - userWalletId = requireNotNull(params.userWalletId), + useCasesFacade.saveManagedTokensUseCase( currenciesToAdd = manageTokensListManager.currenciesToAdd.value, currenciesToRemove = manageTokensListManager.currenciesToRemove.value, ).getOrElse { diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt index 8cd0a27d68..4699454540 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt @@ -19,6 +19,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.component.AddCustomTokenComponent +import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.preview.PreviewAddCustomTokenComponent import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenConfig import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenUM @@ -68,12 +69,13 @@ private fun Preview_AddCustomTokenBottomSheet( } private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider { + private val mode: AddCustomTokenMode get() = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321")) override val values: Sequence get() = sequenceOf( PreviewAddCustomTokenComponent(), PreviewAddCustomTokenComponent( initialState = AddCustomTokenConfig( - userWalletId = UserWalletId(stringValue = "321"), + mode = mode, step = AddCustomTokenConfig.Step.FORM, selectedNetwork = SelectedNetwork( id = Network.ID(value = "1", derivationPath = Network.DerivationPath.None), @@ -85,7 +87,7 @@ private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider< ), PreviewAddCustomTokenComponent( initialState = AddCustomTokenConfig( - userWalletId = UserWalletId(stringValue = "321"), + mode = mode, step = AddCustomTokenConfig.Step.NETWORK_SELECTOR, selectedNetwork = SelectedNetwork( id = Network.ID(value = "0", derivationPath = Network.DerivationPath.None), @@ -97,7 +99,7 @@ private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider< ), PreviewAddCustomTokenComponent( initialState = AddCustomTokenConfig( - userWalletId = UserWalletId(stringValue = "321"), + mode = mode, step = AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR, selectedDerivationPath = SelectedDerivationPath( id = Network.ID(value = "0", derivationPath = Network.DerivationPath.None), diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt index 02cbe0e3bb..65951a6c05 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt @@ -94,8 +94,8 @@ internal fun CustomTokenFormContent(model: CustomTokenFormUM, modifier: Modifier enabled = model.canAddToken, showProgress = model.isValidating, animateContentChange = true, - icon = if (model.needToAddDerivation) { - TangemButtonIconPosition.End(R.drawable.ic_tangem_24) + icon = if (model.walletInteractionIcon != null) { + TangemButtonIconPosition.End(model.walletInteractionIcon) } else { TangemButtonIconPosition.None }, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt index 6320fd6743..b7eae1ce2c 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt @@ -32,6 +32,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.CustomTokenSelectorComponent import com.tangem.features.managetokens.component.preview.PreviewCustomTokenSelectorComponent import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM @@ -268,12 +269,13 @@ private class CustomTokenNetworkSelectorComponentPreviewProvider : PreviewParameterProvider { private val derivationPath = Network.DerivationPath.Card("m/44'/0'/0'/0/0") + private val mode: AddCustomTokenMode get() = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321")) override val values: Sequence get() = sequenceOf( PreviewCustomTokenSelectorComponent( params = CustomTokenSelectorComponent.Params.DerivationPathSelector( - userWalletId = UserWalletId(stringValue = "321"), + mode = mode, selectedNetwork = SelectedNetwork( id = Network.ID(value = "0", derivationPath = derivationPath), name = "Ethereum", @@ -291,7 +293,7 @@ private class CustomTokenNetworkSelectorComponentPreviewProvider : ), PreviewCustomTokenSelectorComponent( params = CustomTokenSelectorComponent.Params.NetworkSelector( - userWalletId = UserWalletId(stringValue = "321"), + mode = mode, selectedNetwork = SelectedNetwork( id = Network.ID(value = "0", derivationPath = derivationPath), name = "Ethereum", diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt index 07efb73d81..6c1d6a16cc 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt @@ -104,7 +104,7 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi .fillMaxWidth(), isVisible = state.hasChanges, showProgress = state.isSavingInProgress, - showIcon = state.needToAddDerivations, + showIcon = state.needToInteractWithColdWallet, onClick = state.saveChanges, ) } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensTopBar.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensTopBar.kt index 2b7d67794c..7ea91f6583 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensTopBar.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensTopBar.kt @@ -3,7 +3,9 @@ package com.tangem.features.managetokens.ui import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.fields.SearchBar @@ -14,7 +16,12 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM @Composable -internal fun ManageTokensTopBar(topBar: ManageTokensTopBarUM?, search: SearchBarUM, modifier: Modifier = Modifier) { +internal fun ManageTokensTopBar( + topBar: ManageTokensTopBarUM?, + search: SearchBarUM, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { Column( modifier = modifier, ) { @@ -31,6 +38,7 @@ internal fun ManageTokensTopBar(topBar: ManageTokensTopBarUM?, search: SearchBar SearchBar( colors = TangemSearchBarDefaults.secondaryTextFieldColors, state = search, + focusRequester = focusRequester, modifier = Modifier .padding(bottom = TangemTheme.dimens.spacing12) .padding(horizontal = TangemTheme.dimens.spacing16), 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 bed3b20286..feabc9cb4d 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt @@ -1,31 +1,21 @@ 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.CreateCryptoCurrencyUseCase -import com.tangem.domain.managetokens.FindTokenUseCase -import com.tangem.domain.managetokens.ValidateTokenFormUseCase import com.tangem.domain.managetokens.model.AddCustomTokenForm import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException import com.tangem.domain.managetokens.model.exceptoin.FindTokenException import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.utils.list.CustomTokenFormUseCasesFacade import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveInAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber -import javax.inject.Inject -@ModelScoped -internal class CustomCurrencyValidator @Inject constructor( - private val validateTokenFormUseCase: ValidateTokenFormUseCase, - private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase, - private val findTokenUseCase: FindTokenUseCase, - private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase, +internal class CustomCurrencyValidator( + private val useCasesFacade: CustomTokenFormUseCasesFacade, ) { private val validateFormJobHolder = JobHolder() @@ -45,14 +35,13 @@ internal class CustomCurrencyValidator @Inject constructor( } suspend fun validateForm( - userWalletId: UserWalletId, networkId: Network.ID, derivationPath: Network.DerivationPath, formValues: AddCustomTokenForm.Raw, ) = coroutineScope { updateStatus(Status.Validating) - val result = validateTokenFormUseCase( + val result = useCasesFacade.validateTokenFormUseCase( networkId = networkId, formValues = formValues, ) @@ -73,20 +62,19 @@ internal class CustomCurrencyValidator @Inject constructor( launch { when (validatedForm) { is AddCustomTokenForm.Validated.All -> { - findOrCreateCurrency(userWalletId, networkId, derivationPath, validatedForm) + findOrCreateCurrency(networkId, derivationPath, validatedForm) } is AddCustomTokenForm.Validated.ContractAddressOnly -> { - findToken(userWalletId, networkId, derivationPath, validatedForm) + findToken(networkId, derivationPath, validatedForm) } is AddCustomTokenForm.Validated.Empty -> { - createCurrency(userWalletId, networkId, derivationPath, validatedForm = null) + createCurrency(networkId, derivationPath, validatedForm = null) } } }.saveInAndJoin(validateFormJobHolder) } private suspend fun findOrCreateCurrency( - userWalletId: UserWalletId, networkId: Network.ID, derivationPath: Network.DerivationPath, validatedForm: AddCustomTokenForm.Validated.All, @@ -96,14 +84,13 @@ internal class CustomCurrencyValidator @Inject constructor( currentState.prevFoundOrCreatedCurrency.contractAddress == validatedForm.contractAddress ) { // No need to search for token again if contract address is not changed - createCurrency(userWalletId, networkId, derivationPath, validatedForm) + createCurrency(networkId, derivationPath, validatedForm) return } updateStatus(Status.SearchingToken) - val foundToken = findTokenUseCase( - userWalletId = userWalletId, + val foundToken = useCasesFacade.findTokenUseCase( contractAddress = validatedForm.contractAddress, networkId = networkId, derivationPath = derivationPath, @@ -121,22 +108,20 @@ internal class CustomCurrencyValidator @Inject constructor( } if (foundToken != null) { - updateStateToValidated(userWalletId, foundToken, fillForm = true, isCustom = false) + updateStateToValidated(foundToken, fillForm = true, isCustom = false) } else { - createCurrency(userWalletId, networkId, derivationPath, validatedForm) + createCurrency(networkId, derivationPath, validatedForm) } } private suspend fun findToken( - userWalletId: UserWalletId, networkId: Network.ID, derivationPath: Network.DerivationPath, validatedForm: AddCustomTokenForm.Validated.ContractAddressOnly, ) { updateStatus(Status.SearchingToken) - val token = findTokenUseCase( - userWalletId = userWalletId, + val token = useCasesFacade.findTokenUseCase( contractAddress = validatedForm.contractAddress, networkId = networkId, derivationPath = derivationPath, @@ -155,17 +140,15 @@ internal class CustomCurrencyValidator @Inject constructor( return } - updateStateToValidated(userWalletId, token, fillForm = true, isCustom = false) + updateStateToValidated(token, fillForm = true, isCustom = false) } private suspend fun createCurrency( - userWalletId: UserWalletId, networkId: Network.ID, derivationPath: Network.DerivationPath, validatedForm: AddCustomTokenForm.Validated.All?, ) { - val currency = createCryptoCurrencyUseCase( - userWalletId = userWalletId, + val currency = useCasesFacade.createCryptoCurrencyUseCase( networkId = networkId, derivationPath = derivationPath, formValues = validatedForm, @@ -175,20 +158,14 @@ internal class CustomCurrencyValidator @Inject constructor( return } - updateStateToValidated(userWalletId, currency, fillForm = false, isCustom = validatedForm != null) + updateStateToValidated(currency, fillForm = false, isCustom = validatedForm != null) } - private suspend fun updateStateToValidated( - userWalletId: UserWalletId, - currency: CryptoCurrency, - fillForm: Boolean, - isCustom: Boolean, - ) { + private suspend fun updateStateToValidated(currency: CryptoCurrency, fillForm: Boolean, isCustom: Boolean) { val currentStatus = state.value.status if (currentStatus is Status.Validated && currentStatus.currency == currency) return - val isNotAdded = checkIsCurrencyNotAddedUseCase( - userWalletId = userWalletId, + val isNotAdded = useCasesFacade.checkIsCurrencyNotAddedUseCase( networkId = currency.network.id, derivationPath = currency.network.derivationPath, contractAddress = when (currency) { diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt new file mode 100644 index 0000000000..a2ade41660 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt @@ -0,0 +1,115 @@ +package com.tangem.features.managetokens.utils.list + +import arrow.core.Either +import arrow.core.NonEmptyList +import com.tangem.domain.managetokens.CheckIsCurrencyNotAddedUseCase +import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase +import com.tangem.domain.managetokens.FindTokenUseCase +import com.tangem.domain.managetokens.ValidateTokenFormUseCase +import com.tangem.domain.managetokens.model.AddCustomTokenForm +import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException +import com.tangem.domain.managetokens.model.exceptoin.FindTokenException +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase +import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase +import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase +import com.tangem.features.managetokens.component.AddCustomTokenMode +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("LongParameterList") +internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( + private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, + private val derivePublicKeysUseCase: DerivePublicKeysUseCase, + private val validateTokenFormUseCase: ValidateTokenFormUseCase, + private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase, + private val findTokenUseCase: FindTokenUseCase, + private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase, + private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, + @Assisted private val mode: AddCustomTokenMode, +) { + + suspend fun needColdWalletInteraction(network: Map): Boolean = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> coldWalletAndHasMissedDerivationsUseCase.invoke( + userWalletId = mode.userWalletId, + networksWithDerivationPath = network, + ) + } + + suspend fun addCryptoCurrenciesUseCase(currency: CryptoCurrency): Either = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> addCryptoCurrenciesUseCase.invoke( + userWalletId = mode.userWalletId, + currency = currency, + ) + } + + suspend fun derivePublicKeysUseCase(currencies: List): Either = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> derivePublicKeysUseCase.invoke( + userWalletId = mode.userWalletId, + currencies = currencies, + ) + } + + suspend fun checkIsCurrencyNotAddedUseCase( + networkId: Network.ID, + derivationPath: Network.DerivationPath, + contractAddress: String?, + ): Either = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> checkIsCurrencyNotAddedUseCase.invoke( + userWalletId = mode.userWalletId, + networkId = networkId, + derivationPath = derivationPath, + contractAddress = contractAddress, + ) + } + + suspend fun createCryptoCurrencyUseCase( + networkId: Network.ID, + derivationPath: Network.DerivationPath, + formValues: AddCustomTokenForm.Validated.All?, + ): Either = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> createCryptoCurrencyUseCase.invoke( + userWalletId = mode.userWalletId, + networkId = networkId, + derivationPath = derivationPath, + formValues = formValues, + ) + } + + suspend fun findTokenUseCase( + contractAddress: String, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + ): Either = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> findTokenUseCase.invoke( + userWalletId = mode.userWalletId, + contractAddress = contractAddress, + networkId = networkId, + derivationPath = derivationPath, + ) + } + + suspend fun validateTokenFormUseCase( + networkId: Network.ID, + formValues: AddCustomTokenForm.Raw, + ): Either, AddCustomTokenForm.Validated> = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> validateTokenFormUseCase.invoke( + networkId = networkId, + formValues = formValues, + ) + } + + @AssistedFactory + interface Factory { + fun create(mode: AddCustomTokenMode): CustomTokenFormUseCasesFacade + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt index 3832809727..23a63a6edd 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt @@ -9,13 +9,14 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage -import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.domain.managetokens.* -import com.tangem.domain.managetokens.model.* +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState +import com.tangem.domain.managetokens.model.ManageTokensListBatchingContext +import com.tangem.domain.managetokens.model.ManageTokensUpdateAction +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.managetokens.entity.item.CurrencyItemUM import com.tangem.features.managetokens.impl.R @@ -24,7 +25,6 @@ import com.tangem.pagination.Batch import com.tangem.pagination.BatchAction import com.tangem.pagination.BatchListState import com.tangem.pagination.PaginationStatus -import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -42,37 +42,36 @@ import timber.log.Timber @Suppress("LongParameterList", "LargeClass") internal class ManageTokensListManager @AssistedInject constructor( - private val getManagedTokensUseCase: GetManagedTokensUseCase, - private val getDistinctManagedTokensUseCase: GetDistinctManagedCurrenciesUseCase, - private val checkHasLinkedTokensUseCase: CheckHasLinkedTokensUseCase, - private val removeCustomCurrencyUseCase: RemoveCustomManagedCryptoCurrencyUseCase, - private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, private val messageSender: UiMessageSender, private val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, private val clipboardManager: ClipboardManager, + manageTokensWarningDelegateFactory: ManageTokensWarningDelegate.Factory, + @Assisted private val useCasesFacade: ManageTokensUseCasesFacade, + @Assisted private val source: ManageTokensSource, + @Assisted private val mode: ManageTokensMode, + @Assisted private val scope: CoroutineScope, @Assisted private val onCurrencySelect: (ManagedCryptoCurrency.Token) -> Unit = {}, ) : ManageTokensUiActions { - private lateinit var scope: CoroutineScope - private lateinit var source: ManageTokensSource - private val jobHolder = JobHolder() private val actionsFlow: MutableSharedFlow = MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) - private val state: MutableStateFlow = MutableStateFlow(ManageTokensListState()) + private val state: MutableStateFlow = + MutableStateFlow(ManageTokensListState(mode = mode)) + private val manageTokensWarningDelegate: ManageTokensWarningDelegate = manageTokensWarningDelegateFactory + .create(mode, source, this) private val changedCurrenciesManager = ChangedCurrenciesManager() private val uiManager = ManageTokensUiManager( state = state, - messageSender = messageSender, + manageTokensWarningDelegate = manageTokensWarningDelegate, dispatchers = dispatchers, actions = this, - scopeProvider = Provider { scope }, - sourceProvider = Provider { source }, + scope = scope, ) val currenciesToAdd: StateFlow = changedCurrenciesManager.currenciesToAdd.asStateFlow() @@ -84,71 +83,61 @@ internal class ManageTokensListManager @AssistedInject constructor( .distinctUntilChanged() val uiItems: Flow> = uiManager.items - /** - * Launch pagination flow to get currencies - * - * @param source screen where manage tokens is used - * @param userWalletId selected user wallet to manage - * @param isCollapsed set initial display state of networks. !!! WARNING !!! Use `false` flag with cation - */ - suspend fun launchPagination(source: ManageTokensSource, userWalletId: UserWalletId?, isCollapsed: Boolean) = - coroutineScope { - scope = this - this@ManageTokensListManager.source = source - - val batchFlow = getManagedTokensUseCase( - context = ManageTokensListBatchingContext( - actionsFlow = actionsFlow, - coroutineScope = this, - ), - // only for onboarding case, change carefully and check repository implementation - loadUserTokensFromRemote = userWalletId != null && source == ManageTokensSource.ONBOARDING, - ) - - batchFlow.state - .onEach { state -> updateState(state, userWalletId, isCollapsed) } - .flowOn(dispatchers.default) - .launchIn(scope = this) - .saveIn(jobHolder) - - // Initial load - reload(userWalletId) + suspend fun launchPagination(isCollapsed: Boolean) = coroutineScope { + val loadUserTokensFromRemote = when (mode) { + is ManageTokensMode.Wallet -> source == ManageTokensSource.ONBOARDING + is ManageTokensMode.Account, + ManageTokensMode.None, + -> false } + val batchFlow = useCasesFacade.getManagedTokensUseCase( + context = ManageTokensListBatchingContext( + actionsFlow = actionsFlow, + coroutineScope = this, + ), + // only for onboarding case, change carefully and check repository implementation + loadUserTokensFromRemote = loadUserTokensFromRemote, + ) - suspend fun reload(userWalletId: UserWalletId?) { - state.value = ManageTokensListState() + batchFlow.state + .onEach { state -> updateState(state, isCollapsed) } + .flowOn(dispatchers.default) + .launchIn(scope = this) + .saveIn(jobHolder) + + // Initial load + reload() + } + + suspend fun reload() { + state.value = ManageTokensListState(mode = mode) actionsFlow.emit( BatchAction.Reload( - requestParams = ManageTokensListConfig(userWalletId, searchText = null), + requestParams = useCasesFacade.manageTokensListConfig(searchText = null), ), ) } - suspend fun loadMore(userWalletId: UserWalletId?, query: String) { + suspend fun loadMore(query: String) { actionsFlow.emit( BatchAction.LoadMore( - requestParams = ManageTokensListConfig(userWalletId, query), + requestParams = useCasesFacade.manageTokensListConfig(query), ), ) } - suspend fun search(userWalletId: UserWalletId?, query: String) { - state.value = ManageTokensListState(searchQuery = query) + suspend fun search(query: String) { + state.value = ManageTokensListState(mode = mode, searchQuery = query) actionsFlow.emit( BatchAction.Reload( - requestParams = ManageTokensListConfig( - userWalletId = userWalletId, + requestParams = useCasesFacade.manageTokensListConfig( searchText = query, ), ), ) } - private fun updateState( - batchListState: BatchListState>, - userWalletId: UserWalletId?, - isCollapsed: Boolean, - ) { + private fun updateState(batchListState: BatchListState>, isCollapsed: Boolean) { state.update { state -> state.copy( status = batchListState.status, @@ -163,7 +152,6 @@ internal class ManageTokensListManager @AssistedInject constructor( ) { state.update { state -> state.copy( - userWalletId = userWalletId, currencyBatches = emptyList(), uiBatches = listOf( Batch( @@ -179,7 +167,7 @@ internal class ManageTokensListManager @AssistedInject constructor( scope.launch { state.update { state -> - val newBatches = getDistinctManagedTokensUseCase(batchListState.data) + val newBatches = useCasesFacade.getDistinctManagedTokensUseCase(batchListState.data) val currentBatches = state.currencyBatches // Distinct until changed @@ -190,9 +178,13 @@ internal class ManageTokensListManager @AssistedInject constructor( return@launch } - val canEditItems = userWalletId != null + val canEditItems = when (state.mode) { + is ManageTokensMode.Account, + is ManageTokensMode.Wallet, + -> true + ManageTokensMode.None -> false + } state.copy( - userWalletId = userWalletId, currencyBatches = newBatches, uiBatches = uiManager.createOrUpdateUiBatches(newBatches, canEditItems, isCollapsed), canEditItems = canEditItems, @@ -225,10 +217,10 @@ internal class ManageTokensListManager @AssistedInject constructor( sendSelectCurrencyAnalyticsEvent(currency, isSelected = false) } - override fun removeCustomCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom) { + override fun removeCustomCurrency(currency: ManagedCryptoCurrency.Custom) { scope.launch { - removeCustomCurrencyUseCase.invoke(userWalletId, currency) - .onRight { reload(userWalletId) } + useCasesFacade.removeCustomCurrencyUseCase(currency) + .onRight { reload() } .onLeft { Timber.e(it) } } } @@ -267,9 +259,8 @@ internal class ManageTokensListManager @AssistedInject constructor( actionsFlow.tryEmit(action) } - override suspend fun checkHasLinkedTokens(userWalletId: UserWalletId, network: Network): Boolean { - return checkHasLinkedTokensUseCase( - userWalletId = userWalletId, + override suspend fun checkHasLinkedTokens(network: Network): Boolean { + return useCasesFacade.checkHasLinkedTokensUseCase( network = network, tempAddedTokens = changedCurrenciesManager.currenciesToAdd.value, tempRemovedTokens = changedCurrenciesManager.currenciesToRemove.value, @@ -278,7 +269,7 @@ internal class ManageTokensListManager @AssistedInject constructor( it, """ Failed to check linked tokens - |- User wallet ID: $userWalletId + |- Mode: $mode |- Network ID: ${network.id} """.trimIndent(), ) @@ -295,18 +286,16 @@ internal class ManageTokensListManager @AssistedInject constructor( } override suspend fun checkCurrencyUnsupportedState( - userWalletId: UserWalletId, sourceNetwork: ManagedCryptoCurrency.SourceNetwork, ): CurrencyUnsupportedState? { - return checkCurrencyUnsupportedUseCase( - userWalletId = userWalletId, + return useCasesFacade.checkCurrencyUnsupportedUseCase( sourceNetwork = sourceNetwork, ).getOrElse { Timber.e( it, """ Failed to check currency unsupported state - |- User wallet ID: $userWalletId + |- Mode: $mode |- Source Network: $sourceNetwork """.trimIndent(), ) @@ -368,8 +357,7 @@ internal class ManageTokensListManager @AssistedInject constructor( if (currency !is ManagedCryptoCurrency.Token) return@launch if (isSelected) { - val userWalletId = state.value.userWalletId - val unsupportedState = userWalletId?.let { checkCurrencyUnsupportedState(it, source) } + val unsupportedState = checkCurrencyUnsupportedState(source) if (unsupportedState != null) { showUnsupportedWarning(unsupportedState) } else { @@ -377,7 +365,7 @@ internal class ManageTokensListManager @AssistedInject constructor( } } else { if (checkNeedToShowRemoveNetworkWarning(currency, source.network)) { - showRemoveNetworkWarning( + manageTokensWarningDelegate.showRemoveNetworkWarning( currency = currency, network = source.network, isCoin = source is ManagedCryptoCurrency.SourceNetwork.Main, @@ -413,67 +401,6 @@ internal class ManageTokensListManager @AssistedInject constructor( messageSender.send(message) } - private suspend fun showRemoveNetworkWarning( - currency: ManagedCryptoCurrency, - network: Network, - isCoin: Boolean, - onConfirm: () -> Unit, - ) { - val userWalletId = state.value.userWalletId - val hasLinkedTokens = if (userWalletId == null || !isCoin) { - false - } else { - checkHasLinkedTokens(userWalletId, network) - } - val canHideWithoutConfirming = source == ManageTokensSource.ONBOARDING - - if (hasLinkedTokens) { - showLinkedTokensWarning(currency, network) - } else if (canHideWithoutConfirming) { - onConfirm() - } else { - showHideTokenWarning(currency, onConfirm) - } - } - - private fun showLinkedTokensWarning(currency: ManagedCryptoCurrency, network: Network) { - val message = DialogMessage( - title = resourceReference( - id = R.string.token_details_unable_hide_alert_title, - formatArgs = wrappedList(currency.name), - ), - message = resourceReference( - id = R.string.token_details_unable_hide_alert_message, - formatArgs = wrappedList( - currency.name, - currency.symbol, - network.name, - ), - ), - ) - messageSender.send(message) - } - - private fun showHideTokenWarning(currency: ManagedCryptoCurrency, onConfirm: () -> Unit) { - val message = DialogMessage( - title = resourceReference( - id = R.string.token_details_hide_alert_title, - formatArgs = wrappedList(currency.name), - ), - message = resourceReference(R.string.token_details_hide_alert_message), - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.token_details_hide_alert_hide), - warning = true, - onClick = onConfirm, - ) - }, - secondActionBuilder = { cancelAction() }, - ) - - messageSender.send(message) - } - private fun Batch>.currencyIndexById(id: ManagedCryptoCurrency.ID): Int { return data .indexOfFirst { it.id == id } @@ -483,6 +410,12 @@ internal class ManageTokensListManager @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(onCurrencySelect: (ManagedCryptoCurrency.Token) -> Unit = {}): ManageTokensListManager + fun create( + scope: CoroutineScope, + mode: ManageTokensMode, + source: ManageTokensSource, + useCasesFacade: ManageTokensUseCasesFacade, + onCurrencySelect: (ManagedCryptoCurrency.Token) -> Unit = {}, + ): ManageTokensListManager } } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListState.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListState.kt index 42fa36da37..b559a93ae7 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListState.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListState.kt @@ -3,7 +3,7 @@ package com.tangem.features.managetokens.utils.list import com.tangem.domain.managetokens.model.ManageTokensListConfig import com.tangem.domain.managetokens.model.ManageTokensUpdateAction import com.tangem.domain.managetokens.model.ManagedCryptoCurrency -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.entity.item.CurrencyItemUM import com.tangem.pagination.Batch import com.tangem.pagination.BatchAction @@ -13,7 +13,7 @@ internal typealias ManageTokensBatchAction = BatchAction = PaginationStatus.None, - val userWalletId: UserWalletId? = null, + val mode: ManageTokensMode, val uiBatches: List>> = mutableListOf(), val currencyBatches: List>> = mutableListOf(), val canEditItems: Boolean = true, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiActions.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiActions.kt index 8c1873e7da..13a4a44cbd 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiActions.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiActions.kt @@ -3,7 +3,6 @@ package com.tangem.features.managetokens.utils.list import com.tangem.domain.managetokens.model.CurrencyUnsupportedState import com.tangem.domain.managetokens.model.ManagedCryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId internal interface ManageTokensUiActions { @@ -13,14 +12,13 @@ internal interface ManageTokensUiActions { fun removeCurrency(batchKey: Int, currency: ManagedCryptoCurrency.Token, network: Network) - fun removeCustomCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom) + fun removeCustomCurrency(currency: ManagedCryptoCurrency.Custom) fun checkNeedToShowRemoveNetworkWarning(currency: ManagedCryptoCurrency.Token, network: Network): Boolean - suspend fun checkHasLinkedTokens(userWalletId: UserWalletId, network: Network): Boolean + suspend fun checkHasLinkedTokens(network: Network): Boolean suspend fun checkCurrencyUnsupportedState( - userWalletId: UserWalletId, sourceNetwork: ManagedCryptoCurrency.SourceNetwork, ): CurrencyUnsupportedState? } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiManager.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiManager.kt index c83c810a11..49919e627d 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiManager.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiManager.kt @@ -1,19 +1,10 @@ package com.tangem.features.managetokens.utils.list -import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.message.DialogMessage -import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.managetokens.model.ManagedCryptoCurrency -import com.tangem.domain.models.network.Network -import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.managetokens.entity.item.CurrencyItemUM -import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.utils.mapper.toUiModel import com.tangem.features.managetokens.utils.ui.update import com.tangem.pagination.Batch -import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.addOrReplace import kotlinx.collections.immutable.ImmutableList @@ -29,19 +20,12 @@ import kotlinx.coroutines.launch @Suppress("LongParameterList") internal class ManageTokensUiManager( private val state: MutableStateFlow, - private val messageSender: UiMessageSender, private val dispatchers: CoroutineDispatcherProvider, - private val scopeProvider: Provider, - private val sourceProvider: Provider, + private val scope: CoroutineScope, private val actions: ManageTokensUiActions, + private val manageTokensWarningDelegate: ManageTokensWarningDelegate, ) { - private val scope: CoroutineScope - get() = scopeProvider() - - private val source: ManageTokensSource - get() = sourceProvider() - @OptIn(ExperimentalCoroutinesApi::class) val items: Flow> = state .mapLatest { state -> @@ -112,75 +96,11 @@ internal class ManageTokensUiManager( } private fun removeCustomCurrency(currency: ManagedCryptoCurrency.Custom) = scope.launch(dispatchers.default) { - showRemoveNetworkWarning( + manageTokensWarningDelegate.showRemoveNetworkWarning( currency = currency, network = currency.network, isCoin = currency is ManagedCryptoCurrency.Custom.Coin, - onConfirm = { - val userWalletId = requireNotNull(state.value.userWalletId) { "UserWalletId is null. Can not remove" } - actions.removeCustomCurrency(userWalletId = userWalletId, currency = currency) - }, + onConfirm = { actions.removeCustomCurrency(currency = currency) }, ) } - - private suspend fun showRemoveNetworkWarning( - currency: ManagedCryptoCurrency, - network: Network, - isCoin: Boolean, - onConfirm: () -> Unit, - ) { - val userWalletId = state.value.userWalletId - val hasLinkedTokens = if (userWalletId == null || !isCoin) { - false - } else { - actions.checkHasLinkedTokens(userWalletId, network) - } - val canHideWithoutConfirming = source == ManageTokensSource.ONBOARDING - - if (hasLinkedTokens) { - showLinkedTokensWarning(currency, network) - } else if (canHideWithoutConfirming) { - onConfirm() - } else { - showHideTokenWarning(currency, onConfirm) - } - } - - private fun showLinkedTokensWarning(currency: ManagedCryptoCurrency, network: Network) { - val message = DialogMessage( - title = resourceReference( - id = R.string.token_details_unable_hide_alert_title, - formatArgs = wrappedList(currency.name), - ), - message = resourceReference( - id = R.string.token_details_unable_hide_alert_message, - formatArgs = wrappedList( - currency.name, - currency.symbol, - network.name, - ), - ), - ) - messageSender.send(message) - } - - private fun showHideTokenWarning(currency: ManagedCryptoCurrency, onConfirm: () -> Unit) { - val message = DialogMessage( - title = resourceReference( - id = R.string.token_details_hide_alert_title, - formatArgs = wrappedList(currency.name), - ), - message = resourceReference(R.string.token_details_hide_alert_message), - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.token_details_hide_alert_hide), - warning = true, - onClick = onConfirm, - ) - }, - secondActionBuilder = { cancelAction() }, - ) - - messageSender.send(message) - } } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt new file mode 100644 index 0000000000..43b4501402 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt @@ -0,0 +1,108 @@ +package com.tangem.features.managetokens.utils.list + +import arrow.core.Either +import arrow.core.left +import com.tangem.domain.managetokens.* +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState +import com.tangem.domain.managetokens.model.ManageTokensListConfig +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase +import com.tangem.features.managetokens.component.ManageTokensMode +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("LongParameterList") +internal class ManageTokensUseCasesFacade @AssistedInject constructor( + val getManagedTokensUseCase: GetManagedTokensUseCase, + val getDistinctManagedTokensUseCase: GetDistinctManagedCurrenciesUseCase, + private val checkHasLinkedTokensUseCase: CheckHasLinkedTokensUseCase, + private val removeCustomCurrencyUseCase: RemoveCustomManagedCryptoCurrencyUseCase, + private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, + private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, + private val saveManagedTokensUseCase: SaveManagedTokensUseCase, + @Assisted private val mode: ManageTokensMode, +) { + + private val nonePortfolioError: IllegalStateException + get() = IllegalStateException("Unsupported") + + fun manageTokensListConfig(searchText: String?): ManageTokensListConfig { + val userWalletId: UserWalletId? = when (mode) { + is ManageTokensMode.Account -> TODO("Account") + ManageTokensMode.None -> null + is ManageTokensMode.Wallet -> mode.userWalletId + } + return ManageTokensListConfig(userWalletId, searchText) + } + + suspend fun removeCustomCurrencyUseCase(customCurrency: ManagedCryptoCurrency.Custom): Either { + return when (mode) { + is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Wallet -> removeCustomCurrencyUseCase.invoke( + userWalletId = mode.userWalletId, + customCurrency = customCurrency, + ) + ManageTokensMode.None -> nonePortfolioError.left() + } + } + + suspend fun checkHasLinkedTokensUseCase( + network: Network, + tempAddedTokens: Map>, + tempRemovedTokens: Map>, + ): Either { + return when (mode) { + is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Wallet -> checkHasLinkedTokensUseCase.invoke( + userWalletId = mode.userWalletId, + network = network, + tempAddedTokens = tempAddedTokens, + tempRemovedTokens = tempRemovedTokens, + ) + ManageTokensMode.None -> nonePortfolioError.left() + } + } + + suspend fun checkCurrencyUnsupportedUseCase( + sourceNetwork: ManagedCryptoCurrency.SourceNetwork, + ): Either { + return when (mode) { + is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Wallet -> checkCurrencyUnsupportedUseCase.invoke( + userWalletId = mode.userWalletId, + sourceNetwork = sourceNetwork, + ) + ManageTokensMode.None -> nonePortfolioError.left() + } + } + + suspend fun needColdWalletInteraction(network: Map): Boolean = when (mode) { + is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Wallet -> coldWalletAndHasMissedDerivationsUseCase.invoke( + userWalletId = mode.userWalletId, + networksWithDerivationPath = network, + ) + ManageTokensMode.None -> false + } + + suspend fun saveManagedTokensUseCase( + currenciesToAdd: Map>, + currenciesToRemove: Map>, + ): Either = when (mode) { + is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Wallet -> saveManagedTokensUseCase.invoke( + userWalletId = mode.userWalletId, + currenciesToAdd = currenciesToAdd, + currenciesToRemove = currenciesToRemove, + ) + ManageTokensMode.None -> nonePortfolioError.left() + } + + @AssistedFactory + interface Factory { + fun create(mode: ManageTokensMode): ManageTokensUseCasesFacade + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensWarningDelegate.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensWarningDelegate.kt new file mode 100644 index 0000000000..037e69b449 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensWarningDelegate.kt @@ -0,0 +1,98 @@ +package com.tangem.features.managetokens.utils.list + +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.features.managetokens.component.ManageTokensMode +import com.tangem.features.managetokens.component.ManageTokensSource +import com.tangem.features.managetokens.impl.R +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class ManageTokensWarningDelegate @AssistedInject constructor( + private val messageSender: UiMessageSender, + @Assisted private val mode: ManageTokensMode, + @Assisted private val source: ManageTokensSource, + @Assisted private val uiActions: ManageTokensUiActions, +) { + + suspend fun showRemoveNetworkWarning( + currency: ManagedCryptoCurrency, + network: Network, + isCoin: Boolean, + onConfirm: () -> Unit, + ) { + val isNonePortfolio = when (mode) { + ManageTokensMode.None -> true + is ManageTokensMode.Account, + is ManageTokensMode.Wallet, + -> false + } + val hasLinkedTokens = if (isNonePortfolio || !isCoin) { + false + } else { + uiActions.checkHasLinkedTokens(network) + } + val canHideWithoutConfirming = source == ManageTokensSource.ONBOARDING + + if (hasLinkedTokens) { + showLinkedTokensWarning(currency, network) + } else if (canHideWithoutConfirming) { + onConfirm() + } else { + showHideTokenWarning(currency, onConfirm) + } + } + + private fun showLinkedTokensWarning(currency: ManagedCryptoCurrency, network: Network) { + val message = DialogMessage( + title = resourceReference( + id = R.string.token_details_unable_hide_alert_title, + formatArgs = wrappedList(currency.name), + ), + message = resourceReference( + id = R.string.token_details_unable_hide_alert_message, + formatArgs = wrappedList( + currency.name, + currency.symbol, + network.name, + ), + ), + ) + messageSender.send(message) + } + + private fun showHideTokenWarning(currency: ManagedCryptoCurrency, onConfirm: () -> Unit) { + val message = DialogMessage( + title = resourceReference( + id = R.string.token_details_hide_alert_title, + formatArgs = wrappedList(currency.name), + ), + message = resourceReference(R.string.token_details_hide_alert_message), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.token_details_hide_alert_hide), + warning = true, + onClick = onConfirm, + ) + }, + secondActionBuilder = { cancelAction() }, + ) + + messageSender.send(message) + } + + @AssistedFactory + interface Factory { + fun create( + mode: ManageTokensMode, + source: ManageTokensSource, + uiActions: ManageTokensUiActions, + ): ManageTokensWarningDelegate + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CustomCurrencyFormOperations.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CustomCurrencyFormOperations.kt index b149fde021..bee73eb1b9 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CustomCurrencyFormOperations.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CustomCurrencyFormOperations.kt @@ -27,7 +27,7 @@ internal fun CustomTokenFormUM.updateWithProgress( showProgress: Boolean, isWasFilled: Boolean = this.tokenForm?.wasFilled ?: false, canAddToken: Boolean = this.canAddToken, - needToAddDerivation: Boolean = false, + walletInteractionIcon: Int? = this.walletInteractionIcon, clearNotifications: Boolean = false, clearFieldErrors: Boolean = false, disableSecondaryFields: Boolean = false, @@ -35,7 +35,7 @@ internal fun CustomTokenFormUM.updateWithProgress( return copy( isValidating = showProgress, canAddToken = canAddToken, - needToAddDerivation = needToAddDerivation, + walletInteractionIcon = walletInteractionIcon, notifications = if (clearNotifications) persistentListOf() else notifications, ).updateTokenForm { val updatedFields = fields.mapValues { (key, field) -> diff --git a/features/markets/impl/build.gradle.kts b/features/markets/impl/build.gradle.kts index 1b9562b290..63c479bfc9 100644 --- a/features/markets/impl/build.gradle.kts +++ b/features/markets/impl/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { api(projects.features.onramp.api) api(projects.features.sendV2.api) api(projects.features.tokenRecieve.api) + api(projects.features.wallet.api) /* Data */ implementation(projects.data.common) @@ -88,4 +89,5 @@ dependencies { /** Tangem libraries */ implementation(tangemDeps.card.core) + implementation(tangemDeps.blockchain) } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt index 0778b806c6..6107b6d7a2 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt @@ -24,12 +24,15 @@ import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.format.bigdecimal.price import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.markets.* +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.markets.details.MarketsTokenDetailsComponent import com.tangem.features.markets.details.impl.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.markets.details.impl.model.converters.DescriptionConverter @@ -72,6 +75,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val excludedBlockchains: ExcludedBlockchains, private val getUserCountryUseCase: GetUserCountryUseCase, + private val getUserWalletsUseCase: GetWalletsUseCase, ) : Model() { private var quotesJob = JobHolder() @@ -423,8 +427,15 @@ internal class MarketsTokenDetailsModel @Inject constructor( ) } + val allWalletsIsHot = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } + val networks = newInfo.networks?.filter { - BlockchainUtils.isSupportedNetworkId(it.networkId, excludedBlockchains) + BlockchainUtils.isSupportedNetworkId( + blockchainId = it.networkId, + excludedBlockchains = excludedBlockchains, + hotExcludedBlockchains = hotWalletExcludedBlockchains, + hasOnlyHotWallets = allWalletsIsHot, + ) } networksState.value = if (networks.isNullOrEmpty()) { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt index feef86486d..7d607c26d9 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt @@ -7,7 +7,6 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.rows.model.BlockchainRowUM import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -56,7 +55,7 @@ internal class AddToPortfolioBSContentUMFactory( portfolioUIData: PortfolioUIData, selectedWallet: UserWallet?, alreadyAddedNetworks: Set?, - artworks: HashMap, + artworks: Map, ): TangemBottomSheetConfig { return (currentState ?: TangemBottomSheetConfig.Empty).copy( isShown = portfolioUIData.portfolioBSVisibilityModel.addToPortfolioBSVisibility, @@ -77,7 +76,7 @@ internal class AddToPortfolioBSContentUMFactory( alreadyAddedNetworks = alreadyAddedNetworks, onNetworkSwitchClick = onNetworkSwitchClick, ).convert(value = token), - isScanCardNotificationVisible = portfolioUIData.hasMissedDerivations, + isScanCardNotificationVisible = portfolioUIData.needColdWalletInteraction, continueButtonEnabled = portfolioUIData.addToPortfolioData.isUserAddedNetworks( userWalletId = selectedWallet.walletId, ), @@ -110,7 +109,7 @@ internal class AddToPortfolioBSContentUMFactory( } private fun UserWallet.toSelectedUserWalletItemUM( - artwork: ArtworkModel? = null, + artwork: UserWalletItemUM.ImageState? = null, portfolioData: PortfolioData, balance: TotalFiatBalance?, ): UserWalletItemUM { @@ -128,7 +127,7 @@ internal class AddToPortfolioBSContentUMFactory( isShow: Boolean, portfolioData: PortfolioData, selectedWalletId: UserWalletId, - artworks: HashMap, + artworks: Map, ): TangemBottomSheetConfig { return TangemBottomSheetConfig( isShown = isShow, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt index f7a23493ed..913963f668 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.markets.portfolio.impl.model import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -21,7 +22,6 @@ import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase import com.tangem.domain.managetokens.model.CurrencyUnsupportedState import com.tangem.domain.markets.SaveMarketTokensUseCase import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.ReceiveAddressModel import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency @@ -32,9 +32,8 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.transaction.usecase.GetEnsNameUseCase -import com.tangem.domain.wallets.usecase.GetCardImageUseCase +import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase -import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase import com.tangem.features.markets.impl.R import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent @@ -43,13 +42,14 @@ import com.tangem.features.markets.portfolio.impl.loader.PortfolioDataLoader import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle +import com.tangem.features.wallet.utils.UserWalletImageFetcher import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.operations.attestation.ArtworkSize import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject @@ -65,23 +65,19 @@ internal class MarketsPortfolioModel @Inject constructor( private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, private val getSelectedWalletUseCase: GetSelectedWalletUseCase, private val portfolioDataLoader: PortfolioDataLoader, - private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase, + private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, private val saveMarketTokensUseCase: SaveMarketTokensUseCase, - private val getCardImageUseCase: GetCardImageUseCase, private val addToPortfolioManager: AddToPortfolioManager, private val analyticsEventHandler: AnalyticsEventHandler, private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, private val getEnsNameUseCase: GetEnsNameUseCase, + private val userWalletImageFetcher: UserWalletImageFetcher, ) : Model() { val state: StateFlow get() = _state private val _state: MutableStateFlow = MutableStateFlow(value = MyPortfolioUM.Loading) - private val loadedArtworks: HashMap = hashMapOf() - private val artworksState: MutableStateFlow> = MutableStateFlow(hashMapOf()) - private val loadArtworksMutex = Mutex() - private val params = paramsContainer.require() private val analyticsEventBuilder = PortfolioAnalyticsEvent.EventBuilder( token = params.token, @@ -197,38 +193,33 @@ internal class MarketsPortfolioModel @Inject constructor( private fun subscribeOnStateUpdates() { combine( - flow = loadPortfolioData(params.token.id), + flow = loadPortfolioDataWithArtworks(params.token.id), flow2 = getPortfolioUIDataFlow(), - flow3 = artworksState, - transform = factory::create, + transform = { pair, portfolioUIData -> + val (portfolioData, artworks) = pair + factory.create(portfolioData, portfolioUIData, artworks) + }, ) .onEach { _state.value = it } .launchIn(modelScope) } - private fun loadPortfolioData(currencyRawId: CryptoCurrency.RawID): Flow { - portfolioDataLoader.load(currencyRawId).onEach { - loadArtworks(it.walletsWithCurrencies.keys.toList()) - }.also { return it } - } + private fun loadPortfolioDataWithArtworks( + currencyRawId: CryptoCurrency.RawID, + ): Flow>> { + val wallets = Channel>() + val portfolioFlow = portfolioDataLoader + .load(currencyRawId) + .onEach { wallets.trySend(it.walletsWithCurrencies.keys) } - private fun loadArtworks(wallets: List) { - modelScope.launch { - loadArtworksMutex.withLock { - wallets.filterIsInstance().forEach { wallet -> - if (!loadedArtworks.containsKey(wallet.walletId)) { - val artwork = getCardImageUseCase( - cardId = wallet.cardId, - manufacturerName = wallet.scanResponse.card.manufacturer.name, - firmwareVersion = wallet.scanResponse.card.firmwareVersion.toSdkFirmwareVersion(), - cardPublicKey = wallet.scanResponse.card.cardPublicKey, - ) - loadedArtworks[wallet.walletId] = artwork - artworksState.emit(loadedArtworks) - } - } - } - } + val artworksFlow = wallets.receiveAsFlow() + .distinctUntilChanged() + .flatMapLatest { userWalletImageFetcher.walletsImage(wallets = it, size = ArtworkSize.SMALL) } + + return combine( + flow = portfolioFlow, + flow2 = artworksFlow, + ) { portfolioData, artworks -> portfolioData to artworks } } private fun getPortfolioUIDataFlow(): Flow { @@ -241,18 +232,18 @@ internal class MarketsPortfolioModel @Inject constructor( portfolioBSVisibilityModel = portfolioBSVisibilityModel, selectedWalletId = selectedWalletId, addToPortfolioData = addToPortfolioData, - hasMissedDerivations = hasMissedDerivations(selectedWalletId, addToPortfolioData), + needColdWalletInteraction = needColdWalletInteraction(selectedWalletId, addToPortfolioData), ) }, ) } - private suspend fun hasMissedDerivations( + private suspend fun needColdWalletInteraction( selectedWalletId: UserWalletId?, addToPortfolioData: AddToPortfolioManager.AddToPortfolioData, ): Boolean { return if (selectedWalletId != null) { - hasMissedDerivationsUseCase.invoke( + coldWalletAndHasMissedDerivationsUseCase.invoke( userWalletId = selectedWalletId, networksWithDerivationPath = addToPortfolioData.addedNetworks[selectedWalletId].orEmpty() .associate { it.networkId to null }, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt index 5c5bb59b96..44fd810a9a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt @@ -1,8 +1,8 @@ package com.tangem.features.markets.portfolio.impl.model +import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -36,7 +36,7 @@ internal class MyPortfolioUMFactory( fun create( portfolioData: PortfolioData, portfolioUIData: PortfolioUIData, - artworks: HashMap, + artworks: Map, ): MyPortfolioUM { val addToPortfolioData = portfolioUIData.addToPortfolioData @@ -89,7 +89,7 @@ internal class MyPortfolioUMFactory( private fun createAddToPortfolioBSConfig( portfolioData: PortfolioData, portfolioUIData: PortfolioUIData, - artworks: HashMap, + artworks: Map, ): TangemBottomSheetConfig { val selectedWallet = portfolioData.walletsWithCurrencies.keys .firstOrNull { it.walletId == portfolioUIData.selectedWalletId } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt index d2e7ee0d2a..f0dfad20dd 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt @@ -8,7 +8,7 @@ import com.tangem.domain.models.wallet.UserWalletId * @property portfolioBSVisibilityModel portfolio bottom sheet visibility model * @property selectedWalletId selected wallet id * @property addToPortfolioData add to portfolio data - * @property hasMissedDerivations flag that indicates if user has missed derivations + * @property needColdWalletInteraction flag that indicates if user has missed derivations and has a cold wallet * [REDACTED_AUTHOR] */ @@ -16,5 +16,5 @@ internal data class PortfolioUIData( val portfolioBSVisibilityModel: PortfolioBSVisibilityModel, val selectedWalletId: UserWalletId?, val addToPortfolioData: AddToPortfolioManager.AddToPortfolioData, - val hasMissedDerivations: Boolean, + val needColdWalletInteraction: Boolean, ) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt index fdfbb03db2..c12a36531c 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt @@ -26,8 +26,8 @@ import com.tangem.common.ui.userwallet.UserWalletItem import com.tangem.core.ui.components.* import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.block.information.InformationBlock -import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet 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 @@ -228,9 +228,7 @@ private fun NetworkSelection(state: SelectNetworkUM, modifier: Modifier = Modifi isLastItem = index == state.networks.lastIndex, content = { BlockchainRow( - modifier = Modifier.padding( - end = TangemTheme.dimens.spacing4, - ), + modifier = Modifier.padding(end = TangemTheme.dimens.spacing4), model = network, action = { TangemSwitch( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt index 01bcc834fd..e0daa6f742 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -28,6 +29,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.MarketsTestTags import com.tangem.core.ui.windowsize.WindowSizeType import com.tangem.features.markets.impl.R import com.tangem.features.markets.tokenlist.impl.ui.preview.MarketChartListItemPreviewDataProvider @@ -41,7 +43,8 @@ internal fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modi modifier = modifier .fillMaxWidth() .clip(RectangleShape) - .clickable(onClick = onClick), + .clickable(onClick = onClick) + .testTag(MarketsTestTags.TOKENS_LIST_ITEM), model = model, ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt index 91029b1f71..bc0f3aea6d 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig @@ -18,6 +19,7 @@ import com.tangem.core.ui.event.EventEffect 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.test.MarketsTestTags import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.markets.impl.R import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM @@ -65,7 +67,7 @@ internal fun MarketsListLazyColumn( } } else { LazyColumn( - modifier = modifier, + modifier = modifier.testTag(MarketsTestTags.TOKENS_LIST), state = lazyListState, contentPadding = PaddingValues(bottom = bottomBarHeight), userScrollEnabled = true, diff --git a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt index b52fabf421..a21785815f 100644 --- a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt +++ b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt @@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.entry import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWalletId interface OnboardingEntryComponent : ComposableContentComponent { @@ -11,12 +12,13 @@ interface OnboardingEntryComponent : ComposableContentComponent { val mode: Mode, ) - enum class Mode { - Onboarding, - AddBackupWallet1, - WelcomeOnlyTwin, - RecreateWalletTwin, - ContinueFinalize, + sealed class Mode { + data object Onboarding : Mode() + data object AddBackupWallet1 : Mode() + data object WelcomeOnlyTwin : Mode() + data object RecreateWalletTwin : Mode() + data object ContinueFinalize : Mode() + data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() } interface Factory : ComponentFactory diff --git a/features/onboarding-v2/impl/build.gradle.kts b/features/onboarding-v2/impl/build.gradle.kts index d6abeb7356..7febfb81ba 100644 --- a/features/onboarding-v2/impl/build.gradle.kts +++ b/features/onboarding-v2/impl/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation(projects.domain.transaction) /** Tangem libraries */ + implementation(tangemDeps.hot.core) implementation(projects.libs.tangemSdkApi) implementation(tangemDeps.card.core) implementation(tangemDeps.card.android) { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt index 24dc8aaa95..ff0ace4c4a 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt @@ -13,12 +13,14 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.legacy.asLockable import com.tangem.domain.models.wallet.UserWallet import com.tangem.features.biometry.AskBiometryComponent +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.onboarding.v2.TitleProvider import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent @@ -46,6 +48,8 @@ internal class OnboardingEntryModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val uiMessageSender: UiMessageSender, private val userWalletsListManager: UserWalletsListManager, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val userWalletsListRepository: UserWalletsListRepository, ) : Model() { private val params = paramsContainer.require() @@ -69,16 +73,20 @@ internal class OnboardingEntryModel @Inject constructor( uiMessageSender.send(CantLeaveBackupDialog) } + @Suppress("CyclomaticComplexMethod") private fun routeByProductType(scanResponse: ScanResponse): OnboardingRoute { return when (scanResponse.productType) { ProductType.Wallet, ProductType.Wallet2, ProductType.Ring, -> { - val multiWalletNavigationMode = when (params.mode) { - Mode.Onboarding -> OnboardingMultiWalletComponent.Mode.Onboarding - Mode.AddBackupWallet1 -> OnboardingMultiWalletComponent.Mode.AddBackup - Mode.ContinueFinalize -> OnboardingMultiWalletComponent.Mode.ContinueFinalize + val multiWalletNavigationMode = when (val mode = params.mode) { + is Mode.Onboarding -> OnboardingMultiWalletComponent.Mode.Onboarding + is Mode.AddBackupWallet1 -> OnboardingMultiWalletComponent.Mode.AddBackup + is Mode.ContinueFinalize -> OnboardingMultiWalletComponent.Mode.ContinueFinalize + is Mode.UpgradeHotWallet -> OnboardingMultiWalletComponent.Mode.UpgradeHotWallet( + userWalletId = mode.userWalletId, + ) else -> error("Incorrect onboarding type") } @@ -145,7 +153,7 @@ internal class OnboardingEntryModel @Inject constructor( doneMode: OnboardingDoneComponent.Mode = OnboardingDoneComponent.Mode.WalletCreated, ) { modelScope.launch { - if (tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen()) { + if (tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowAskBiometry()) { doIfVisa { analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.BiometricScreenOpened) } @@ -197,6 +205,19 @@ internal class OnboardingEntryModel @Inject constructor( } private fun exitComponentScreen() { + // new flow + if (hotWalletFeatureToggles.isHotWalletEnabled) { + modelScope.launch { + if (userWalletsListRepository.userWalletsSync().isEmpty()) { + router.replaceAll(AppRoute.Home()) + } else { + router.replaceAll(AppRoute.Wallet) + } + } + return + } + + // legacy flow if (userWalletsListManager.hasUserWallets) { val isLocked = runCatching { userWalletsListManager.asLockable()?.isLockedSync!! }.getOrElse { false } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt index a6c7896af2..75a31024e6 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt @@ -5,6 +5,7 @@ import com.tangem.core.decompose.navigation.inner.InnerNavigationHolder import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.onboarding.v2.TitleProvider interface OnboardingMultiWalletComponent : ComposableContentComponent, InnerNavigationHolder { @@ -17,10 +18,11 @@ interface OnboardingMultiWalletComponent : ComposableContentComponent, InnerNavi val onDone: (UserWallet.Cold) -> Unit, ) - enum class Mode { - Onboarding, - AddBackup, - ContinueFinalize, + sealed class Mode { + data object Onboarding : Mode() + data object AddBackup : Mode() + data object ContinueFinalize : Mode() + data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() } interface Factory : ComponentFactory diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt index 90696c234b..6a1d3cf78e 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt @@ -35,6 +35,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.Mul import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.MultiWalletFinalizeComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.scanprimary.MultiWalletScanPrimaryComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.MultiWalletSeedPhraseComponent +import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.MultiWalletUpgradeWalletComponent import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletModel import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.Step.* @@ -57,6 +58,7 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor private val artworksState = instanceKeeper.getOrCreateSimple(key = "artworksState") { MutableStateFlow( when (model.state.value.currentStep) { + UpgradeWallet -> WalletArtworksState.Folded CreateWallet -> WalletArtworksState.Folded ChooseBackupOption -> WalletArtworksState.Fan SeedPhrase -> WalletArtworksState.Folded @@ -142,6 +144,11 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor childContext: AppComponentContext, ): ComposableContentComponent { return when (step) { + UpgradeWallet -> MultiWalletUpgradeWalletComponent( + context = childContext, + params = childParams, + onNextStep = ::handleNavigationEvent, + ) CreateWallet -> MultiWalletCreateWalletComponent( context = childContext, params = childParams, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt index a864a30018..1567ffe625 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt @@ -9,7 +9,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.card.repository.CardRepository -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.scan.ScanResponse @@ -39,7 +39,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val tangemSdkManager: TangemSdkManager, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val cardRepository: CardRepository, private val analyticsHandler: AnalyticsEventHandler, private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, @@ -168,7 +168,8 @@ internal class MultiWalletCreateWalletModel @Inject constructor( fun navigateToSupportScreen() { modelScope.launch { - val cardInfo = getCardInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch + val cardInfo = + getWalletMetaInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo)) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index 8565d7c6f3..65dd32233d 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -9,7 +9,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.repository.CardRepository -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.scan.CardDTO @@ -19,8 +19,8 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.wallets.builder.ColdUserWalletBuilder -import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.repository.WalletsRepository +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.domain.wallets.usecase.UpdateWalletUseCase import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog @@ -37,7 +37,10 @@ import com.tangem.sdk.api.TangemSdkManager import com.tangem.utils.StringsSigns import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -49,11 +52,11 @@ internal class MultiWalletFinalizeModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val backupServiceHolder: BackupServiceHolder, private val tangemSdkManager: TangemSdkManager, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, - private val userWalletsListManager: UserWalletsListManager, private val saveWalletUseCase: SaveWalletUseCase, + private val getUserWalletsUseCase: GetWalletsUseCase, private val updateWalletUseCase: UpdateWalletUseCase, private val cardRepository: CardRepository, private val onboardingRepository: OnboardingRepository, @@ -244,7 +247,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( userWalletCreated } OnboardingMultiWalletComponent.Mode.AddBackup -> { - val userWallet = userWalletsListManager.userWallets.first() + val userWallet = getUserWalletsUseCase.invokeSync() .firstOrNull { it is UserWallet.Cold && it.scanResponse.primaryCard?.cardId == scanResponse.primaryCard?.cardId @@ -262,6 +265,16 @@ internal class MultiWalletFinalizeModel @Inject constructor( userWallet } + is OnboardingMultiWalletComponent.Mode.UpgradeHotWallet -> { + saveWalletUseCase( + userWallet = userWalletCreated.copy( + scanResponse = scanResponse.updateScanResponseAfterBackup(), + ), + canOverride = true, + ) + // TODO [REDACTED_TASK_KEY] remove hot wallet after upgrade + userWalletCreated + } }.requireColdWallet() if (hasRing) { @@ -333,7 +346,8 @@ internal class MultiWalletFinalizeModel @Inject constructor( private fun navigateToSupportScreen() { modelScope.launch { - val cardInfo = getCardInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch + val cardInfo = + getWalletMetaInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo)) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt index c59be4fb41..52538e1e92 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt @@ -1,18 +1,28 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model import androidx.compose.runtime.Stable +import arrow.core.getOrElse import com.tangem.common.CompletionResult import com.tangem.common.core.TangemSdkError import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.SnackbarMessage import com.tangem.crypto.bip39.Mnemonic import com.tangem.domain.card.repository.CardRepository -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.wallets.usecase.IsWalletAlreadySavedUseCase import com.tangem.features.hotwallet.MnemonicRepository import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM @@ -44,9 +54,12 @@ internal class MultiWalletSeedPhraseModel @Inject constructor( private val urlOpener: UrlOpener, private val tangemSdkManager: TangemSdkManager, private val cardRepository: CardRepository, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val analyticsHandler: AnalyticsEventHandler, + private val isWalletAlreadySavedUseCase: IsWalletAlreadySavedUseCase, + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, + @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { private val params = paramsContainer.require() @@ -198,30 +211,42 @@ internal class MultiWalletSeedPhraseModel @Inject constructor( when (result) { is CompletionResult.Success -> { - analyticsHandler.send( - OnboardingEvent.CreateWallet.WalletCreatedSuccessfully( - creationType = if (generatedSeedPhrase) { - OnboardingEvent.CreateWallet.WalletCreationType.NewSeed - } else { - OnboardingEvent.CreateWallet.WalletCreationType.SeedImport - }, - seedPhraseLength = mnemonic.mnemonicComponents.size, - ), + val updatedScanResponse = multiWalletState.value.currentScanResponse.copy( + card = result.data.card, + derivedKeys = result.data.derivedKeys, + primaryCard = result.data.primaryCard, ) - multiWalletState.update { - it.copy( - currentScanResponse = it.currentScanResponse.copy( - card = result.data.card, - derivedKeys = result.data.derivedKeys, - primaryCard = result.data.primaryCard, + val wallet = createUserWallet(updatedScanResponse) + + val isWalletAlreadySaved = isWalletAlreadySavedUseCase + .invoke(wallet) + .getOrElse { false } + + if (!isWalletAlreadySaved) { + analyticsHandler.send( + OnboardingEvent.CreateWallet.WalletCreatedSuccessfully( + creationType = if (generatedSeedPhrase) { + OnboardingEvent.CreateWallet.WalletCreationType.NewSeed + } else { + OnboardingEvent.CreateWallet.WalletCreationType.SeedImport + }, + seedPhraseLength = mnemonic.mnemonicComponents.size, ), ) + + multiWalletState.update { + it.copy(currentScanResponse = updatedScanResponse) + } + + cardRepository.startCardActivation(cardId = result.data.card.cardId) + + onDone.emit(Unit) + } else { + uiMessageSender.send( + SnackbarMessage(resourceReference(R.string.hw_import_seed_phrase_already_imported)), + ) } - - cardRepository.startCardActivation(cardId = result.data.card.cardId) - - onDone.emit(Unit) } is CompletionResult.Failure -> { if (result.error is TangemSdkError.WalletAlreadyCreated) { @@ -255,9 +280,17 @@ internal class MultiWalletSeedPhraseModel @Inject constructor( } } + private fun createUserWallet(scanResponse: ScanResponse): UserWallet.Cold { + return requireNotNull( + value = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build(), + lazyMessage = { "User wallet not created" }, + ) + } + fun navigateToSupportScreen() { modelScope.launch { - val cardInfo = getCardInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch + val cardInfo = + getWalletMetaInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo)) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/GenerateSeedPhraseUiStateBuilder.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/GenerateSeedPhraseUiStateBuilder.kt index e7c4efd3b2..d5c6e72ba3 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/GenerateSeedPhraseUiStateBuilder.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/GenerateSeedPhraseUiStateBuilder.kt @@ -1,5 +1,6 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.crypto.bip39.Mnemonic import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM @@ -20,10 +21,10 @@ internal class GenerateSeedPhraseUiStateBuilder( option: GeneratedWordsType, ): MultiWalletSeedPhraseUM.GenerateSeedPhrase { val words12 = generatedWords12.mnemonicComponents.mapIndexed { index, s -> - MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem(index + 1, s) + EnumeratedTwoColumnGridItem(index + 1, s) }.toImmutableList() val words24 = generatedWords24.mnemonicComponents.mapIndexed { index, s -> - MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem(index + 1, s) + EnumeratedTwoColumnGridItem(index + 1, s) }.toImmutableList() return MultiWalletSeedPhraseUM.GenerateSeedPhrase( @@ -45,8 +46,8 @@ internal class GenerateSeedPhraseUiStateBuilder( private fun switchType( newType: GeneratedWordsType, - generatedWords12: ImmutableList, - generatedWords24: ImmutableList, + generatedWords12: ImmutableList, + generatedWords24: ImmutableList, ) { updateUiState { uiSt -> changeGeneratedWordsType(newType) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhraseWords.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhraseWords.kt index 1099d8bcc1..039ddff2e5 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhraseWords.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhraseWords.kt @@ -5,15 +5,14 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.components.grid.EnumeratedTwoColumnGrid +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.core.ui.extensions.pluralStringResourceSafe import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -21,8 +20,6 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM -import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -45,8 +42,8 @@ internal fun MultiWalletSeedPhraseWords( TitleBlock(state) - SeedPhraseGridBlock( - mnemonicGridItems = state.words, + EnumeratedTwoColumnGrid( + items = state.words, modifier = Modifier .fillMaxWidth() .padding(top = 20.dp, bottom = 32.dp), @@ -127,67 +124,6 @@ private fun TitleBlock(state: MultiWalletSeedPhraseUM.GenerateSeedPhrase, modifi } } -@Composable -private fun SeedPhraseGridBlock(mnemonicGridItems: ImmutableList, modifier: Modifier = Modifier) { - VerticalGrid( - modifier = modifier, - items = mnemonicGridItems, - ) { item -> - Row( - modifier = Modifier.padding(all = TangemTheme.dimens.size8), - verticalAlignment = Alignment.CenterVertically, - ) { - if (LocalLayoutDirection.current == LayoutDirection.Ltr) { - Text( - modifier = Modifier.width(TangemTheme.dimens.size40), - text = "${item.index}.", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - Text( - text = item.mnemonic, - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - ) - } else { - Text( - text = item.mnemonic, - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - ) - Text( - modifier = Modifier.width(TangemTheme.dimens.size40), - text = "${item.index}.", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - } - } - } -} - -@Composable -private inline fun VerticalGrid( - items: ImmutableList, - modifier: Modifier = Modifier, - crossinline content: @Composable (T) -> Unit, -) { - val columnLength = items.size / 2 - Row( - modifier = modifier, - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - repeat(2) { index -> - Column { - for (i in 0 until columnLength) { - val item = items[index * columnLength + i] - content(item) - } - } - } - } -} - @Preview(showBackground = true, heightDp = 640) @Composable private fun Preview() { @@ -195,7 +131,7 @@ private fun Preview() { MultiWalletSeedPhraseWords( state = MultiWalletSeedPhraseUM.GenerateSeedPhrase( words = List(24) { - MnemonicGridItem( + EnumeratedTwoColumnGridItem( index = it + 1, mnemonic = "word1", ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt index c167a59481..af9e46b850 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt @@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.s import androidx.compose.runtime.Immutable import androidx.compose.ui.text.input.TextFieldValue import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.core.ui.extensions.TextReference import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType @@ -22,15 +23,10 @@ internal sealed class MultiWalletSeedPhraseUM( data class GenerateSeedPhrase( val option: GeneratedWordsType = GeneratedWordsType.Words12, - val words: ImmutableList = persistentListOf(), + val words: ImmutableList = persistentListOf(), val onOptionChange: (GeneratedWordsType) -> Unit = {}, val onContinueClick: () -> Unit = {}, - ) : MultiWalletSeedPhraseUM(order = 1) { - data class MnemonicGridItem( - val index: Int, - val mnemonic: String, - ) - } + ) : MultiWalletSeedPhraseUM(order = 1) data class GeneratedWordsCheck( val wordFields: ImmutableList = persistentListOf(), diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt new file mode 100644 index 0000000000..222b173ffb --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt @@ -0,0 +1,56 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.essenty.lifecycle.doOnStart +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildComponent +import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.model.MultiWalletUpgradeWalletModel +import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.ui.MultiWalletUpgradeWallet +import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal class MultiWalletUpgradeWalletComponent( + context: AppComponentContext, + params: MultiWalletChildParams, + onNextStep: (OnboardingMultiWalletState.Step) -> Unit, +) : AppComponentContext by context, MultiWalletChildComponent { + + private val model: MultiWalletUpgradeWalletModel = getOrCreateModel(params) + + init { + lifecycle.doOnStart { + params.innerNavigation.update { + it.copy( + stackSize = 2, + stackMaxSize = 9, + ) + } + + params.parentParams.titleProvider.changeTitle( + text = resourceReference(R.string.common_tangem), + ) + } + + componentScope.launch { + model.onDone.collect(onNextStep) + } + } + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + MultiWalletUpgradeWallet( + modifier = modifier, + state = state, + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/model/MultiWalletUpgradeWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/model/MultiWalletUpgradeWalletModel.kt new file mode 100644 index 0000000000..f6b6306cd6 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/model/MultiWalletUpgradeWalletModel.kt @@ -0,0 +1,181 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.common.CompletionResult +import com.tangem.common.core.TangemSdkError +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.card.repository.CardRepository +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.ExportSeedPhraseUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams +import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.ui.state.MultiWalletUpgradeWalletUM +import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.resetCardDialog +import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.Step +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@Stable +@ModelScoped +internal class MultiWalletUpgradeWalletModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val tangemSdkManager: TangemSdkManager, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, + private val cardRepository: CardRepository, + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, + private val saveWalletUseCase: SaveWalletUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val exportSeedPhraseUseCase: ExportSeedPhraseUseCase, +) : Model() { + + private val params = paramsContainer.require() + private val multiWalletState + get() = params.multiWalletState + + private val _uiState = MutableStateFlow( + MultiWalletUpgradeWalletUM( + title = resourceReference(R.string.hw_upgrade_start_title), + bodyText = resourceReference(R.string.hw_upgrade_start_description), + onStartUpgradeClick = { + // TODO [REDACTED_TASK_KEY] track button click + upgradeWallet(false) + }, + dialog = null, + ), + ) + + val uiState: StateFlow = _uiState + val onDone = MutableSharedFlow() + + init { + // TODO [REDACTED_TASK_KEY] track screen opened + } + + private fun upgradeWallet(shouldReset: Boolean) { + modelScope.launch { + val mode = params.parentParams.mode + require(mode is OnboardingMultiWalletComponent.Mode.UpgradeHotWallet) + + val userWallet = getUserWalletUseCase(mode.userWalletId) + .getOrElse { error("User wallet with id ${mode.userWalletId} not found") } + if (userWallet is UserWallet.Hot) { + val privateInfo = exportSeedPhraseUseCase + .invoke(userWallet.hotWalletId) + .getOrElse { error("Unable to export seed phrase for wallet with id ${mode.userWalletId}") } + + modelScope.launch { + val result = tangemSdkManager.importWallet( + scanResponse = multiWalletState.value.currentScanResponse, + shouldReset = shouldReset, + mnemonic = privateInfo.mnemonic.mnemonicComponents.joinToString(" "), + passphrase = privateInfo.passphrase?.concatToString(), + ) + + when (result) { + is CompletionResult.Success -> { + multiWalletState.update { + it.copy( + currentScanResponse = it.currentScanResponse.copy( + card = result.data.card, + derivedKeys = result.data.derivedKeys, + primaryCard = result.data.primaryCard, + ), + ) + } + + cardRepository.startCardActivation(cardId = result.data.card.cardId) + + // TODO [REDACTED_TASK_KEY] track wallet created + val cardDoesNotSupportBackup = result.data.card.settings.isBackupAllowed.not() + when { + cardDoesNotSupportBackup -> createWalletAndNavigateBackWithDone() + params.parentParams.withSeedPhraseFlow -> onDone.emit(Step.AddBackupDevice) + else -> { + onDone.emit(Step.ChooseBackupOption) + } + } + } + + is CompletionResult.Failure -> { + if (result.error is TangemSdkError.WalletAlreadyCreated) { + // show should reset dialog + handleActivationError() + } + } + } + } + } + } + } + + private fun createWalletAndNavigateBackWithDone() { + modelScope.launch { + val scanResponse = params.multiWalletState.value.currentScanResponse + + val userWallet = createUserWallet(scanResponse) + saveWalletUseCase(userWallet, canOverride = true) + .onRight { + cardRepository.finishCardActivation(scanResponse.card.cardId) + + // save user wallet for manage tokens screen + params.multiWalletState.update { + it.copy(resultUserWallet = userWallet) + } + + onDone.emit(Step.Done) + } + } + } + + private fun createUserWallet(scanResponse: ScanResponse): UserWallet.Cold { + return requireNotNull( + value = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build(), + lazyMessage = { "User wallet not created" }, + ) + } + + private fun handleActivationError() { + _uiState.update { state -> + state.copy( + dialog = resetCardDialog( + onConfirm = ::navigateToSupportScreen, + dismiss = { _uiState.update { it.copy(dialog = null) } }, + onDismissButtonClick = ::resetCard, + ), + ) + } + } + + private fun resetCard() { + upgradeWallet(true) + } + + fun navigateToSupportScreen() { + modelScope.launch { + val cardInfo = + getWalletMetaInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch + sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo)) + } + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/MultiWalletUpgradeWallet.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/MultiWalletUpgradeWallet.kt new file mode 100644 index 0000000000..28ffd8f2f8 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/MultiWalletUpgradeWallet.kt @@ -0,0 +1,98 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.components.BasicDialog +import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.ui.state.MultiWalletUpgradeWalletUM + +@Composable +internal fun MultiWalletUpgradeWallet(state: MultiWalletUpgradeWalletUM, modifier: Modifier = Modifier) { + if (state.dialog != null) { + BasicDialog( + title = state.dialog.title.resolveReference(), + message = state.dialog.message.resolveReference(), + confirmButton = DialogButtonUM( + title = state.dialog.confirmButtonText.resolveReference(), + onClick = state.dialog.onConfirmClick, + ), + dismissButton = DialogButtonUM( + title = state.dialog.dismissButtonText.resolveReference(), + warning = state.dialog.dismissWarningColor, + onClick = state.dialog.onDismissButtonClick, + ), + onDismissDialog = state.dialog.onDismiss, + ) + } + + Column( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding(), + verticalArrangement = Arrangement.Bottom, + ) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(start = 32.dp, end = 32.dp, bottom = 16.dp) + .weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 16.dp), + ) + + Text( + text = state.bodyText.resolveReference(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 12.dp), + ) + } + + PrimaryButtonIconEnd( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .fillMaxWidth(), + iconResId = R.drawable.ic_tangem_24, + text = stringResourceSafe(R.string.hw_upgrade_start_action), + onClick = state.onStartUpgradeClick, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + TangemThemePreview { + MultiWalletUpgradeWallet( + state = MultiWalletUpgradeWalletUM( + title = stringReference("Title"), + bodyText = stringReference("Body body body"), + onStartUpgradeClick = {}, + dialog = null, + ), + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/state/MultiWalletUpgradeWalletUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/state/MultiWalletUpgradeWalletUM.kt new file mode 100644 index 0000000000..c61c12a810 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/state/MultiWalletUpgradeWalletUM.kt @@ -0,0 +1,11 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.ui.state + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM + +internal data class MultiWalletUpgradeWalletUM( + val title: TextReference, + val bodyText: TextReference, + val onStartUpgradeClick: () -> Unit, + val dialog: OnboardingDialogUM?, +) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/di/ComponentModule.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/di/ComponentModule.kt index 45a7844f49..86d6bdf5b2 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/di/ComponentModule.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/di/ComponentModule.kt @@ -11,6 +11,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.mod import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.model.MultiWalletFinalizeModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.scanprimary.model.MultiWalletScanPrimaryModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.MultiWalletSeedPhraseModel +import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.model.MultiWalletUpgradeWalletModel import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletModel import dagger.Binds import dagger.Module @@ -72,4 +73,9 @@ internal interface ModelModule { @IntoMap @ClassKey(MultiWalletScanPrimaryModel::class) fun provideModel8(model: MultiWalletScanPrimaryModel): Model + + @Binds + @IntoMap + @ClassKey(MultiWalletUpgradeWalletModel::class) + fun provideModel9(model: MultiWalletUpgradeWalletModel): Model } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt index bf9980e12d..9895662a0a 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt @@ -117,6 +117,9 @@ internal class OnboardingMultiWalletModel @Inject constructor( val card = scanResponse.card return when { + params.mode is OnboardingMultiWalletComponent.Mode.UpgradeHotWallet -> { + OnboardingMultiWalletState.Step.UpgradeWallet + } params.mode == OnboardingMultiWalletComponent.Mode.ContinueFinalize -> OnboardingMultiWalletState.Step.Finalize // Add backup button diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt index aeb5dc4011..d1fa192d94 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt @@ -32,7 +32,14 @@ data class OnboardingMultiWalletState( * -> AddBackupDevice -> Finalize -> [Done] */ enum class Step { - CreateWallet, ChooseBackupOption, SeedPhrase, ScanPrimary, AddBackupDevice, Finalize, Done + UpgradeWallet, + CreateWallet, + ChooseBackupOption, + SeedPhrase, + ScanPrimary, + AddBackupDevice, + Finalize, + Done, } @JvmInline diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt index 1f4d1ee3a6..9a7b2c1476 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt @@ -14,4 +14,5 @@ fun screenTitleByStep(step: OnboardingMultiWalletState.Step): TextReference = wh OnboardingMultiWalletState.Step.ChooseBackupOption -> resourceReference(R.string.onboarding_getting_started) OnboardingMultiWalletState.Step.Finalize -> resourceReference(R.string.onboarding_button_finalize_backup) OnboardingMultiWalletState.Step.Done -> resourceReference(R.string.common_done) + OnboardingMultiWalletState.Step.UpgradeWallet -> resourceReference(R.string.common_tangem) } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt index 6a15db448c..af0fadb5fb 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt @@ -10,7 +10,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.decompose.context.AppComponentContext import com.tangem.domain.card.common.TapWorkarounds.isVisa -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.features.onboarding.v2.stepper.api.OnboardingStepperComponent @@ -24,7 +24,7 @@ import kotlinx.coroutines.launch internal class DefaultOnboardingStepperComponent @AssistedInject constructor( @Assisted val context: AppComponentContext, @Assisted val params: OnboardingStepperComponent.Params, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val analyticsHandler: AnalyticsEventHandler, ) : OnboardingStepperComponent, AppComponentContext by context { @@ -40,7 +40,7 @@ internal class DefaultOnboardingStepperComponent @AssistedInject constructor( ) componentScope.launch { - val cardInfo = getCardInfoUseCase(params.scanResponse).getOrNull() ?: return@launch + val cardInfo = getWalletMetaInfoUseCase(params.scanResponse).getOrNull() ?: return@launch sendFeedbackEmailUseCase( if (params.scanResponse.card.isVisa) { FeedbackEmailType.Visa.Activation(cardInfo) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt index 15c3b08a41..b80bb62fda 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt @@ -18,7 +18,7 @@ import com.tangem.domain.visa.model.VisaCardActivationStatus import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest import com.tangem.domain.visa.repository.VisaActivationRepository -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.OnboardingVisaAccessCodeComponent import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.ui.state.OnboardingVisaAccessCodeUM import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent @@ -44,7 +44,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, @Suppress("UnusedPrivateMember") private val tangemSdkManager: TangemSdkManager, - private val visaAuthRepository: VisaAuthRepository, + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, private val uiMessageSender: UiMessageSender, private val analyticsEventsHandler: AnalyticsEventHandler, ) : Model() { @@ -155,7 +155,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( loading(true) modelScope.launch { - val challengeToSign = visaAuthRepository.getCardAuthChallenge( + val challengeToSign = visaAuthRemoteDataSource.getCardAuthChallenge( cardId = activationInput.cardId, cardPublicKey = activationInput.cardPublicKey, ).getOrElse { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt index e517a628cb..1786357959 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt @@ -18,7 +18,7 @@ import com.tangem.domain.visa.model.VisaAuthTokens import com.tangem.domain.visa.model.VisaCardActivationStatus import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.visa.repository.VisaActivationRepository -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.SaveWalletUseCase @@ -42,7 +42,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor( paramsContainer: ParamsContainer, visaActivationRepositoryFactory: VisaActivationRepository.Factory, override val dispatchers: CoroutineDispatcherProvider, - private val visaAuthRepository: VisaAuthRepository, + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, private val visaAuthTokenStorage: VisaAuthTokenStorage, private val otpStorage: VisaOTPStorage, private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, @@ -166,7 +166,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor( val authTokens = visaAuthTokenStorage.get(params.scanResponse.card.cardId) ?: error("Auth tokens are not found. This should not happen.") - val newTokens = visaAuthRepository.exchangeAccessToken(authTokens) + val newTokens = visaAuthRemoteDataSource.exchangeAccessToken(authTokens) .getOrElse { uiMessageSender.showErrorDialog(it) return diff --git a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt index 7bdb6d8901..3de1a19fb7 100644 --- a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt +++ b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt @@ -8,7 +8,12 @@ import com.tangem.domain.models.wallet.UserWalletId interface OnrampComponent : ComposableContentComponent { - data class Params(val userWalletId: UserWalletId, val cryptoCurrency: CryptoCurrency, val source: OnrampSource) + data class Params( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + val source: OnrampSource, + val launchSepa: Boolean = false, + ) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt new file mode 100644 index 0000000000..d2d2615c0e --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt @@ -0,0 +1,20 @@ +package com.tangem.features.onramp.alloffers + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.onramp.model.OnrampProviderWithQuote + +internal interface AllOffersComponent : ComposableBottomSheetComponent { + + data class Params( + val userWallet: UserWallet, + val cryptoCurrency: CryptoCurrency, + val onDismiss: () -> Unit, + val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, + val amountCurrencyCode: String, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt new file mode 100644 index 0000000000..12c4ec31f9 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt @@ -0,0 +1,38 @@ +package com.tangem.features.onramp.alloffers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.onramp.alloffers.model.AllOffersModel +import com.tangem.features.onramp.alloffers.ui.AllOffersContentSheet +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAllOffersComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: AllOffersComponent.Params, +) : AllOffersComponent, AppComponentContext by context { + + private val model: AllOffersModel = getOrCreateModel(params) + + override fun dismiss() { + model.dismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.state.collectAsState() + AllOffersContentSheet( + state = state, + onCloseClick = { dismiss() }, + ) + } + + @AssistedFactory + interface Factory : AllOffersComponent.Factory { + override fun create(context: AppComponentContext, params: AllOffersComponent.Params): DefaultAllOffersComponent + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModelModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModelModule.kt new file mode 100644 index 0000000000..e709ab73d6 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.onramp.alloffers.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.onramp.alloffers.model.AllOffersModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface AllOffersComponentModelModule { + + @Binds + @IntoMap + @ClassKey(AllOffersModel::class) + fun bindAllOffersModel(model: AllOffersModel): Model +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModule.kt new file mode 100644 index 0000000000..3979f343c2 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModule.kt @@ -0,0 +1,18 @@ +package com.tangem.features.onramp.alloffers.di + +import com.tangem.features.onramp.alloffers.AllOffersComponent +import com.tangem.features.onramp.alloffers.DefaultAllOffersComponent +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 AllOffersComponentModule { + + @Binds + @Singleton + fun bindAllOffersComponentFactory(factory: DefaultAllOffersComponent.Factory): AllOffersComponent.Factory +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersIntents.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersIntents.kt new file mode 100644 index 0000000000..2f3739e450 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersIntents.kt @@ -0,0 +1,15 @@ +package com.tangem.features.onramp.alloffers.entity + +import com.tangem.domain.onramp.model.OnrampProviderWithQuote +import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM + +internal interface AllOffersIntents { + + fun onPaymentMethodClicked(paymentMethodId: String) + + fun onBuyClick(quote: OnrampProviderWithQuote.Data, onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM) + + fun onBackClicked() + + fun onRefresh() +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt new file mode 100644 index 0000000000..351cd1a5aa --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt @@ -0,0 +1,159 @@ +package com.tangem.features.onramp.alloffers.entity + +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent +import com.tangem.domain.onramp.model.* +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM +import com.tangem.utils.Provider +import com.tangem.utils.StringsSigns.MINUS +import kotlinx.collections.immutable.toPersistentList +import java.math.BigDecimal + +internal class AllOffersStateFactory( + private val analyticsEventHandler: AnalyticsEventHandler, + private val currentStateProvider: Provider, + private val allOffersIntents: AllOffersIntents, +) { + + fun getLoadedPaymentsState(methodGroups: List): AllOffersStateUM { + return AllOffersStateUM.Content( + methods = methodGroups.map { methodGroup -> + AllOffersPaymentMethodUM( + offers = mapOffersToUM(methodGroup.offers).toPersistentList(), + methodConfig = OnrampPaymentMethodConfig( + method = methodGroup.paymentMethod, + onClick = { allOffersIntents.onPaymentMethodClicked(methodGroup.paymentMethod.id) }, + ), + diff = methodGroup + .bestRateOffer + ?.rateDif + ?.takeIf { it > BigDecimal.ZERO } + ?.let { diff -> + stringReference("$MINUS${diff.format { percent() }}") + }, + rate = methodGroup.bestRateOffer?.let { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote.toAmount.value.format { + crypto( + symbol = quote.toAmount.symbol, + decimals = quote.toAmount.decimals, + ) + } + else -> "" + } + } ?: "", + providersCount = methodGroup.providerCount, + isBestRate = methodGroup.isBestPaymentMethod, + ) + }.toPersistentList(), + currentMethod = null, + onBackClicked = { allOffersIntents.onBackClicked() }, + ) + } + + fun getPaymentsState(): AllOffersStateUM { + return when (val currentState = currentStateProvider.invoke()) { + is AllOffersStateUM.Content -> { + analyticsEventHandler.send(OnrampAnalyticsEvent.PaymentMethodsScreenOpened) + currentState.copy(currentMethod = null) + } + AllOffersStateUM.Loading, + is AllOffersStateUM.Error, + -> currentState + } + } + + fun getOnrampErrorState(onrampError: OnrampError): AllOffersStateUM { + return when (onrampError) { + is OnrampError.DataError -> getErrorState( + errorCode = onrampError.code, + onRefresh = allOffersIntents::onRefresh, + ) + OnrampError.PairsNotFound, + is OnrampError.DomainError, + -> getErrorState(onRefresh = allOffersIntents::onRefresh) + is OnrampError.AmountError.TooBigError, + is OnrampError.AmountError.TooSmallError, + OnrampError.RedirectError.VerificationFailed, + OnrampError.RedirectError.WrongRequestId, + -> currentStateProvider() + } + } + + private fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): AllOffersStateUM { + val state = currentStateProvider() + return when (state) { + is AllOffersStateUM.Content, + AllOffersStateUM.Loading, + -> AllOffersStateUM.Error( + errorNotification = NotificationUM.Warning.OnrampErrorNotification( + errorCode = errorCode, + onRefresh = onRefresh, + ), + ) + is AllOffersStateUM.Error -> state + } + } + + private fun mapOfferAdvantagesDTOtoUM(advantages: OnrampOfferAdvantages): OnrampOfferAdvantagesUM { + return when (advantages) { + OnrampOfferAdvantages.Default -> OnrampOfferAdvantagesUM.Default + OnrampOfferAdvantages.BestRate -> OnrampOfferAdvantagesUM.BestRate + OnrampOfferAdvantages.Fastest -> OnrampOfferAdvantagesUM.Fastest + } + } + + private fun mapOffersToUM(offers: List): List { + return buildList { + offers.forEach { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> { + add( + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = mapOfferAdvantagesDTOtoUM(offer.advantages), + paymentMethod = quote.paymentMethod, + providerId = quote.provider.id, + providerName = quote.provider.info.name, + rate = quote.toAmount.value.format { + crypto( + symbol = quote.toAmount.symbol, + decimals = quote.toAmount.decimals, + ) + }, + diff = offer + .rateDif + ?.takeIf { it > BigDecimal.ZERO } + ?.let { diff -> + stringReference("$MINUS${diff.format { percent() }}") + }, + onBuyClicked = { + allOffersIntents.onBuyClick( + quote = OnrampProviderWithQuote.Data( + provider = quote.provider, + paymentMethod = quote.paymentMethod, + toAmount = quote.toAmount, + fromAmount = quote.fromAmount, + ), + onrampOfferAdvantagesUM = mapOfferAdvantagesDTOtoUM(offer.advantages), + ) + }, + ), + ) + } + is OnrampQuote.AmountError, + is OnrampQuote.Error, + -> Unit + } + } + } + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt new file mode 100644 index 0000000000..834bc396b3 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt @@ -0,0 +1,34 @@ +package com.tangem.features.onramp.alloffers.entity + +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM +import kotlinx.collections.immutable.ImmutableList + +internal sealed interface AllOffersStateUM { + + data object Loading : AllOffersStateUM + + data class Content( + val methods: ImmutableList, + val currentMethod: AllOffersPaymentMethodUM? = null, + val onBackClicked: () -> Unit, + ) : AllOffersStateUM + + data class Error(val errorNotification: NotificationUM) : AllOffersStateUM +} + +internal data class AllOffersPaymentMethodUM( + val offers: ImmutableList, + val methodConfig: OnrampPaymentMethodConfig, + val diff: TextReference?, + val rate: String, + val providersCount: Int, + val isBestRate: Boolean, +) + +internal data class OnrampPaymentMethodConfig( + val method: OnrampPaymentMethod, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt new file mode 100644 index 0000000000..29414b13c0 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt @@ -0,0 +1,109 @@ +package com.tangem.features.onramp.alloffers.model + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.onramp.GetOnrampAllOffersUseCase +import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent +import com.tangem.domain.onramp.model.OnrampProviderWithQuote +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.features.onramp.alloffers.AllOffersComponent +import com.tangem.features.onramp.alloffers.entity.AllOffersIntents +import com.tangem.features.onramp.alloffers.entity.AllOffersStateFactory +import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +internal class AllOffersModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, + private val getOnrampAllOffersUseCase: GetOnrampAllOffersUseCase, + paramsContainer: ParamsContainer, +) : Model(), AllOffersIntents { + + private var quotesJob: Job? = null + + private val stateFactory: AllOffersStateFactory by lazy(LazyThreadSafetyMode.NONE) { + AllOffersStateFactory( + analyticsEventHandler = analyticsEventHandler, + currentStateProvider = Provider { state.value }, + allOffersIntents = this, + ) + } + + private val params: AllOffersComponent.Params = paramsContainer.require() + + private val _state: MutableStateFlow = MutableStateFlow(AllOffersStateUM.Loading) + val state: StateFlow = _state.asStateFlow() + + init { + subscribeOnAllOffers() + analyticsEventHandler.send(OnrampAnalyticsEvent.PaymentMethodsScreenOpened) + analyticsEventHandler.send(OnrampAnalyticsEvent.AllOffersClicked) + } + + fun dismiss() { + params.onDismiss() + } + + override fun onPaymentMethodClicked(paymentMethodId: String) { + val contentState = state.value as? AllOffersStateUM.Content ?: return + val method = contentState.methods.firstOrNull { it.methodConfig.method.id == paymentMethodId } ?: return + analyticsEventHandler.send( + event = OnrampAnalyticsEvent.OnPaymentMethodChosen(paymentMethod = method.methodConfig.method.name), + ) + _state.update { contentState.copy(currentMethod = method) } + } + + override fun onBuyClick(quote: OnrampProviderWithQuote.Data, onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM) { + analyticsEventHandler.send( + OnrampAnalyticsEvent.OnBuyClick( + providerName = quote.provider.info.name, + currency = params.amountCurrencyCode, + tokenSymbol = params.cryptoCurrency.symbol, + ), + ) + onrampOfferAdvantagesUM.toAnalyticsEvent( + cryptoCurrencySymbol = params.cryptoCurrency.symbol, + providerName = quote.provider.info.name, + paymentMethodName = quote.paymentMethod.name, + )?.let { analyticsEventHandler::send } + params.openRedirectPage(quote) + } + + override fun onBackClicked() { + _state.update { stateFactory.getPaymentsState() } + } + + override fun onRefresh() { + subscribeOnAllOffers() + } + + private fun subscribeOnAllOffers() { + quotesJob?.cancel() + quotesJob = modelScope.launch(dispatchers.default) { + getOnrampAllOffersUseCase.invoke( + userWalletId = params.userWallet.walletId, + cryptoCurrencyId = params.cryptoCurrency.id, + ).collectLatest { maybeOffers -> + maybeOffers.fold( + ifLeft = ::handleOnrampError, + ifRight = { offersGroup -> + _state.update { stateFactory.getLoadedPaymentsState(offersGroup) } + }, + ) + } + } + } + + private fun handleOnrampError(onrampError: OnrampError) { + Timber.e(onrampError.toString()) + _state.update { stateFactory.getOnrampErrorState(onrampError) } + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt new file mode 100644 index 0000000000..f60660546a --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt @@ -0,0 +1,322 @@ +package com.tangem.features.onramp.alloffers.ui + +import android.content.res.Configuration +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerH +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.notifications.Notification +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.domain.onramp.model.PaymentMethodType +import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM +import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM +import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM +import com.tangem.features.onramp.mainv2.ui.Offer +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +@Composable +internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () -> Unit) { + val onBack = remember(state) { + { + if (state is AllOffersStateUM.Content && state.currentMethod != null) { + state.onBackClicked() + } else { + onCloseClick() + } + } + } + + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onCloseClick, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = onBack, + containerColor = TangemTheme.colors.background.tertiary, + title = { + if (state is AllOffersStateUM.Content && state.currentMethod != null) { + ProviderTitle( + onCloseClick = onCloseClick, + onBackClick = onBack, + ) + } else { + PaymentMethodTitle(onCloseClick = onCloseClick) + } + }, + content = { + Box( + modifier = Modifier + .fillMaxSize() + .padding(vertical = 8.dp) + .animateContentSize(), + ) { + AnimatedContent( + targetState = state is AllOffersStateUM.Content && state.currentMethod != null, + transitionSpec = { + fadeIn(tween(durationMillis = 220)) togetherWith + fadeOut(tween(durationMillis = 220)) + }, + label = "Change offers and payment method state", + ) { shouldShowOffersScreen -> + when (state) { + AllOffersStateUM.Loading -> AllOffersContentLoading() + is AllOffersStateUM.Error -> AllOffersError(state.errorNotification) + is AllOffersStateUM.Content -> { + if (shouldShowOffersScreen) { + state.currentMethod?.let { + OffersBasedOnPaymentMethodContent(offers = it.offers) + } + } else { + PaymentMethodsContent(methods = state.methods) + } + } + } + } + } + }, + ) +} + +@Composable +private fun ProviderTitle(onBackClick: () -> Unit, onCloseClick: () -> Unit) { + TangemModalBottomSheetTitle( + title = TextReference.Res(R.string.onramp_all_offers_button_title), + subtitle = TextReference.Res(R.string.express_choose_providers_title), + startIconRes = R.drawable.ic_back_24, + onStartClick = onBackClick, + endIconRes = com.tangem.core.ui.R.drawable.ic_close_24, + onEndClick = onCloseClick, + ) +} + +@Composable +private fun PaymentMethodTitle(onCloseClick: () -> Unit) { + TangemModalBottomSheetTitle( + title = TextReference.Res(R.string.onramp_all_offers_button_title), + subtitle = TextReference.Res(R.string.onramp_payment_method_subtitle), + endIconRes = com.tangem.core.ui.R.drawable.ic_close_24, + onEndClick = onCloseClick, + ) +} + +@Composable +private fun OffersBasedOnPaymentMethodContent(offers: ImmutableList) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + offers.fastForEach { offer -> + key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") { + Offer(offer) + SpacerH(8.dp) + } + } + } +} + +@Composable +private fun AllOffersContentLoading() { + Column( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors.background.tertiary) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RectangleShimmer( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + .height(96.dp) + .size(width = 76.dp, height = 20.dp), + radius = 14.dp, + ) + RectangleShimmer( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + .height(96.dp) + .size(width = 76.dp, height = 20.dp), + radius = 14.dp, + ) + RectangleShimmer( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + .height(96.dp) + .size(width = 76.dp, height = 20.dp), + radius = 14.dp, + ) + } +} + +@Composable +fun AllOffersError(errorNotification: NotificationUM) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Notification(config = errorNotification.config) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AllOffersContentSheetPaymentPreview() { + val method = AllOffersPaymentMethodUM( + offers = persistentListOf( + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.BestRate, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId1", + providerName = "Simplex", + rate = "0,0245334 BTC", + diff = null, + onBuyClicked = {}, + ), + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.Default, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId2", + providerName = "Simplex", + rate = "0,00145334 BTC", + diff = stringReference("–0.07%"), + onBuyClicked = {}, + ), + ), + methodConfig = OnrampPaymentMethodConfig( + method = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + onClick = {}, + ), + diff = null, + rate = "0,0245334 BTC", + providersCount = 2, + isBestRate = true, + ) + + TangemThemePreview { + AllOffersContentSheet( + state = AllOffersStateUM.Content( + methods = persistentListOf(), + currentMethod = method, + onBackClicked = {}, + ), + onCloseClick = {}, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AllOffersContentSheetOffersPreview() { + val methods = List(5) { + AllOffersPaymentMethodUM( + offers = persistentListOf( + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.BestRate, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = + "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId1", + providerName = "Simplex", + rate = "0,0245334 BTC", + diff = null, + onBuyClicked = {}, + ), + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.Default, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = + "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId2", + providerName = "Simplex", + rate = "0,00145334 BTC", + diff = stringReference("–0.07%"), + onBuyClicked = {}, + ), + ), + methodConfig = OnrampPaymentMethodConfig( + method = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + onClick = {}, + ), + diff = null, + rate = "0,0245334 BTC", + providersCount = 2, + isBestRate = true, + ) + } + + TangemThemePreview { + AllOffersContentSheet( + state = AllOffersStateUM.Content( + methods = methods.toPersistentList(), + currentMethod = null, + onBackClicked = {}, + ), + onCloseClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt new file mode 100644 index 0000000000..49d457c4b2 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt @@ -0,0 +1,262 @@ +package com.tangem.features.onramp.alloffers.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerW +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 +import com.tangem.domain.onramp.model.PaymentMethodType +import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM +import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM +import com.tangem.features.onramp.mainv2.ui.TimingBlock +import com.tangem.features.onramp.paymentmethod.ui.PaymentMethodIcon +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun PaymentMethodsContent(methods: ImmutableList) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + methods.fastForEach { method -> + key(method.methodConfig.method.id) { + PaymentMethod(methodUM = method) + SpacerH(8.dp) + } + } + } +} + +@Composable +private fun PaymentMethod(methodUM: AllOffersPaymentMethodUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.action, + shape = RoundedCornerShape(14.dp), + ) + .clickable( + indication = ripple(), + interactionSource = remember { MutableInteractionSource() }, + onClick = methodUM.methodConfig.onClick, + ) + .padding( + start = 12.dp, + end = 12.dp, + top = 14.dp, + bottom = 12.dp, + ), + ) { + PaymentMethodIcon( + modifier = Modifier.size(36.dp), + imageUrl = methodUM.methodConfig.method.imageUrl, + ) + + SpacerW(12.dp) + + Column { + PaymentMethodInfoBlock( + paymentMethodName = methodUM.methodConfig.method.name, + rate = methodUM.rate, + diff = methodUM.diff, + isBestRate = methodUM.isBestRate, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + ProvidersCountBlockInfo(providersCount = methodUM.providersCount) + SpacerW(8.dp) + TimingBlockInfo(speed = methodUM.methodConfig.method.type.getProcessingSpeed()) + } + } + } +} + +@Composable +private fun PaymentMethodInfoBlock( + paymentMethodName: String, + rate: String, + diff: TextReference?, + isBestRate: Boolean, +) { + Column(modifier = Modifier.padding(bottom = 14.dp)) { + Text( + text = paymentMethodName, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + SpacerH(2.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResourceSafe(R.string.onramp_up_to_rate), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + + Text( + text = rate, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + + when { + isBestRate -> { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_best_rate_12), + contentDescription = null, + ) + } + diff != null -> { + Text( + modifier = Modifier + .background( + color = TangemTheme.colors.text.warning.copy(alpha = 0.1f), + shape = RoundedCornerShape(4.dp), + ) + .padding(horizontal = 4.dp), + text = diff.resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.warning, + ) + } + } + } + } +} + +@Composable +private fun ProvidersCountBlockInfo(providersCount: Int) { + BorderedRow { + Icon( + modifier = Modifier.size(10.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_clock_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + SpacerW(4.dp) + Text( + text = pluralStringResourceSafe( + id = R.plurals.onramp_providers_count, + count = providersCount, + providersCount, + ), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +@Composable +private fun TimingBlockInfo(speed: PaymentMethodType.PaymentSpeed) { + BorderedRow { + Icon( + modifier = Modifier.size(10.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_staking_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + SpacerW(4.dp) + TimingBlock(speed) + } +} + +@Composable +fun BorderedRow(content: @Composable RowScope.() -> Unit) { + Row( + modifier = Modifier + .border( + width = 1.dp, + color = TangemTheme.colors.stroke.primary, + shape = RoundedCornerShape(6.dp), + ) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } +} + +@Preview +@Composable +private fun PaymentMethodsContentPreview() { + val method = AllOffersPaymentMethodUM( + offers = persistentListOf( + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.BestRate, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId1", + providerName = "Simplex", + rate = "0,0245334 BTC", + diff = null, + onBuyClicked = {}, + ), + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.Default, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId2", + providerName = "Simplex", + rate = "0,00145334 BTC", + diff = stringReference("–0.07%"), + onBuyClicked = {}, + ), + ), + methodConfig = OnrampPaymentMethodConfig( + method = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + onClick = {}, + ), + diff = null, + rate = "0,0245334 BTC", + providersCount = 2, + isBestRate = true, + ) + TangemThemePreview { + PaymentMethodsContent(persistentListOf(method)) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddToPortfolioUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddToPortfolioUM.kt index 3e61a19e9a..709d679db3 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddToPortfolioUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddToPortfolioUM.kt @@ -37,7 +37,11 @@ data class OnrampAddToPortfolioUM( formatArgs = wrappedList(networkName), ) - data class AddButtonUM(val isProgress: Boolean, val onClick: () -> Unit) { + data class AddButtonUM( + val isProgress: Boolean, + val isTangemIconVisible: Boolean, + val onClick: () -> Unit, + ) { val text: TextReference = resourceReference(R.string.common_add_to_portfolio) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt index 63b2225169..f3c1b1fcd9 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt @@ -49,10 +49,20 @@ internal class OnrampAddToPortfolioModel @Inject constructor( currencyName = params.cryptoCurrency.name, networkName = params.cryptoCurrency.network.name, currencyIconState = params.currencyIconState, - addButtonUM = OnrampAddToPortfolioUM.AddButtonUM(isProgress = false, onClick = ::onAddClick), + addButtonUM = OnrampAddToPortfolioUM.AddButtonUM( + isProgress = false, + isTangemIconVisible = isTangemIconVisible(), + onClick = ::onAddClick, + ), ) } + private fun isTangemIconVisible(): Boolean { + return getUserWalletUseCase(params.userWalletId) + .onLeft { Timber.e("Unable to get wallet by id [${params.userWalletId}]: $it") } + .fold(ifLeft = { false }, ifRight = { it is UserWallet.Cold }) + } + private fun getUserWalletName(): String { return getUserWalletUseCase(params.userWalletId) .onLeft { Timber.e("Unable to get wallet name by id [${params.userWalletId}]: $it") } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/ui/OnrampAddToPortfolioContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/ui/OnrampAddToPortfolioContent.kt index 4666c4e5b7..b507a11e81 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/ui/OnrampAddToPortfolioContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/ui/OnrampAddToPortfolioContent.kt @@ -149,7 +149,11 @@ private fun PreviewOnrampAddToPortfolioContent() { isGrayscale = false, showCustomBadge = false, ), - addButtonUM = OnrampAddToPortfolioUM.AddButtonUM(isProgress = false, onClick = {}), + addButtonUM = OnrampAddToPortfolioUM.AddButtonUM( + isProgress = false, + onClick = {}, + isTangemIconVisible = true, + ), ), ) }, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt index 57c298a510..fa5cf89457 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt @@ -15,6 +15,7 @@ internal interface OnrampMainComponent : ComposableContentComponent { val source: OnrampSource, val openSettings: () -> Unit, val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, + val launchSepa: Boolean, ) interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt index 8eb611e8fe..bfd3db959d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt @@ -148,4 +148,8 @@ internal class OnrampStateFactory( decimals = currency.precision, type = AmountType.FiatType(currency.code), ) + + companion object { + const val PREDEFINED_SEPA_AMOUNT = "100" + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index 97f9d147c7..832304f55a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -24,10 +24,12 @@ import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.onramp.model.OnrampCurrency import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.onramp.main.OnrampMainComponent import com.tangem.features.onramp.main.entity.* import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory +import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory.Companion.PREDEFINED_SEPA_AMOUNT import com.tangem.features.onramp.main.entity.factory.amount.OnrampAmountStateFactory import com.tangem.features.onramp.providers.entity.SelectProviderResult import com.tangem.features.onramp.utils.sendOnrampErrorEvent @@ -54,6 +56,8 @@ internal class OnrampMainComponentModel @Inject constructor( private val fetchQuotesUseCase: OnrampFetchQuotesUseCase, private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase, private val fetchPairsUseCase: OnrampFetchPairsUseCase, + private val onrampSaveDefaultCurrencyUseCase: OnrampSaveDefaultCurrencyUseCase, + private val onrampGetDefaultCurrencyUseCase: OnrampGetDefaultCurrencyUseCase, private val amountInputManager: InputManager, private val messageSender: UiMessageSender, private val urlOpener: UrlOpener, @@ -64,6 +68,9 @@ internal class OnrampMainComponentModel @Inject constructor( private val params: OnrampMainComponent.Params = paramsContainer.require() + private var isSepaLaunched = false + private var currencyToRestore: OnrampCurrency? = null + val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } private val stateFactory = OnrampStateFactory( @@ -97,6 +104,11 @@ internal class OnrampMainComponentModel @Inject constructor( modelScope.launch { clearOnrampCacheUseCase() + + if (params.launchSepa) { + currencyToRestore = onrampGetDefaultCurrencyUseCase.invoke().getOrNull() + onrampSaveDefaultCurrencyUseCase.invoke(EUR_CURRENCY) + } } sendScreenOpenAnalytics() @@ -146,6 +158,8 @@ internal class OnrampMainComponentModel @Inject constructor( ifLeft = ::handleOnrampError, ifRight = { country -> if (country == null) return@onEach + + val wasInitialLoading = _state.value is OnrampMainComponentUM.InitialLoading _state.update { if (it is OnrampMainComponentUM.InitialLoading) { stateFactory.getReadyState(country.defaultCurrency) @@ -153,7 +167,12 @@ internal class OnrampMainComponentModel @Inject constructor( amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency) } } + updatePairsAndQuotes() + + if (wasInitialLoading && params.launchSepa) { + onAmountValueChanged(PREDEFINED_SEPA_AMOUNT) + } }, ) } @@ -284,6 +303,13 @@ internal class OnrampMainComponentModel @Inject constructor( override fun onDestroy() { modelScope.launch { clearOnrampCacheUseCase.invoke() } quotesTaskScheduler.cancelTask() + + modelScope.launch { + if (params.launchSepa) { + currencyToRestore?.let { onrampSaveDefaultCurrencyUseCase.invoke(it) } + } + } + super.onDestroy() } @@ -315,8 +341,18 @@ internal class OnrampMainComponentModel @Inject constructor( private fun selectOrUpdateQuote(quotes: List): OnrampQuote? { val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error } + val sepaQuote = if (params.launchSepa && !isSepaLaunched) { + isSepaLaunched = true + + quotes.filterIsInstance() + .filter { it.paymentMethod.id == SEPA_METHOD_ID } + .maxByOrNull { it.toAmount.value } + } else { + null + } + // Check if amount, country or currency has changed - val newQuote = if (checkLastInputState(quoteToCheck)) { + val newQuote = sepaQuote ?: if (checkLastInputState(quoteToCheck)) { quoteToCheck } else { val state = state.value as? OnrampMainComponentUM.Content @@ -418,5 +454,15 @@ internal class OnrampMainComponentModel @Inject constructor( private companion object { const val UPDATE_DELAY = 10_000L + + const val SEPA_METHOD_ID = "sepa" + + val EUR_CURRENCY = OnrampCurrency( + code = "EUR", + name = "Euro", + unit = "€", + precision = 2, + image = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/Currencies/EUR.png", + ) } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt new file mode 100644 index 0000000000..af62d7ecbe --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt @@ -0,0 +1,91 @@ +package com.tangem.features.onramp.mainv2 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.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.onramp.alloffers.AllOffersComponent +import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent +import com.tangem.features.onramp.mainv2.entity.OnrampV2MainBottomSheetConfig +import com.tangem.features.onramp.mainv2.model.OnrampV2MainComponentModel +import com.tangem.features.onramp.mainv2.ui.OnrampNewMainScreen +import com.tangem.features.onramp.selectcurrency.SelectCurrencyComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultOnrampV2MainComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: OnrampV2MainComponent.Params, + private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory, + private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory, + private val allOffersComponentFactory: AllOffersComponent.Factory, +) : OnrampV2MainComponent, AppComponentContext by appComponentContext { + + private val model: OnrampV2MainComponentModel = getOrCreateModel(params) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = null, + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsState() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + OnrampNewMainScreen(modifier = modifier, state = state) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: OnrampV2MainBottomSheetConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = when (config) { + is OnrampV2MainBottomSheetConfig.ConfirmResidency -> confirmResidencyComponentFactory.create( + context = childByContext(componentContext), + params = ConfirmResidencyComponent.Params( + userWalletId = params.userWalletId, + cryptoCurrency = params.cryptoCurrency, + country = config.country, + onDismiss = { model.bottomSheetNavigation.dismiss() }, + ), + ) + is OnrampV2MainBottomSheetConfig.CurrenciesList -> selectCurrencyComponentFactory.create( + context = childByContext(componentContext), + params = SelectCurrencyComponent.Params( + userWallet = model.userWallet, + cryptoCurrency = params.cryptoCurrency, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + is OnrampV2MainBottomSheetConfig.AllOffers -> allOffersComponentFactory.create( + context = childByContext(componentContext), + params = AllOffersComponent.Params( + userWallet = model.userWallet, + cryptoCurrency = params.cryptoCurrency, + onDismiss = model.bottomSheetNavigation::dismiss, + openRedirectPage = params.openRedirectPage, + amountCurrencyCode = config.amountCurrencyCode, + ), + ) + } + + @AssistedFactory + interface Factory : OnrampV2MainComponent.Factory { + override fun create( + context: AppComponentContext, + params: OnrampV2MainComponent.Params, + ): DefaultOnrampV2MainComponent + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainFeatureToggle.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainFeatureToggle.kt new file mode 100644 index 0000000000..815fa5060b --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainFeatureToggle.kt @@ -0,0 +1,10 @@ +package com.tangem.features.onramp.mainv2 + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager + +class DefaultOnrampV2MainFeatureToggle( + private val featureTogglesManager: FeatureTogglesManager, +) : OnrampV2MainFeatureToggle { + override val isOnrampNewMainEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("NEW_ONRAMP_MAIN_ENABLED") +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainComponent.kt new file mode 100644 index 0000000000..9767cf4496 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainComponent.kt @@ -0,0 +1,21 @@ +package com.tangem.features.onramp.mainv2 + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.OnrampProviderWithQuote +import com.tangem.domain.onramp.model.OnrampSource + +internal interface OnrampV2MainComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + val source: OnrampSource, + val openSettings: () -> Unit, + val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainFeatureToggle.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainFeatureToggle.kt new file mode 100644 index 0000000000..54595ff8d7 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainFeatureToggle.kt @@ -0,0 +1,5 @@ +package com.tangem.features.onramp.mainv2 + +internal interface OnrampV2MainFeatureToggle { + val isOnrampNewMainEnabled: Boolean +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt new file mode 100644 index 0000000000..84fb039ffd --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.onramp.mainv2.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.onramp.mainv2.model.OnrampV2MainComponentModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface OnrampMainV2ComponentModelModule { + + @Binds + @IntoMap + @ClassKey(OnrampV2MainComponentModel::class) + fun bindOnrampV2MainComponentModel(model: OnrampV2MainComponentModel): Model +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampNewV2ComponentModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampNewV2ComponentModule.kt new file mode 100644 index 0000000000..08817d31ac --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampNewV2ComponentModule.kt @@ -0,0 +1,33 @@ +package com.tangem.features.onramp.mainv2.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.onramp.mainv2.DefaultOnrampV2MainComponent +import com.tangem.features.onramp.mainv2.DefaultOnrampV2MainFeatureToggle +import com.tangem.features.onramp.mainv2.OnrampV2MainComponent +import com.tangem.features.onramp.mainv2.OnrampV2MainFeatureToggle +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface OnrampNewMainComponentModule { + + @Binds + @Singleton + fun bindOnrampV2MainComponentFactory(factory: DefaultOnrampV2MainComponent.Factory): OnrampV2MainComponent.Factory +} + +@Module +@InstallIn(SingletonComponent::class) +internal object FeatureToggleModule { + + @Provides + @Singleton + fun provideOnrampV2MainFeatureToggle(featureTogglesManager: FeatureTogglesManager): OnrampV2MainFeatureToggle { + return DefaultOnrampV2MainFeatureToggle(featureTogglesManager = featureTogglesManager) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt new file mode 100644 index 0000000000..54ea1f1451 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt @@ -0,0 +1,73 @@ +package com.tangem.features.onramp.mainv2.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent +import com.tangem.domain.onramp.model.OnrampPaymentMethod +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed interface OnrampOffersBlockUM { + + val isBlockVisible: Boolean + + data object Empty : OnrampOffersBlockUM { + override val isBlockVisible: Boolean + get() = false + } + + data class Loading( + override val isBlockVisible: Boolean, + ) : OnrampOffersBlockUM + + data class Content( + override val isBlockVisible: Boolean, + val recentOffer: OnrampOfferUM?, + val recommended: ImmutableList, + val onrampAllOffersButtonConfig: OnrampAllOffersButtonConfig?, + ) : OnrampOffersBlockUM +} + +internal data class OnrampOfferUM( + val category: OnrampOfferCategoryUM, + val advantages: OnrampOfferAdvantagesUM, + val paymentMethod: OnrampPaymentMethod, + val providerId: String, + val providerName: String, + val rate: String, + val diff: TextReference?, + val onBuyClicked: () -> Unit, +) + +internal enum class OnrampOfferCategoryUM { + RecentlyUsed, Recommended +} + +internal enum class OnrampOfferAdvantagesUM { + Default, BestRate, Fastest; + + fun toAnalyticsEvent( + cryptoCurrencySymbol: String, + providerName: String, + paymentMethodName: String, + ): OnrampAnalyticsEvent? { + return when (this) { + BestRate -> OnrampAnalyticsEvent.BestRateClicked( + tokenSymbol = cryptoCurrencySymbol, + providerName = providerName, + paymentMethod = paymentMethodName, + ) + Fastest -> OnrampAnalyticsEvent.FastestBuyMethodClicked( + tokenSymbol = cryptoCurrencySymbol, + providerName = providerName, + paymentMethod = paymentMethodName, + ) + Default -> null + } + } +} + +internal data class OnrampAllOffersButtonConfig( + val title: TextReference, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt new file mode 100644 index 0000000000..b151b7818c --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt @@ -0,0 +1,38 @@ +package com.tangem.features.onramp.mainv2.entity + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.amountScreen.models.AmountFieldModel +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +internal data class OnrampNewAmountBlockUM( + val currencyUM: OnrampNewCurrencyUM, + val amountFieldModel: AmountFieldModel, + val secondaryFieldModel: OnrampNewAmountSecondaryFieldUM, +) + +internal data class OnrampNewCurrencyUM( + val unit: String, + val code: String, + val iconUrl: String?, + val precision: Int, + val onClick: () -> Unit, +) + +@Immutable +internal sealed interface OnrampNewAmountSecondaryFieldUM { + data object Loading : OnrampNewAmountSecondaryFieldUM + data class Content(val amount: TextReference) : OnrampNewAmountSecondaryFieldUM + data class Error(val error: TextReference) : OnrampNewAmountSecondaryFieldUM +} + +internal sealed interface OnrampV2AmountButtonUMState { + data class Loaded(val amountButtons: ImmutableList) : OnrampV2AmountButtonUMState + data object None : OnrampV2AmountButtonUMState +} + +internal data class OnrampAmountButtonUM( + val value: Int, + val currency: String, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2Intents.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2Intents.kt new file mode 100644 index 0000000000..a9f65922cd --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2Intents.kt @@ -0,0 +1,13 @@ +package com.tangem.features.onramp.mainv2.entity + +import com.tangem.domain.onramp.model.OnrampProviderWithQuote + +internal interface OnrampV2Intents { + fun onAmountValueChanged(value: String) + fun openSettings() + fun openCurrenciesList() + fun onBuyClick(quote: OnrampProviderWithQuote.Data, onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM) + fun openProviders() + fun onRefresh() + fun onContinueClick() +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt new file mode 100644 index 0000000000..afc654a422 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt @@ -0,0 +1,16 @@ +package com.tangem.features.onramp.mainv2.entity + +import com.tangem.domain.onramp.model.OnrampCountry +import kotlinx.serialization.Serializable + +@Serializable +sealed interface OnrampV2MainBottomSheetConfig { + @Serializable + data class ConfirmResidency(val country: OnrampCountry) : OnrampV2MainBottomSheetConfig + + @Serializable + data object CurrenciesList : OnrampV2MainBottomSheetConfig + + @Serializable + data class AllOffers(val amountCurrencyCode: String) : OnrampV2MainBottomSheetConfig +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt new file mode 100644 index 0000000000..0b6560e380 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt @@ -0,0 +1,43 @@ +package com.tangem.features.onramp.mainv2.entity + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal sealed interface OnrampV2MainComponentUM { + + val topBarConfig: OnrampV2MainTopBarUM + val continueButtonConfig: ContinueButtonUM + val errorNotification: NotificationUM? + + data class InitialLoading( + override val topBarConfig: OnrampV2MainTopBarUM, + override val continueButtonConfig: ContinueButtonUM, + override val errorNotification: NotificationUM?, + ) : OnrampV2MainComponentUM + + data class Content( + override val topBarConfig: OnrampV2MainTopBarUM, + override val continueButtonConfig: ContinueButtonUM, + override val errorNotification: NotificationUM?, + val amountBlockState: OnrampNewAmountBlockUM, + val offersBlockState: OnrampOffersBlockUM, + val onrampAmountButtonUMState: OnrampV2AmountButtonUMState, + val onrampProviderState: OnrampV2ProvidersUM, + ) : OnrampV2MainComponentUM +} + +internal data class ContinueButtonUM( + val text: TextReference, + val onClick: () -> Unit, + val enabled: Boolean, + val showProgress: Boolean = false, +) + +internal data class OnrampV2MainTopBarUM( + val title: TextReference, + val startButtonUM: TopAppBarButtonUM, + val endButtonUM: TopAppBarButtonUM, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2ProvidersUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2ProvidersUM.kt new file mode 100644 index 0000000000..750e9ba8f3 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2ProvidersUM.kt @@ -0,0 +1,15 @@ +package com.tangem.features.onramp.mainv2.entity + +import com.tangem.domain.onramp.model.OnrampPaymentMethod + +sealed interface OnrampV2ProvidersUM { + + data object Empty : OnrampV2ProvidersUM + + data object Loading : OnrampV2ProvidersUM + + data class Content( + val providerId: String, + val paymentMethod: OnrampPaymentMethod, + ) : OnrampV2ProvidersUM +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt new file mode 100644 index 0000000000..ec94be047d --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt @@ -0,0 +1,84 @@ +package com.tangem.features.onramp.mainv2.entity.converter + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.parseBigDecimalOrNull +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.onramp.mainv2.entity.* +import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter +import java.math.BigDecimal + +internal class OnrampV2AmountFieldChangeConverter( + private val currentStateProvider: Provider, + private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory, + private val onrampIntents: OnrampV2Intents, + private val cryptoCurrency: CryptoCurrency, +) : Converter { + + override fun convert(value: String): OnrampV2MainComponentUM { + val state = currentStateProvider() + if (state !is OnrampV2MainComponentUM.Content) return state + + if (value.isEmpty()) return state.emptyState() + + val amountState = state.amountBlockState + val amountTextField = amountState.amountFieldModel + val fiatDecimal = value.parseBigDecimalOrNull() ?: BigDecimal.ZERO + val amountFieldModel = amountState.amountFieldModel.copy( + fiatValue = value, + fiatAmount = amountTextField.fiatAmount.copy(value = fiatDecimal), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + + return state.copy( + amountBlockState = amountState.copy( + amountFieldModel = amountFieldModel, + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading, + ), + continueButtonConfig = state.continueButtonConfig.copy(enabled = false), + onrampProviderState = OnrampV2ProvidersUM.Loading, + onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, + offersBlockState = OnrampOffersBlockUM.Empty, + ) + } + + private fun OnrampV2MainComponentUM.Content.emptyState(): OnrampV2MainComponentUM.Content { + val amountFieldModel = amountBlockState.amountFieldModel.copy( + value = "", + fiatValue = "", + cryptoAmount = amountBlockState.amountFieldModel.cryptoAmount.copy(value = BigDecimal.ZERO), + fiatAmount = amountBlockState.amountFieldModel.fiatAmount.copy(value = BigDecimal.ZERO), + isError = false, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.None, + keyboardType = KeyboardType.Number, + ), + ) + return copy( + amountBlockState = amountBlockState.copy( + amountFieldModel = amountFieldModel, + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content( + stringReference( + BigDecimal.ZERO.format { + crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) + }, + ), + ), + ), + continueButtonConfig = continueButtonConfig.copy(enabled = false), + offersBlockState = OnrampOffersBlockUM.Empty, + onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton( + currencySymbol = amountBlockState.currencyUM.unit, + currencyCode = amountBlockState.currencyUM.code, + onAmountValueChanged = onrampIntents::onAmountValueChanged, + ), + onrampProviderState = OnrampV2ProvidersUM.Empty, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampAmountButtonUMStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampAmountButtonUMStateFactory.kt new file mode 100644 index 0000000000..4bf1a7285a --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampAmountButtonUMStateFactory.kt @@ -0,0 +1,35 @@ +package com.tangem.features.onramp.mainv2.entity.factory + +import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM +import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState +import kotlinx.collections.immutable.toPersistentList + +internal class OnrampAmountButtonUMStateFactory { + + private val defaultPreselectedAmount = listOf(50, 100, 200, 300, 500) + + fun createOnrampAmountActionButton( + currencyCode: String, + currencySymbol: String, + onAmountValueChanged: (String) -> Unit, + ): OnrampV2AmountButtonUMState { + return when (currencyCode) { + USD_CODE, EUR_CODE -> { + val buttons = defaultPreselectedAmount.map { value -> + OnrampAmountButtonUM( + value = value, + currency = currencySymbol, + onClick = { onAmountValueChanged(value.toString()) }, + ) + }.toPersistentList() + OnrampV2AmountButtonUMState.Loaded(buttons) + } + else -> OnrampV2AmountButtonUMState.None + } + } + + companion object { + private const val USD_CODE = "USD" + private const val EUR_CODE = "EUR" + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt new file mode 100644 index 0000000000..966893cab7 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt @@ -0,0 +1,106 @@ +package com.tangem.features.onramp.mainv2.entity.factory + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.onramp.model.* +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.mainv2.entity.* +import com.tangem.utils.Provider +import com.tangem.utils.StringsSigns.MINUS +import kotlinx.collections.immutable.toPersistentList + +internal class OnrampOffersStateFactory( + private val currentStateProvider: Provider, + private val onrampIntents: OnrampV2Intents, +) { + + fun getOnShowOffersState(offers: List): OnrampV2MainComponentUM { + val currentState = currentStateProvider.invoke() + return when (currentState) { + is OnrampV2MainComponentUM.Content -> { + currentState.copy( + offersBlockState = mapOnrampOffersBlockToUM(offers), + ) + } + is OnrampV2MainComponentUM.InitialLoading -> { + currentState + } + } + } + + private fun mapOnrampOffersBlockToUM(offersBlocks: List): OnrampOffersBlockUM.Content { + val allOffersUM = mutableListOf() + offersBlocks.map { block -> + block.offers.forEach { offer -> + when (val currentQuote = offer.quote) { + is OnrampQuote.AmountError, + is OnrampQuote.Error, + -> Unit + is OnrampQuote.Data -> { + allOffersUM.add( + OnrampOfferUM( + category = mapOfferCategoryDTOtoUM(block.category), + advantages = mapOfferAdvantagesDTOtoUM(offer.advantages), + paymentMethod = currentQuote.paymentMethod, + providerId = currentQuote.provider.id, + providerName = currentQuote.provider.info.name, + rate = currentQuote.toAmount.value.format { + crypto( + symbol = currentQuote.toAmount.symbol, + decimals = currentQuote.toAmount.decimals, + ) + }, + diff = offer.rateDif?.let { diff -> + stringReference("$MINUS${diff.format { percent() }}") + }, + onBuyClicked = { + onrampIntents.onBuyClick( + quote = OnrampProviderWithQuote.Data( + provider = offer.quote.provider, + paymentMethod = currentQuote.paymentMethod, + toAmount = currentQuote.toAmount, + fromAmount = currentQuote.fromAmount, + ), + onrampOfferAdvantagesUM = mapOfferAdvantagesDTOtoUM(offer.advantages), + ) + }, + ), + ) + } + } + } + } + + return OnrampOffersBlockUM.Content( + isBlockVisible = false, + recentOffer = allOffersUM.firstOrNull { it.category == OnrampOfferCategoryUM.RecentlyUsed }, + recommended = allOffersUM.filter { it.category == OnrampOfferCategoryUM.Recommended }.toPersistentList(), + onrampAllOffersButtonConfig = if (offersBlocks.any { it.hasMoreOffers }) { + OnrampAllOffersButtonConfig( + title = TextReference.Res(R.string.onramp_all_offers_button_title), + onClick = { onrampIntents.openProviders() }, + ) + } else { + null + }, + ) + } + + private fun mapOfferCategoryDTOtoUM(category: OnrampOfferCategory): OnrampOfferCategoryUM { + return when (category) { + OnrampOfferCategory.Recent -> OnrampOfferCategoryUM.RecentlyUsed + OnrampOfferCategory.Recommended -> OnrampOfferCategoryUM.Recommended + } + } + + private fun mapOfferAdvantagesDTOtoUM(advantages: OnrampOfferAdvantages): OnrampOfferAdvantagesUM { + return when (advantages) { + OnrampOfferAdvantages.Default -> OnrampOfferAdvantagesUM.Default + OnrampOfferAdvantages.BestRate -> OnrampOfferAdvantagesUM.BestRate + OnrampOfferAdvantages.Fastest -> OnrampOfferAdvantagesUM.Fastest + } + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt new file mode 100644 index 0000000000..0e1dc10b5b --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt @@ -0,0 +1,221 @@ +package com.tangem.features.onramp.mainv2.entity.factory + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent +import com.tangem.domain.onramp.model.OnrampCurrency +import com.tangem.domain.onramp.model.OnrampQuote +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.tokens.model.AmountType +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.mainv2.entity.* +import com.tangem.features.onramp.mainv2.entity.converter.OnrampV2AmountFieldChangeConverter +import com.tangem.utils.Provider +import java.math.BigDecimal + +internal class OnrampV2AmountStateFactory( + private val currentStateProvider: Provider, + private val analyticsEventHandler: AnalyticsEventHandler, + private val onrampIntents: OnrampV2Intents, + private val cryptoCurrency: CryptoCurrency, + private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory, +) { + + private val onrampAmountFieldChangeConverter: OnrampV2AmountFieldChangeConverter by lazy( + mode = LazyThreadSafetyMode.NONE, + ) { + OnrampV2AmountFieldChangeConverter( + currentStateProvider = currentStateProvider, + onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, + onrampIntents = onrampIntents, + cryptoCurrency = cryptoCurrency, + ) + } + + fun getOnAmountValueChange(value: String): OnrampV2MainComponentUM { + return onrampAmountFieldChangeConverter.convert(value) + } + + fun getUpdatedCurrencyState(currency: OnrampCurrency): OnrampV2MainComponentUM { + val currentState = currentStateProvider() + if (currentState !is OnrampV2MainComponentUM.Content) return currentState + + val amountState = currentState.amountBlockState + + return currentState.copy( + amountBlockState = amountState.copy( + currencyUM = amountState.currencyUM.copy( + unit = currency.unit, + code = currency.code, + iconUrl = currency.image, + precision = currency.precision, + ), + amountFieldModel = amountState.amountFieldModel.copy( + isError = false, + fiatAmount = amountState.amountFieldModel.fiatAmount.copy( + currencySymbol = currency.unit, + decimals = currency.precision, + type = AmountType.FiatType(currency.code), + ), + ), + ), + onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton( + currencyCode = currency.code, + currencySymbol = currency.unit, + onAmountValueChanged = onrampIntents::onAmountValueChanged, + ), + ) + } + + fun getAmountSecondaryLoadingState(): OnrampV2MainComponentUM { + val currentState = currentStateProvider() + if (currentState !is OnrampV2MainComponentUM.Content) return currentState + + val amountState = currentState.amountBlockState + + return currentState.copy( + amountBlockState = amountState.copy( + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading, + ), + offersBlockState = OnrampOffersBlockUM.Loading(isBlockVisible = false), + continueButtonConfig = currentState.continueButtonConfig.copy(enabled = false), + errorNotification = null, + onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, + onrampProviderState = OnrampV2ProvidersUM.Loading, + ) + } + + fun getAmountSecondaryUpdatedState(quote: OnrampQuote): OnrampV2MainComponentUM { + val currentState = currentStateProvider() + if (currentState !is OnrampV2MainComponentUM.Content) return currentState + + val amountState = currentState.amountBlockState + if (amountState.amountFieldModel.fiatValue.isEmpty()) return currentState + + return currentState.copy( + amountBlockState = amountState.copy( + amountFieldModel = amountState.amountFieldModel.copy(isError = false), + secondaryFieldModel = quote.toSecondaryFieldUiModel(amountState) ?: amountState.secondaryFieldModel, + ), + continueButtonConfig = currentState.continueButtonConfig.copy( + enabled = quote is OnrampQuote.Data, + onClick = onrampIntents::onContinueClick, + ), + errorNotification = null, + ) + } + + fun getUpdatedProviderState(selectedQuote: OnrampQuote): OnrampV2MainComponentUM { + val currentState = currentStateProvider() + if (currentState !is OnrampV2MainComponentUM.Content) return currentState + + analyticsEventHandler.send( + OnrampAnalyticsEvent.ProviderCalculated( + providerName = selectedQuote.provider.info.name, + tokenSymbol = cryptoCurrency.symbol, + paymentMethod = selectedQuote.paymentMethod.name, + ), + ) + return currentState.copy( + onrampProviderState = selectedQuote.toProviderBlockState(), + ) + } + + fun getAmountSecondaryResetState(): OnrampV2MainComponentUM { + val currentState = currentStateProvider() + if (currentState !is OnrampV2MainComponentUM.Content) return currentState + + val amountState = currentState.amountBlockState + + if (amountState.secondaryFieldModel is OnrampNewAmountSecondaryFieldUM.Content) return currentState + + return currentState.copy( + amountBlockState = amountState.copy( + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content( + amount = stringReference( + BigDecimal.ZERO.format { + crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) + }, + ), + ), + ), + onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, + errorNotification = null, + ) + } + + fun getShowProvidersState(): OnrampV2MainComponentUM { + val currentState = currentStateProvider() + if (currentState !is OnrampV2MainComponentUM.Content) return currentState + + return when (currentState.offersBlockState) { + is OnrampOffersBlockUM.Content -> { + currentState.copy( + offersBlockState = currentState.offersBlockState.copy(isBlockVisible = true), + ) + } + OnrampOffersBlockUM.Empty, + is OnrampOffersBlockUM.Loading, + -> currentState + } + } + + private fun OnrampQuote.toProviderBlockState(): OnrampV2ProvidersUM { + return OnrampV2ProvidersUM.Content( + paymentMethod = paymentMethod, + providerId = provider.id, + ) + } + + private fun OnrampQuote.toSecondaryFieldUiModel( + amountState: OnrampNewAmountBlockUM, + ): OnrampNewAmountSecondaryFieldUM? { + return when (this) { + is OnrampQuote.Error -> null + is OnrampQuote.Data -> { + val amount = toAmount.value.format { + crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) + } + val contentAmount = combinedReference(stringReference("\u007E"), stringReference(amount)) + OnrampNewAmountSecondaryFieldUM.Content(contentAmount) + } + is OnrampQuote.AmountError -> this.toSecondaryFieldUiModel(amountState) + } + } + + private fun OnrampQuote.AmountError.toSecondaryFieldUiModel( + amountState: OnrampNewAmountBlockUM, + ): OnrampNewAmountSecondaryFieldUM.Error { + val amount = error.requiredAmount.format { + fiat( + fiatCurrencyCode = amountState.amountFieldModel.fiatAmount.currencySymbol, + fiatCurrencySymbol = amountState.amountFieldModel.fiatAmount.currencySymbol, + ) + } + + val errorTextRes = when (error) { + is OnrampError.AmountError.TooBigError -> { + analyticsEventHandler.send(OnrampAnalyticsEvent.MaxAmountError) + R.string.onramp_max_amount_restriction + } + is OnrampError.AmountError.TooSmallError -> { + analyticsEventHandler.send(OnrampAnalyticsEvent.MinAmountError) + R.string.onramp_min_amount_restriction + } + } + + return OnrampNewAmountSecondaryFieldUM.Error( + resourceReference( + errorTextRes, + wrappedList(amount), + ), + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt new file mode 100644 index 0000000000..e39a2078b2 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt @@ -0,0 +1,204 @@ +package com.tangem.features.onramp.mainv2.entity.factory + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.tangem.common.ui.amountScreen.models.AmountFieldModel +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.onramp.model.OnrampCurrency +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.tokens.model.Amount +import com.tangem.domain.tokens.model.AmountType +import com.tangem.domain.tokens.model.convertToAmount +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.mainv2.entity.* +import com.tangem.utils.Provider +import java.math.BigDecimal + +internal class OnrampV2StateFactory( + private val currentStateProvider: Provider, + private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory, + private val cryptoCurrency: CryptoCurrency, + private val onrampIntents: OnrampV2Intents, +) { + + fun getInitialState( + currency: String, + onClose: () -> Unit, + openSettings: () -> Unit, + ): OnrampV2MainComponentUM.InitialLoading { + return OnrampV2MainComponentUM.InitialLoading( + errorNotification = null, + topBarConfig = OnrampV2MainTopBarUM( + title = combinedReference(resourceReference(R.string.common_buy), stringReference(" $currency")), + startButtonUM = TopAppBarButtonUM.Close( + onCloseClick = onClose, + enabled = true, + ), + endButtonUM = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_more_vertical_24, + onClicked = openSettings, + enabled = false, + ), + ), + continueButtonConfig = ContinueButtonUM( + text = resourceReference(R.string.common_continue), + onClick = {}, + enabled = false, + ), + ) + } + + fun getReadyState(currency: OnrampCurrency): OnrampV2MainComponentUM.Content { + val state = currentStateProvider() + + val endButton = when (val button = state.topBarConfig.endButtonUM) { + is TopAppBarButtonUM.Icon -> button.copy(enabled = true) + is TopAppBarButtonUM.Text -> button.copy(enabled = true) + } + + val initialAmountBlockState = getInitialAmountBlockState(currency) + + return OnrampV2MainComponentUM.Content( + topBarConfig = state.topBarConfig.copy(endButtonUM = endButton), + continueButtonConfig = ContinueButtonUM( + text = resourceReference(R.string.common_continue), + onClick = onrampIntents::onContinueClick, + enabled = false, + ), + amountBlockState = initialAmountBlockState, + offersBlockState = OnrampOffersBlockUM.Empty, + errorNotification = null, + onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton( + currencyCode = currency.code, + currencySymbol = currency.unit, + onAmountValueChanged = onrampIntents::onAmountValueChanged, + ), + onrampProviderState = OnrampV2ProvidersUM.Empty, + ) + } + + fun getOnrampErrorState(onrampError: OnrampError): OnrampV2MainComponentUM { + return when (onrampError) { + OnrampError.PairsNotFound -> getNoPairsErrorState() + is OnrampError.DataError -> getErrorState( + errorCode = onrampError.code, + onRefresh = onrampIntents::onRefresh, + ) + is OnrampError.DomainError -> getErrorState(onRefresh = onrampIntents::onRefresh) + is OnrampError.AmountError.TooBigError, + is OnrampError.AmountError.TooSmallError, + OnrampError.RedirectError.VerificationFailed, + OnrampError.RedirectError.WrongRequestId, + -> currentStateProvider() // ignore error state + } + } + + private fun getNoPairsErrorState(): OnrampV2MainComponentUM { + val state = currentStateProvider() + val contentState = state as? OnrampV2MainComponentUM.Content ?: return state + + return contentState.copy( + continueButtonConfig = contentState.continueButtonConfig.copy(enabled = false), + amountBlockState = contentState.amountBlockState.copy( + amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true), + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Error( + error = resourceReference(R.string.onramp_no_available_providers), + ), + ), + onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, + offersBlockState = OnrampOffersBlockUM.Empty, + ) + } + + fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): OnrampV2MainComponentUM { + val state = currentStateProvider() + val endButton = when (val button = state.topBarConfig.endButtonUM) { + is TopAppBarButtonUM.Icon -> button.copy(enabled = true) + is TopAppBarButtonUM.Text -> button.copy(enabled = true) + } + + return when (state) { + is OnrampV2MainComponentUM.Content -> state.copy( + topBarConfig = state.topBarConfig.copy(endButtonUM = endButton), + continueButtonConfig = state.continueButtonConfig.copy(enabled = false), + amountBlockState = state.amountBlockState.copy( + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content( + stringReference( + BigDecimal.ZERO.format { + crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) + }, + ), + ), + ), + offersBlockState = OnrampOffersBlockUM.Empty, + errorNotification = NotificationUM.Warning.OnrampErrorNotification( + errorCode = errorCode, + onRefresh = onRefresh, + ), + onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, + onrampProviderState = OnrampV2ProvidersUM.Empty, + ) + is OnrampV2MainComponentUM.InitialLoading -> state.copy( + errorNotification = NotificationUM.Warning.OnrampErrorNotification( + errorCode = errorCode, + onRefresh = onRefresh, + ), + ) + } + } + + private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampNewAmountBlockUM { + return OnrampNewAmountBlockUM( + currencyUM = OnrampNewCurrencyUM( + code = currency.code, + iconUrl = currency.image, + precision = currency.precision, + onClick = onrampIntents::openCurrenciesList, + unit = currency.unit, + ), + amountFieldModel = AmountFieldModel( + value = "", + fiatValue = "", + onValueChange = onrampIntents::onAmountValueChanged, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.None, + keyboardType = KeyboardType.Number, + ), + keyboardActions = KeyboardActions(), + isFiatValue = true, + cryptoAmount = BigDecimal.ZERO.convertToAmount(cryptoCurrency), + fiatAmount = BigDecimal.ZERO.convertToFiatAmount(currency), + isError = false, + isWarning = false, + error = TextReference.EMPTY, + isFiatUnavailable = false, + isValuePasted = false, + onValuePastedTriggerDismiss = {}, + ), + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content( + stringReference( + BigDecimal.ZERO.format { + crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) + }, + ), + ), + ) + } + + private fun BigDecimal.convertToFiatAmount(currency: OnrampCurrency): Amount = Amount( + currencySymbol = currency.unit, + value = this, + decimals = currency.precision, + type = AmountType.FiatType(currency.code), + ) +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt new file mode 100644 index 0000000000..93f199fc85 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt @@ -0,0 +1,393 @@ +package com.tangem.features.onramp.mainv2.model + +import androidx.compose.runtime.mutableStateOf +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.ui.components.fields.InputManager +import com.tangem.domain.onramp.* +import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent +import com.tangem.domain.onramp.model.OnrampAvailability +import com.tangem.domain.onramp.model.OnrampProviderWithQuote +import com.tangem.domain.onramp.model.OnrampQuote +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.onramp.main.entity.OnrampLastUpdate +import com.tangem.features.onramp.mainv2.OnrampV2MainComponent +import com.tangem.features.onramp.mainv2.entity.* +import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory +import com.tangem.features.onramp.mainv2.entity.factory.OnrampOffersStateFactory +import com.tangem.features.onramp.mainv2.entity.factory.OnrampV2AmountStateFactory +import com.tangem.features.onramp.mainv2.entity.factory.OnrampV2StateFactory +import com.tangem.features.onramp.utils.sendOnrampErrorEvent +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.isNullOrZero +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@Suppress("LongParameterList", "LargeClass") +internal class OnrampV2MainComponentModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, + private val router: Router, + private val checkOnrampAvailabilityUseCase: CheckOnrampAvailabilityUseCase, + private val getOnrampCountryUseCase: GetOnrampCountryUseCase, + private val clearOnrampCacheUseCase: ClearOnrampCacheUseCase, + private val fetchQuotesUseCase: OnrampFetchQuotesUseCase, + private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase, + private val fetchPairsUseCase: OnrampFetchPairsUseCase, + private val amountInputManager: InputManager, + private val getOnrampOffersUseCase: GetOnrampOffersUseCase, + paramsContainer: ParamsContainer, + getWalletsUseCase: GetWalletsUseCase, +) : Model(), OnrampV2Intents { + + val params = paramsContainer.require() + + private var loadQuotesJob: Job? = null + + private val lastUpdateState = mutableStateOf(null) + + private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory by lazy(LazyThreadSafetyMode.NONE) { + OnrampAmountButtonUMStateFactory() + } + + private val onrampOffersStateFactory: OnrampOffersStateFactory by lazy(LazyThreadSafetyMode.NONE) { + OnrampOffersStateFactory( + currentStateProvider = Provider { _state.value }, + onrampIntents = this, + ) + } + + private val stateFactory = OnrampV2StateFactory( + currentStateProvider = Provider { _state.value }, + cryptoCurrency = params.cryptoCurrency, + onrampIntents = this, + onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, + ) + + private val amountStateFactory = OnrampV2AmountStateFactory( + currentStateProvider = Provider { _state.value }, + analyticsEventHandler = analyticsEventHandler, + onrampIntents = this, + cryptoCurrency = params.cryptoCurrency, + onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, + ) + + private val _state: MutableStateFlow = MutableStateFlow( + value = stateFactory.getInitialState( + currency = params.cryptoCurrency.name, + onClose = ::onCloseClick, + openSettings = ::openSettings, + ), + ) + val state: StateFlow get() = _state.asStateFlow() + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } + + init { + modelScope.launch { + clearOnrampCacheUseCase() + } + + sendScreenOpenAnalytics() + checkResidenceCountry() + subscribeToAmountChanges() + subscribeToCountryAndCurrencyUpdates() + subscribeToQuotesUpdate() + subscribeOnOffers() + } + + override fun onDestroy() { + modelScope.launch { clearOnrampCacheUseCase.invoke() } + loadQuotesJob?.cancel() + super.onDestroy() + } + + override fun onAmountValueChanged(value: String) { + _state.update { amountStateFactory.getOnAmountValueChange(value) } + modelScope.launch { amountInputManager.update(value) } + } + + override fun openSettings() { + params.openSettings.invoke() + } + + override fun openCurrenciesList() { + analyticsEventHandler.send(OnrampAnalyticsEvent.SelectCurrencyScreenOpened) + bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.CurrenciesList) + } + + override fun onBuyClick(quote: OnrampProviderWithQuote.Data, onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM) { + val currentContentState = state.value as? OnrampV2MainComponentUM.Content ?: return + analyticsEventHandler.send( + OnrampAnalyticsEvent.OnBuyClick( + providerName = quote.provider.info.name, + currency = currentContentState.amountBlockState.currencyUM.code, + tokenSymbol = params.cryptoCurrency.symbol, + ), + ) + onrampOfferAdvantagesUM.toAnalyticsEvent( + cryptoCurrencySymbol = params.cryptoCurrency.symbol, + providerName = quote.provider.info.name, + paymentMethodName = quote.paymentMethod.name, + )?.let { analyticsEventHandler::send } + params.openRedirectPage(quote) + } + + override fun openProviders() { + val currentContentState = state.value as? OnrampV2MainComponentUM.Content ?: return + val amountCurrentCode = currentContentState.amountBlockState.currencyUM.code + bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.AllOffers(amountCurrentCode)) + } + + override fun onRefresh() { + _state.update { + stateFactory.getInitialState( + currency = params.cryptoCurrency.name, + onClose = router::pop, + openSettings = ::openSettings, + ) + } + loadQuotesJob?.cancel() + modelScope.launch { + clearOnrampCacheUseCase.invoke() + checkResidenceCountry() + } + } + + override fun onContinueClick() { + val currentState = _state.value + if (currentState is OnrampV2MainComponentUM.Content) { + _state.update { amountStateFactory.getShowProvidersState() } + } + } + + private fun checkResidenceCountry() { + modelScope.launch { + checkOnrampAvailabilityUseCase(userWallet) + .onRight(::handleOnrampAvailability) + .onLeft(::handleOnrampError) + } + } + + private fun handleOnrampAvailability(availability: OnrampAvailability) { + when (availability) { + is OnrampAvailability.Available -> Unit + is OnrampAvailability.ConfirmResidency, + is OnrampAvailability.NotSupported, + -> bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.ConfirmResidency(availability.country)) + } + } + + private fun onCloseClick() { + analyticsEventHandler.send(OnrampAnalyticsEvent.CloseOnramp) + router.pop() + } + + private fun subscribeOnOffers() = modelScope.launch { + getOnrampOffersUseCase.invoke( + userWalletId = userWallet.walletId, + cryptoCurrencyId = params.cryptoCurrency.id, + ).collectLatest { maybeOffers -> + maybeOffers.fold( + ifLeft = ::handleOnrampError, + ifRight = { offers -> + _state.update { onrampOffersStateFactory.getOnShowOffersState(offers) } + }, + ) + } + } + + private fun subscribeToAmountChanges() = modelScope.launch { + amountInputManager.query + .filter(String::isNotEmpty) + .collectLatest { _ -> + _state.update { amountStateFactory.getAmountSecondaryLoadingState() } + loadQuotes() + } + } + + private fun subscribeToCountryAndCurrencyUpdates() { + getOnrampCountryUseCase.invoke() + .onEach { maybeCountry -> + maybeCountry.fold( + ifLeft = ::handleOnrampError, + ifRight = { country -> + if (country == null) return@onEach + _state.update { + when (it) { + is OnrampV2MainComponentUM.Content -> { + amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency) + } + is OnrampV2MainComponentUM.InitialLoading -> { + stateFactory.getReadyState(country.defaultCurrency) + } + } + } + updatePairsAndQuotes() + }, + ) + } + .launchIn(modelScope) + } + + private fun subscribeToQuotesUpdate() { + getOnrampQuotesUseCase.invoke() + .conflate() + .onEach { maybeQuotes -> + maybeQuotes.fold( + ifLeft = ::handleOnrampError, + ifRight = ::handleQuoteResult, + ) + } + .launchIn(modelScope) + } + + private fun handleQuoteResult(quotes: List) { + sendOnrampQuotesErrorAnalytic(quotes) + + val quote = selectOrUpdateQuote(quotes) + + if (quote == null) { + _state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) } + lastUpdateState.value = null + return + } + _state.update { amountStateFactory.getAmountSecondaryUpdatedState(quote = quote) } + } + + private fun selectOrUpdateQuote(quotes: List): OnrampQuote? { + val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error } + + // Check if amount, country or currency has changed + val newQuote = if (checkLastInputState(quoteToCheck)) { + quoteToCheck + } else { + val state = state.value as? OnrampV2MainComponentUM.Content + val providerState = state?.onrampProviderState as? OnrampV2ProvidersUM.Content + + // Get current selected quote to update + val lastSelectedQuote = quotes.firstOrNull { + it.provider.id == providerState?.providerId && + it.paymentMethod.id == providerState.paymentMethod.id + } + + if (lastSelectedQuote is OnrampQuote.Error) { + quoteToCheck + } else { + lastSelectedQuote + } + } + newQuote?.let { updateProvider(newQuote) } + + return newQuote + } + + private fun onRetryQuotes() { + _state.update { + (it as? OnrampV2MainComponentUM.Content)?.copy( + errorNotification = null, + onrampProviderState = OnrampV2ProvidersUM.Loading, + offersBlockState = OnrampOffersBlockUM.Loading(isBlockVisible = false), + amountBlockState = it.amountBlockState.copy( + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading, + ), + ) ?: it + } + loadQuotes() + } + + private suspend fun updatePairsAndQuotes() { + val state = state.value as? OnrampV2MainComponentUM.Content + + if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) { + _state.update { amountStateFactory.getAmountSecondaryLoadingState() } + } + fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold( + ifLeft = ::handleOnrampError, + ifRight = { + _state.update { + if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) { + return@fold + } else { + amountStateFactory.getAmountSecondaryResetState() + } + } + }, + ) + loadQuotes() + } + + private fun loadQuotes() { + loadQuotesJob?.cancel() + loadQuotesJob = modelScope.launch { + runCatching { + val content = state.value as? OnrampV2MainComponentUM.Content ?: return@runCatching + if (content.amountBlockState.amountFieldModel.fiatAmount.value.isNullOrZero()) return@runCatching + fetchQuotesUseCase.invoke( + userWallet = userWallet, + amount = content.amountBlockState.amountFieldModel.fiatAmount, + cryptoCurrency = params.cryptoCurrency, + ).onLeft(::handleOnrampError) + } + } + } + + private fun handleOnrampError(onrampError: OnrampError) { + Timber.e(onrampError.toString()) + _state.update { stateFactory.getOnrampErrorState(onrampError) } + } + + private fun updateProvider(quote: OnrampQuote) { + lastUpdateState.value = OnrampLastUpdate( + quote.fromAmount, + quote.countryCode, + ) + + _state.update { + amountStateFactory.getUpdatedProviderState(selectedQuote = quote) + } + } + + private fun sendOnrampQuotesErrorAnalytic(quotes: List) { + quotes.forEach { errorState -> + when (errorState) { + is OnrampQuote.Error -> analyticsEventHandler.sendOnrampErrorEvent( + error = errorState.error, + tokenSymbol = params.cryptoCurrency.symbol, + providerName = errorState.provider.info.name, + paymentMethod = errorState.paymentMethod.name, + ) + is OnrampQuote.AmountError -> analyticsEventHandler.sendOnrampErrorEvent( + error = errorState.error, + tokenSymbol = params.cryptoCurrency.symbol, + providerName = errorState.provider.info.name, + paymentMethod = errorState.paymentMethod.name, + ) + else -> Unit + } + } + } + + private fun checkLastInputState(quote: OnrampQuote?): Boolean { + return lastUpdateState.value?.lastAmount != quote?.fromAmount || + lastUpdateState.value?.lastCountryString != quote?.countryCode + } + + private fun sendScreenOpenAnalytics() { + analyticsEventHandler.send( + OnrampAnalyticsEvent.ScreenOpened( + source = params.source, + tokenSymbol = params.cryptoCurrency.symbol, + ), + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt new file mode 100644 index 0000000000..5668fac482 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt @@ -0,0 +1,130 @@ +package com.tangem.features.onramp.mainv2.ui + +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.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.Keyboard +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.keyboardAsState +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM +import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState +import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM + +@Composable +internal fun OnrampFooterContent( + state: OnrampV2MainComponentUM.Content, + boxScope: BoxScope, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + + boxScope.apply { + AnimatedVisibility( + modifier = Modifier + .imePadding() + .align(Alignment.BottomCenter), + visible = state.offersBlockState.isBlockVisible.not(), + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(durationMillis = 300), + ), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(durationMillis = 300), + ), + label = "Footer block animation", + ) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = stringResourceSafe(id = R.string.common_continue), + onClick = { + state.continueButtonConfig.onClick() + keyboardController?.hide() + }, + enabled = state.continueButtonConfig.enabled, + ) + SpacerH(16.dp) + OnrampAmountButtons(state = state.onrampAmountButtonUMState) + } + } + } +} + +@Composable +private fun OnrampAmountButtons(state: OnrampV2AmountButtonUMState) { + val keyboard by keyboardAsState() + + AnimatedVisibility( + visible = state is OnrampV2AmountButtonUMState.Loaded, + enter = fadeIn(), + exit = fadeOut(), + ) { + when (state) { + is OnrampV2AmountButtonUMState.Loaded -> { + if (keyboard is Keyboard.Opened) { + LazyRow( + modifier = Modifier.background(color = TangemTheme.colors.button.secondary), + contentPadding = PaddingValues( + vertical = 10.dp, + horizontal = 8.dp, + ), + state = rememberLazyListState(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = state.amountButtons, + key = OnrampAmountButtonUM::value, + ) { + AmountButton(button = it) + } + } + } + } + OnrampV2AmountButtonUMState.None -> Unit + } + } +} + +@Composable +private fun AmountButton(button: OnrampAmountButtonUM, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .sizeIn(minHeight = 24.dp, minWidth = 62.dp) + .background( + color = TangemTheme.colors.field.primary, + shape = RoundedCornerShape(16.dp), + ) + .clickable(onClick = button.onClick) + .padding(vertical = 4.dp, horizontal = 20.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "${button.value}${button.currency}", + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt new file mode 100644 index 0000000000..cf0d2ca599 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt @@ -0,0 +1,141 @@ +package com.tangem.features.onramp.mainv2.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +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.unit.dp +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.WindowInsetsZero +import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM + +@Composable +internal fun OnrampNewMainScreen(state: OnrampV2MainComponentUM, modifier: Modifier = Modifier) { + Scaffold( + modifier = modifier.systemBarsPadding(), + topBar = { + TangemTopAppBar( + startButton = state.topBarConfig.startButtonUM, + endButton = state.topBarConfig.endButtonUM, + title = state.topBarConfig.title.resolveReference(), + ) + }, + contentWindowInsets = WindowInsetsZero, + containerColor = TangemTheme.colors.background.secondary, + ) { scaffoldPaddings -> + OnrampNewMainComponentContent( + state = state, + modifier = Modifier.padding(scaffoldPaddings), + ) + } +} + +@Composable +internal fun OnrampNewMainComponentContent(state: OnrampV2MainComponentUM, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors.background.secondary), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (state) { + is OnrampV2MainComponentUM.InitialLoading -> InitialLoading(state = state) + is OnrampV2MainComponentUM.Content -> Content(state = state) + } + } + + if (state is OnrampV2MainComponentUM.Content) { + OnrampFooterContent( + state = state, + boxScope = this, + ) + } + } +} + +@Composable +private fun InitialLoading(state: OnrampV2MainComponentUM.InitialLoading, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + OnrampAmountContentLoading() + if (state.errorNotification != null) Notification(config = state.errorNotification.config) + } +} + +@Composable +private fun OnrampAmountContentLoading() { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(shape = RoundedCornerShape(size = TangemTheme.dimens.radius16)) + .background(TangemTheme.colors.background.action) + .padding(vertical = TangemTheme.dimens.spacing28), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RectangleShimmer( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing16) + .size(width = 76.dp, height = 20.dp), + radius = TangemTheme.dimens.radius4, + ) + RectangleShimmer( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing12) + .size(width = 136.dp, height = 44.dp), + radius = TangemTheme.dimens.radius4, + ) + RectangleShimmer( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing8) + .size(width = 52.dp, height = 16.dp), + radius = TangemTheme.dimens.radius4, + ) + RectangleShimmer( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing20) + .size(width = 84.dp, height = 28.dp), + radius = TangemTheme.dimens.radius14, + ) + } +} + +@Composable +private fun Content(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .navigationBarsPadding() + .padding( + bottom = 76.dp, + start = 16.dp, + end = 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + OnrampV2AmountContent(state = state) + + OnrampOffersContent(state = state.offersBlockState) + + if (state.errorNotification != null) Notification(config = state.errorNotification.config) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt new file mode 100644 index 0000000000..05645f62d0 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt @@ -0,0 +1,384 @@ +package com.tangem.features.onramp.mainv2.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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 androidx.compose.ui.util.fastForEach +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.buttons.common.TangemButtonSize +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 +import com.tangem.domain.onramp.model.PaymentMethodType +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM +import com.tangem.features.onramp.mainv2.entity.OnrampOffersBlockUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { + AnimatedVisibility( + visible = state.isBlockVisible, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(durationMillis = 300), + ), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(durationMillis = 300), + ), + label = "Offers block animation", + ) { + if (state is OnrampOffersBlockUM.Content) { + Column(modifier = Modifier.fillMaxWidth()) { + state.recentOffer?.let { recentOffer -> + Column { + Text( + modifier = Modifier.padding(start = 12.dp), + text = stringResourceSafe(R.string.onramp_recently_used_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + + SpacerH(8.dp) + + Offer(recentOffer) + + SpacerH(16.dp) + } + } + + Text( + modifier = Modifier.padding(start = 12.dp), + text = stringResourceSafe(R.string.onramp_recommended_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + + SpacerH(8.dp) + + state.recommended.fastForEach { offer -> + key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") { + Offer(offer) + SpacerH(8.dp) + } + } + + state.onrampAllOffersButtonConfig?.let { + SpacerH(12.dp) + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + text = it.title.resolveReference(), + onClick = it.onClick, + ) + } + } + } + } +} + +@Composable +internal fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.action, + shape = RoundedCornerShape(14.dp), + ) + .padding(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + OfferHeader(advantage = onrampOfferUM.advantages) + RateBlock(rate = onrampOfferUM.rate, diff = onrampOfferUM.diff) + } + SpacerWMax() + SecondaryButton( + size = TangemButtonSize.RoundedAction, + text = stringResourceSafe(R.string.common_buy), + onClick = onrampOfferUM.onBuyClicked, + ) + } + SpacerH(10.dp) + HorizontalDivider(color = TangemTheme.colors.stroke.primary) + SpacerH(12.dp) + PaymentBlockInOffer( + paymentMethod = onrampOfferUM.paymentMethod, + providerName = onrampOfferUM.providerName, + ) + } +} + +@Composable +private fun OfferHeader(advantage: OnrampOfferAdvantagesUM) { + when (advantage) { + OnrampOfferAdvantagesUM.Default -> { + Text( + text = stringResourceSafe(R.string.onramp_title_you_get), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + OnrampOfferAdvantagesUM.BestRate -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_best_rate_16), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + Text( + text = stringResourceSafe(R.string.express_provider_best_rate), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.icon.accent, + ) + } + } + OnrampOfferAdvantagesUM.Fastest -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_fastest_16), + tint = TangemTheme.colors.icon.attention, + contentDescription = null, + ) + Text( + text = stringResourceSafe(R.string.onramp_offer_type_fastet), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.icon.attention, + ) + } + } + } +} + +@Composable +private fun RateBlock(rate: String, diff: TextReference?) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = rate, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + diff?.let { + Text( + modifier = Modifier + .background( + color = TangemTheme.colors.text.warning.copy(alpha = 0.1f), + shape = RoundedCornerShape(4.dp), + ) + .padding(horizontal = 4.dp), + text = it.resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.warning, + ) + } + } +} + +@Composable +private fun PaymentBlockInOffer(paymentMethod: OnrampPaymentMethod, providerName: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_clock_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + + SpacerW(2.dp) + + TimingBlock(speed = paymentMethod.type.getProcessingSpeed()) + + SpacerW(6.dp) + + DrawDot(color = TangemTheme.colors.text.tertiary) + + SpacerW(6.dp) + + Text( + text = providerName, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + + SpacerWMax() + + Text( + text = stringResourceSafe(R.string.onramp_pay_with), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + + SpacerW(4.dp) + + SubcomposeAsyncImage( + modifier = Modifier.sizeIn(maxWidth = 38.dp, maxHeight = 16.dp), + model = ImageRequest.Builder(context = LocalContext.current) + .data(paymentMethod.imageUrl) + .crossfade(enable = true) + .allowHardware(false) + .build(), + loading = { + TextShimmer( + style = TangemTheme.typography.body1, + modifier = Modifier.width(40.dp), + ) + }, + contentDescription = null, + ) + } +} + +@Composable +internal fun TimingBlock(speed: PaymentMethodType.PaymentSpeed) { + val timingText = when (speed) { + PaymentMethodType.PaymentSpeed.Instant -> { + stringResourceSafe(id = R.string.onramp_instant_status) + } + PaymentMethodType.PaymentSpeed.FewMin -> { + stringResourceSafe( + id = R.string.onramp_timing_minutes, + FEW_MINS_VALUE, + ) + } + PaymentMethodType.PaymentSpeed.FewDays -> { + pluralStringResourceSafe( + id = R.plurals.onramp_timing_days, + count = FEW_DAYS_VALUE, + FEW_DAYS_VALUE, + ) + } + PaymentMethodType.PaymentSpeed.PlentyDays -> { + pluralStringResourceSafe( + id = R.plurals.onramp_timing_days, + count = PLENTY_DAYS_VALUE, + PLENTY_DAYS_VALUE, + ) + } + PaymentMethodType.PaymentSpeed.Unknown -> "" + } + + Text( + text = timingText, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Composable +fun DrawDot(color: Color) { + Spacer( + modifier = Modifier + .size(4.dp) + .drawWithCache { + val radius = size.minDimension / 2f + onDrawBehind { + drawCircle( + color = color, + radius = radius, + ) + } + }, + ) +} + +// will be hardcoded till server will be ready to provide this values +private const val FEW_MINS_VALUE = "3-5" +private const val FEW_DAYS_VALUE = 3 +private const val PLENTY_DAYS_VALUE = 5 + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun OnrampOffersContentPreview() { + val state = OnrampOffersBlockUM.Content( + isBlockVisible = true, + recentOffer = OnrampOfferUM( + category = OnrampOfferCategoryUM.RecentlyUsed, + advantages = OnrampOfferAdvantagesUM.Default, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId3", + providerName = "Simplex", + rate = "0,00045334 BTC", + diff = stringReference("–27%"), + onBuyClicked = {}, + ), + recommended = persistentListOf( + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.BestRate, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId1", + providerName = "Simplex", + rate = "0,0245334 BTC", + diff = null, + onBuyClicked = {}, + ), + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.Fastest, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId2", + providerName = "Simplex", + rate = "0,00145334 BTC", + diff = stringReference("–0.07%"), + onBuyClicked = {}, + ), + ), + onrampAllOffersButtonConfig = null, + ) + TangemThemePreview { + OnrampOffersContent(state) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt new file mode 100644 index 0000000000..b0ea37b98b --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt @@ -0,0 +1,200 @@ +package com.tangem.features.onramp.mainv2.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.tangem.common.ui.amountScreen.models.AmountFieldModel +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.fields.AmountTextField +import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.BuyTokenDetailsScreenTestTags +import com.tangem.core.ui.utils.rememberDecimalFormat +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.mainv2.entity.OnrampNewAmountSecondaryFieldUM +import com.tangem.features.onramp.mainv2.entity.OnrampNewCurrencyUM +import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM + +@Composable +internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) { + val padding = remember(state.offersBlockState.isBlockVisible) { + if (state.offersBlockState.isBlockVisible) { + 22.dp + } else { + 46.dp + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.action, + shape = RoundedCornerShape(size = TangemTheme.dimens.radius16), + ) + .padding(vertical = padding) + .animateContentSize(animationSpec = tween(durationMillis = 300)), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + OnrampHeaderTitle() + + OnrampAmountField( + amountField = state.amountBlockState.amountFieldModel, + currencyCode = state.amountBlockState.currencyUM.code, + ) + + AnimatedVisibility(!state.offersBlockState.isBlockVisible) { + OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel) + } + + SpacerH(20.dp) + + OnrampCurrencyIcon(currencyUM = state.amountBlockState.currencyUM) + } +} + +@Composable +private fun OnrampHeaderTitle() { + Text( + text = stringResourceSafe(R.string.onramp_you_will_pay_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Composable +private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: String) { + val decimalFormat = rememberDecimalFormat() + val requester = remember { FocusRequester() } + AmountTextField( + value = amountField.fiatValue, + decimals = amountField.fiatAmount.decimals, + visualTransformation = AmountVisualTransformation( + decimals = amountField.fiatAmount.decimals, + symbol = currencyCode, + currencyCode = currencyCode, + decimalFormat = decimalFormat, + symbolColor = TangemTheme.colors.text.disabled, + ), + onValueChange = amountField.onValueChange, + keyboardOptions = amountField.keyboardOptions, + keyboardActions = amountField.keyboardActions, + textStyle = TangemTheme.typography.head.copy( + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ), + isEnabled = !amountField.isError, + isAutoResize = true, + isValuePasted = amountField.isValuePasted, + onValuePastedTriggerDismiss = amountField.onValuePastedTriggerDismiss, + modifier = Modifier + .focusRequester(requester) + .padding( + top = TangemTheme.dimens.spacing8, + bottom = TangemTheme.dimens.spacing4, + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + ) + .requiredHeightIn(min = TangemTheme.dimens.size32) + .testTag(BuyTokenDetailsScreenTestTags.FIAT_AMOUNT_TEXT_FIELD), + ) + + LaunchedEffect(key1 = Unit) { + requester.requestFocus() + } +} + +@Composable +private fun OnrampAmountSecondary(state: OnrampNewAmountSecondaryFieldUM) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding( + top = TangemTheme.dimens.spacing8, + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + ) + .testTag(BuyTokenDetailsScreenTestTags.TOKEN_AMOUNT), + contentAlignment = Alignment.Center, + ) { + when (state) { + is OnrampNewAmountSecondaryFieldUM.Content -> Text( + text = state.amount.resolveReference(), + style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr), + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + is OnrampNewAmountSecondaryFieldUM.Error -> Text( + text = state.error.resolveReference(), + color = TangemTheme.colors.text.warning, + style = TangemTheme.typography.caption2, + textAlign = TextAlign.Center, + ) + is OnrampNewAmountSecondaryFieldUM.Loading -> TextShimmer( + style = TangemTheme.typography.caption2, + modifier = Modifier.width(TangemTheme.dimens.size62), + ) + } + } +} + +@Composable +private fun OnrampCurrencyIcon(currencyUM: OnrampNewCurrencyUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .clip(RoundedCornerShape(14.dp)) + .background(TangemTheme.colors.button.secondary) + .clickable(onClick = currencyUM.onClick) + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + AsyncImage( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .testTag(BuyTokenDetailsScreenTestTags.FIAT_CURRENCY_ICON), + model = currencyUM.iconUrl, + contentDescription = null, + ) + Text( + text = currencyUM.code, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.body2.copy(fontWeight = FontWeight.SemiBold), + textAlign = TextAlign.Center, + ) + Icon( + modifier = Modifier + .size(TangemTheme.dimens.size16) + .testTag(BuyTokenDetailsScreenTestTags.EXPAND_FIAT_LIST_BUTTON), + painter = painterResource(id = R.drawable.ic_chevron_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt index e08b4a4477..b9ff5cb77a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt @@ -17,6 +17,8 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.onramp.component.OnrampComponent import com.tangem.features.onramp.main.OnrampMainComponent +import com.tangem.features.onramp.mainv2.OnrampV2MainComponent +import com.tangem.features.onramp.mainv2.OnrampV2MainFeatureToggle import com.tangem.features.onramp.redirect.OnrampRedirectComponent import com.tangem.features.onramp.root.entity.OnrampChild import com.tangem.features.onramp.settings.OnrampSettingsComponent @@ -24,13 +26,15 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -@Suppress("UnusedPrivateMember") +@Suppress("LongParameterList") internal class DefaultOnrampComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: OnrampComponent.Params, private val settingsComponentFactory: OnrampSettingsComponent.Factory, private val onrampMainComponentFactory: OnrampMainComponent.Factory, + private val onrampMainV2ComponentFactory: OnrampV2MainComponent.Factory, private val onrampRedirectComponentFactory: OnrampRedirectComponent.Factory, + private val onrampV2MainFeatureToggle: OnrampV2MainFeatureToggle, ) : OnrampComponent, AppComponentContext by context { private val navigation = StackNavigation() @@ -66,23 +70,44 @@ internal class DefaultOnrampComponent @AssistedInject constructor( onBack = navigation::pop, ), ) - OnrampChild.Main -> onrampMainComponentFactory.create( - context = childByContext(componentContext), - params = OnrampMainComponent.Params( - userWalletId = params.userWalletId, - cryptoCurrency = params.cryptoCurrency, - openSettings = { navigation.push(OnrampChild.Settings) }, - source = params.source, - openRedirectPage = { - navigation.push( - OnrampChild.RedirectPage( - quote = it, - cryptoCurrency = params.cryptoCurrency, - ), - ) - }, - ), - ) + OnrampChild.Main -> if (onrampV2MainFeatureToggle.isOnrampNewMainEnabled) { + onrampMainV2ComponentFactory.create( + context = childByContext(componentContext), + params = OnrampV2MainComponent.Params( + userWalletId = params.userWalletId, + cryptoCurrency = params.cryptoCurrency, + openSettings = { navigation.push(OnrampChild.Settings) }, + source = params.source, + openRedirectPage = { + navigation.push( + OnrampChild.RedirectPage( + quote = it, + cryptoCurrency = params.cryptoCurrency, + ), + ) + }, + ), + ) + } else { + onrampMainComponentFactory.create( + context = childByContext(componentContext), + params = OnrampMainComponent.Params( + userWalletId = params.userWalletId, + cryptoCurrency = params.cryptoCurrency, + openSettings = { navigation.push(OnrampChild.Settings) }, + source = params.source, + openRedirectPage = { + navigation.push( + OnrampChild.RedirectPage( + quote = it, + cryptoCurrency = params.cryptoCurrency, + ), + ) + }, + launchSepa = params.launchSepa, + ), + ) + } is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create( context = childByContext(componentContext), params = OnrampRedirectComponent.Params( diff --git a/features/push-notifications/impl/build.gradle.kts b/features/push-notifications/impl/build.gradle.kts index 2f2c875648..e8a673832e 100644 --- a/features/push-notifications/impl/build.gradle.kts +++ b/features/push-notifications/impl/build.gradle.kts @@ -40,7 +40,6 @@ dependencies { /** Domain module */ implementation(projects.domain.settings) - implementation(projects.domain.notifications.toggles) implementation(projects.domain.notifications) /** Feature modules */ diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsBottomSheetComponent.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsBottomSheetComponent.kt index 259b0bacf0..2862913418 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsBottomSheetComponent.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsBottomSheetComponent.kt @@ -1,8 +1,6 @@ package com.tangem.features.pushnotifications.impl import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel @@ -38,7 +36,6 @@ internal class DefaultPushNotificationsBottomSheetComponent @AssistedInject cons @Composable override fun BottomSheet() { - val state by model.state.collectAsState() val bottomSheetConfig = remember(key1 = this) { TangemBottomSheetConfig( isShown = true, @@ -55,7 +52,6 @@ internal class DefaultPushNotificationsBottomSheetComponent @AssistedInject cons onLaterClick = model::onLaterClick, onAllowPermission = model::onAllowPermission, onDenyPermission = model::onDenyPermission, - showNotificationsInfo = state.showInfoAboutNotifications, ) } } diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt index 458d977d4e..3eb2a6fdc9 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt @@ -2,8 +2,6 @@ package com.tangem.features.pushnotifications.impl import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.tangem.core.decompose.context.AppComponentContext @@ -28,15 +26,15 @@ internal class DefaultPushNotificationsComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val activity = LocalContext.current.findActivity() - val state by model.state.collectAsState() + BackHandler(onBack = { activity.finish() }) NavigationBar3ButtonsScrim() + PushNotificationsScreen( onAllowClick = model::onAllowClick, onLaterClick = model::onLaterClick, onAllowPermission = model::onAllowPermission, onDenyPermission = model::onDenyPermission, - showNotificationsInfo = state.showInfoAboutNotifications, ) } diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt index c76c250f14..bd3edb7000 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt @@ -9,16 +9,12 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.notifications.repository.NotificationsRepository -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.settings.NeverRequestPermissionUseCase import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase import com.tangem.features.pushnotifications.api.PushNotificationsParams import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION -import com.tangem.features.pushnotifications.impl.presentation.state.PushNotificationsUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -32,7 +28,6 @@ internal class PushNotificationsModel @Inject constructor( private val neverToInitiallyAskPermissionUseCase: NeverToInitiallyAskPermissionUseCase, private val appRouter: AppRouter, private val analyticHandler: AnalyticsEventHandler, - private val notificationsFeatureToggles: NotificationsFeatureToggles, private val notificationsRepository: NotificationsRepository, ) : Model(), PushNotificationsClickIntents { @@ -43,32 +38,21 @@ internal class PushNotificationsModel @Inject constructor( AppRoute.PushNotification.Source.Onboarding -> AnalyticsParam.ScreensSources.Onboarding } - private val _state = MutableStateFlow( - PushNotificationsUM( - showInfoAboutNotifications = notificationsFeatureToggles.isNotificationsEnabled, - ), - ) - init { analyticHandler.send(PushNotificationAnalyticEvents.NotificationsScreenOpened(source)) } - val state = _state.asStateFlow() - override fun onAllowClick() { - if (notificationsFeatureToggles.isNotificationsEnabled) { - modelScope.launch { - notificationsRepository.setUserAllowToSubscribeOnPushNotifications(true) - } + modelScope.launch { + notificationsRepository.setUserAllowToSubscribeOnPushNotifications(true) } + analyticHandler.send(PushNotificationAnalyticEvents.ButtonAllow(source)) } override fun onLaterClick() { - if (notificationsFeatureToggles.isNotificationsEnabled) { - modelScope.launch { - notificationsRepository.setUserAllowToSubscribeOnPushNotifications(false) - } + modelScope.launch { + notificationsRepository.setUserAllowToSubscribeOnPushNotifications(false) } analyticHandler.send(PushNotificationAnalyticEvents.ButtonLater(source)) modelScope.launch { diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/state/PushNotificationsUM.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/state/PushNotificationsUM.kt deleted file mode 100644 index a7161b630b..0000000000 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/state/PushNotificationsUM.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.features.pushnotifications.impl.presentation.state - -data class PushNotificationsUM( - val showInfoAboutNotifications: Boolean = false, -) \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsBottomSheet.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsBottomSheet.kt index fa78628411..07090215d9 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsBottomSheet.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsBottomSheet.kt @@ -44,7 +44,6 @@ internal fun PushNotificationsContent( onLaterClick: () -> Unit, onAllowPermission: () -> Unit, onDenyPermission: () -> Unit, - showNotificationsInfo: Boolean, ) { val requestPushPermission = requestPermission( onAllow = onAllowPermission, @@ -65,18 +64,11 @@ internal fun PushNotificationsContent( R.drawable.ic_storefront_24, resourceReference(R.string.user_push_notification_agreement_argument_two), ), - ).let { baseItems -> - if (showNotificationsInfo) { - baseItems.add( - ShowcaseItemModel( - R.drawable.ic_notifications_24, - resourceReference(R.string.user_push_notification_agreement_argument_three), - ), - ) - } else { - baseItems - } - }, + ShowcaseItemModel( + R.drawable.ic_notifications_24, + resourceReference(R.string.user_push_notification_agreement_argument_three), + ), + ), modifier = Modifier.padding(top = TangemTheme.dimens.spacing40), ) SpacerH28() @@ -111,7 +103,6 @@ private fun Preview_PushNotificationsBottomSheet() { onLaterClick = {}, onAllowPermission = {}, onDenyPermission = {}, - showNotificationsInfo = true, ) } } diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt index 4750463f50..81ec2b2743 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt @@ -18,7 +18,6 @@ internal fun PushNotificationsScreen( onLaterClick: () -> Unit, onAllowPermission: () -> Unit, onDenyPermission: () -> Unit, - showNotificationsInfo: Boolean, ) { val requestPushPermission = requestPermission( onAllow = onAllowPermission, @@ -38,18 +37,11 @@ internal fun PushNotificationsScreen( R.drawable.ic_storefront_24, resourceReference(R.string.user_push_notification_agreement_argument_two), ), - ).let { baseItems -> - if (showNotificationsInfo) { - baseItems.add( - ShowcaseItemModel( - R.drawable.ic_notifications_24, - resourceReference(R.string.user_push_notification_agreement_argument_three), - ), - ) - } else { - baseItems - } - }, + ShowcaseItemModel( + R.drawable.ic_notifications_24, + resourceReference(R.string.user_push_notification_agreement_argument_three), + ), + ), primaryButton = ShowcaseButtonModel( buttonText = resourceReference(R.string.common_allow), onClick = { diff --git a/features/referral/impl/build.gradle.kts b/features/referral/impl/build.gradle.kts index 962fdc6a20..13e30dd409 100644 --- a/features/referral/impl/build.gradle.kts +++ b/features/referral/impl/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(projects.core.decompose) implementation(projects.libs.crypto) implementation(projects.common.routing) + implementation(projects.common.ui) /** AndroidX */ implementation(deps.androidx.appCompat) diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt index 23e2e9e25a..91384054bc 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.tangem.common.routing.AppRouter +import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -147,13 +148,18 @@ internal class ReferralModel @Inject constructor( url = tosLink, expectedAwards = expectedAwards, ) - is ReferralData.NonParticipantData -> ReferralInfoState.NonParticipantContent( - award = getAwardValue(), - networkName = getNetworkName(), - discount = getDiscountValue(), - url = tosLink, - onParticipateClicked = ::participate, - ) + is ReferralData.NonParticipantData -> { + val userWallet = getUserWalletUseCase(params.userWalletId).getOrNull() ?: error("User wallet not found") + + ReferralInfoState.NonParticipantContent( + award = getAwardValue(), + networkName = getNetworkName(), + discount = getDiscountValue(), + url = tosLink, + onParticipateClicked = ::participate, + participateButtonIcon = walletInterationIcon(userWallet), + ) + } } private fun ReferralData.getAwardValue(): String = "$award ${getToken().symbol}" diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/models/ReferralStateHolder.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/models/ReferralStateHolder.kt index 77bf7443e4..3631e0c996 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/models/ReferralStateHolder.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/models/ReferralStateHolder.kt @@ -1,5 +1,6 @@ package com.tangem.feature.referral.models +import androidx.annotation.DrawableRes import com.tangem.feature.referral.domain.models.ExpectedAwards internal data class ReferralStateHolder( @@ -36,6 +37,7 @@ internal data class ReferralStateHolder( override val networkName: String, override val discount: String, override val url: String, + @DrawableRes val participateButtonIcon: Int?, val onParticipateClicked: () -> Unit, ) : ReferralInfoState, ReferralInfoContentState diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/NonParticipateBottomBlock.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/NonParticipateBottomBlock.kt index 0849b5314f..1a418c9483 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/NonParticipateBottomBlock.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/NonParticipateBottomBlock.kt @@ -1,6 +1,7 @@ package com.tangem.feature.referral.ui import android.content.res.Configuration +import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -15,7 +16,11 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.feature.referral.presentation.R @Composable -internal fun NonParticipateBottomBlock(onAgreementClick: () -> Unit, onParticipateClick: () -> Unit) { +internal fun NonParticipateBottomBlock( + @DrawableRes buttonIconRes: Int?, + onAgreementClick: () -> Unit, + onParticipateClick: () -> Unit, +) { Column { AgreementText( firstPartResId = R.string.referral_tos_not_enroled_prefix, @@ -24,7 +29,7 @@ internal fun NonParticipateBottomBlock(onAgreementClick: () -> Unit, onParticipa PrimaryButtonIconEnd( text = stringResourceSafe(id = R.string.referral_button_participate), - iconResId = R.drawable.ic_tangem_24, + iconResId = buttonIconRes, onClick = onParticipateClick, modifier = Modifier .fillMaxWidth() @@ -39,7 +44,7 @@ internal fun NonParticipateBottomBlock(onAgreementClick: () -> Unit, onParticipa private fun Preview_NonParticipateBottomBlock() { TangemThemePreview { Column(Modifier.background(TangemTheme.colors.background.primary)) { - NonParticipateBottomBlock(onAgreementClick = {}, onParticipateClick = {}) + NonParticipateBottomBlock(buttonIconRes = null, onAgreementClick = {}, onParticipateClick = {}) } } } \ No newline at end of file diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ReferralScreen.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ReferralScreen.kt index 4aae138a4f..fcda66f563 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ReferralScreen.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ReferralScreen.kt @@ -183,6 +183,7 @@ private fun ReferralInfo( NonParticipateBottomBlock( onAgreementClick = onAgreementClick, onParticipateClick = state.onParticipateClicked, + buttonIconRes = state.participateButtonIcon, ) } @@ -465,6 +466,7 @@ private fun Preview_ReferralScreen_NonParticipant() { discount = "10%", url = "", onParticipateClicked = {}, + participateButtonIcon = R.drawable.ic_tangem_24, ), errorSnackbar = null, analytics = Analytics( diff --git a/features/send-v2/api/src/test/java/com/tangem/features/send/v2/api/subcomponents/feeSelector/utils/FeeCalculationUtilsTest.kt b/features/send-v2/api/src/test/java/com/tangem/features/send/v2/api/subcomponents/feeSelector/utils/FeeCalculationUtilsTest.kt index 76958ee240..7cfd18cb02 100644 --- a/features/send-v2/api/src/test/java/com/tangem/features/send/v2/api/subcomponents/feeSelector/utils/FeeCalculationUtilsTest.kt +++ b/features/send-v2/api/src/test/java/com/tangem/features/send/v2/api/subcomponents/feeSelector/utils/FeeCalculationUtilsTest.kt @@ -172,6 +172,7 @@ class FeeCalculationUtilsTest { hasCurrentNetworkTransactions = false, pendingTransactions = emptySet(), networkAddress = mockk(relaxed = true), + yieldSupplyStatus = null, sources = CryptoCurrencyStatus.Sources(), ) } else { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt index 2597f5a9d6..43091d08a1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt @@ -105,11 +105,12 @@ internal class FeeSelectorAlertFactory @Inject constructor( DialogMessage( message = resourceReference(id = R.string.send_notification_high_fee_title), dismissOnFirstAction = true, + onDismissRequest = { stopAction() }, firstActionBuilder = { - okAction { proceedAction(); onDismissRequest() } - }, - secondActionBuilder = { - cancelAction { stopAction(); onDismissRequest() } + EventMessageAction( + title = resourceReference(R.string.common_understand), + onClick = { stopAction(); onDismissRequest() }, + ) }, ), ) 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 index 7e9b481bef..8b04dae993 100644 --- 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 @@ -129,6 +129,9 @@ internal class FeeSelectorModel @Inject constructor( ) } uiState.update(FeeItemSelectedTransformer(feeItem)) + if (feeItem !is FeeItem.Custom) { + onDoneClick() + } } override fun onCustomFeeValueChange(index: Int, value: String) { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt index 2f403f5b0c..793262545b 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt @@ -156,7 +156,7 @@ private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifi val fiatRate = state.feeFiatRateUM Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { EllipsisText( - text = if (fiatRate != null) { + text = if (state.feeExtraInfo.isFeeConvertibleToFiat && fiatRate != null) { getFiatString( value = state.selectedFeeItem.fee.amount.value, rate = fiatRate.rate, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt index c99b647b80..0027bda872 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt @@ -82,15 +82,19 @@ internal fun FeeSelectorModalBottomSheet( modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp), ) }, - footer = { - PrimaryButton( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - enabled = state.isPrimaryButtonEnabled, - text = stringResourceSafe(R.string.common_done), - onClick = feeSelectorIntents::onDoneClick, - ) + footer = if (state.selectedFeeItem is FeeItem.Custom) { + { + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + enabled = state.isPrimaryButtonEnabled, + text = stringResourceSafe(R.string.common_done), + onClick = feeSelectorIntents::onDoneClick, + ) + } + } else { + null }, ) } @@ -166,7 +170,7 @@ private fun FeeSelectorItems( ).fee(canBeLower = state.feeExtraInfo.isFeeApproximate) }, ), - postDot = if (feeFiatRateUM != null) { + postDot = if (state.feeExtraInfo.isFeeConvertibleToFiat && feeFiatRateUM != null) { getFiatReference( value = item.fee.amount.value, rate = feeFiatRateUM.rate, @@ -426,7 +430,14 @@ private class FeeSelectorUMContentProvider : CollectionPreviewParameterProvider< FeeItem.Fast(fee = Fee.Common(Amount(value = BigDecimal("0.03"), blockchain = Blockchain.Ethereum))), customFeeItem, ), - selectedFeeItem = customFeeItem, + selectedFeeItem = FeeItem.Slow( + fee = Fee.Common( + Amount( + value = BigDecimal("0.01"), + blockchain = Blockchain.Ethereum, + ), + ), + ), feeExtraInfo = FeeExtraInfo( isFeeApproximate = true, isFeeConvertibleToFiat = true, 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 c67ed750cb..49efa0b9db 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 @@ -10,6 +10,7 @@ import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationUM +import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -21,14 +22,12 @@ 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.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.NeverShowTapHelpUseCase import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase @@ -45,6 +44,8 @@ import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback import com.tangem.features.send.v2.api.entity.FeeNonce import com.tangem.features.send.v2.api.params.FeeSelectorParams.FeeStateConfiguration import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger @@ -90,13 +91,15 @@ internal class SendConfirmModel @Inject constructor( private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val sendTransactionUseCase: SendTransactionUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger, private val sendFeeCheckReloadListener: SendFeeCheckReloadListener, + private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener, + private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger, private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger, private val notificationsUpdateListener: SendNotificationsUpdateListener, private val alertFactory: SendConfirmAlertFactory, @@ -277,7 +280,11 @@ internal class SendConfirmModel @Inject constructor( verifyAndSendTransaction() } else { modelScope.launch { - sendFeeCheckReloadTrigger.triggerCheckUpdate() + if (uiState.value.isRedesignEnabled) { + feeSelectorCheckReloadTrigger.triggerCheckUpdate() + } else { + sendFeeCheckReloadTrigger.triggerCheckUpdate() + } } } } @@ -329,15 +336,9 @@ internal class SendConfirmModel @Inject constructor( ), ) - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = - getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return - modelScope.launch { - sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo)) + val metaInfo = getWalletMetaInfoUseCase.invoke(userWallet.walletId).getOrNull() ?: return@launch + sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(walletMetaInfo = metaInfo)) } } @@ -514,8 +515,17 @@ internal class SendConfirmModel @Inject constructor( private fun subscribeOnCheckFeeResultUpdates() { sendFeeCheckReloadListener.checkReloadResultFlow.onEach { isFeeResultSuccess -> + sendIdleTimer = SystemClock.elapsedRealtime() + if (isFeeResultSuccess) { + _uiState.update(SendConfirmSendingStateTransformer(isSending = true)) + verifyAndSendTransaction() + } else { + _uiState.update(SendConfirmSendingStateTransformer(isSending = false)) + } + }.launchIn(modelScope) + feeSelectorCheckReloadListener.checkReloadResultFlow.onEach { isFeeResultSuccess -> + sendIdleTimer = SystemClock.elapsedRealtime() if (isFeeResultSuccess) { - sendIdleTimer = SystemClock.elapsedRealtime() _uiState.update(SendConfirmSendingStateTransformer(isSending = true)) verifyAndSendTransaction() } else { @@ -655,7 +665,7 @@ internal class SendConfirmModel @Inject constructor( } else -> resourceReference(R.string.common_send) }, - iconRes = R.drawable.ic_tangem_24, + iconRes = walletInterationIcon(userWallet), isIconVisible = isReadyToSend, isEnabled = confirmUM.isPrimaryButtonEnabled, isHapticClick = isReadyToSend, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt index 22e99488fc..163e6764b0 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt @@ -71,7 +71,9 @@ internal class SendConfirmationNotificationsTransformerV2( val fiatAmountValue = amountUM.amountTextField.fiatAmount.value val fiatFeeValue = feeSelectorUM.feeFiatRateUM?.rate?.let { fee.amount.value?.multiply(it) } - val fiatSendingValue = if (feeSelectorUM.feeFiatRateUM != null) { + val isFeeConvertibleToFiat = feeSelectorUM.feeExtraInfo.isFeeConvertibleToFiat + + val fiatSendingValue = if (isFeeConvertibleToFiat) { fiatFeeValue?.let { fiatAmountValue?.plus(it) } } else { fiatAmountValue @@ -85,7 +87,7 @@ internal class SendConfirmationNotificationsTransformerV2( } val fiatFee = formatFooterFiatFee( amount = fee.amount.copy(value = fiatFeeValue), - isFeeConvertibleToFiat = feeSelectorUM.feeFiatRateUM != null, + isFeeConvertibleToFiat = isFeeConvertibleToFiat, isFeeApproximate = feeSelectorUM.feeExtraInfo.isFeeApproximate, appCurrency = appCurrency, ) @@ -98,7 +100,7 @@ internal class SendConfirmationNotificationsTransformerV2( ) } else { resourceReference( - id = if (feeSelectorUM.feeFiatRateUM != null) { + id = if (isFeeConvertibleToFiat) { R.string.send_summary_transaction_description } else { R.string.send_summary_transaction_description_no_fiat_fee 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 1aa522334b..a6fec59187 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 @@ -18,7 +18,7 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.card.common.util.cardTypesResolver -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo @@ -26,7 +26,6 @@ import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isMultiCurrency -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase @@ -91,7 +90,7 @@ internal class SendModel @Inject constructor( private val parseQrCodeUseCase: ParseQrCodeUseCase, private val sendConfirmAlertFactory: SendConfirmAlertFactory, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, @@ -472,15 +471,9 @@ internal class SendModel @Inject constructor( ), ) - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = - getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return - modelScope.launch { - sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo)) + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch + sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(walletMetaInfo = metaInfo)) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt index 3d3f4d15fe..11b5fd5a50 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -6,6 +6,7 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.common.routing.AppRouter import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationUM +import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -16,13 +17,11 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.NeverShowTapHelpUseCase import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase @@ -35,6 +34,8 @@ import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.SendScreenSource import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.send.v2.common.CommonSendRoute @@ -78,12 +79,14 @@ internal class NFTSendConfirmModel @Inject constructor( private val sendTransactionUseCase: SendTransactionUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger, private val notificationsUpdateListener: SendNotificationsUpdateListener, private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger, private val sendFeeCheckReloadListener: SendFeeCheckReloadListener, + private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger, + private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener, private val alertFactory: SendConfirmAlertFactory, private val urlOpener: UrlOpener, private val shareManager: ShareManager, @@ -208,7 +211,11 @@ internal class NFTSendConfirmModel @Inject constructor( verifyAndSendTransaction() } else { modelScope.launch { - sendFeeCheckReloadTrigger.triggerCheckUpdate() + if (uiState.value.isRedesignEnabled) { + feeSelectorCheckReloadTrigger.triggerCheckUpdate() + } else { + sendFeeCheckReloadTrigger.triggerCheckUpdate() + } } } } @@ -242,15 +249,9 @@ internal class NFTSendConfirmModel @Inject constructor( ), ) - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = - getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return - modelScope.launch { - sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo)) + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch + sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(walletMetaInfo = metaInfo)) } } @@ -380,8 +381,17 @@ internal class NFTSendConfirmModel @Inject constructor( private fun subscribeOnCheckFeeResultUpdates() { sendFeeCheckReloadListener.checkReloadResultFlow.onEach { isFeeResultSuccess -> + sendIdleTimer = SystemClock.elapsedRealtime() + if (isFeeResultSuccess) { + _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = true)) + verifyAndSendTransaction() + } else { + _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = false)) + } + }.launchIn(modelScope) + feeSelectorCheckReloadListener.checkReloadResultFlow.onEach { isFeeResultSuccess -> + sendIdleTimer = SystemClock.elapsedRealtime() if (isFeeResultSuccess) { - sendIdleTimer = SystemClock.elapsedRealtime() _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = true)) verifyAndSendTransaction() } else { @@ -507,7 +517,7 @@ internal class NFTSendConfirmModel @Inject constructor( } else -> resourceReference(R.string.common_send) }, - iconRes = R.drawable.ic_tangem_24, + iconRes = walletInterationIcon(userWallet), isIconVisible = isReadyToSend, isEnabled = confirmUM.isPrimaryButtonEnabled, isHapticClick = isReadyToSend, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt index d69431a549..2aeb3ab7e0 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt @@ -61,10 +61,11 @@ internal class NFTSendConfirmationNotificationsTransformerV2( val fee = feeSelectorUM?.selectedFeeItem?.fee ?: return TextReference.EMPTY val fiatFeeValue = feeSelectorUM.feeFiatRateUM?.rate?.let { fee.amount.value?.multiply(it) } + val isFeeConvertibleToFiat = feeSelectorUM.feeExtraInfo.isFeeConvertibleToFiat val fiatFee = formatFooterFiatFee( amount = fee.amount.copy(value = fiatFeeValue), - isFeeConvertibleToFiat = feeSelectorUM.feeFiatRateUM != null, + isFeeConvertibleToFiat = isFeeConvertibleToFiat, isFeeApproximate = feeSelectorUM.feeExtraInfo.isFeeApproximate, appCurrency = appCurrency, ) @@ -77,7 +78,7 @@ internal class NFTSendConfirmationNotificationsTransformerV2( ) } else { resourceReference( - id = if (feeSelectorUM.feeFiatRateUM != null) { + id = if (isFeeConvertibleToFiat) { R.string.send_summary_transaction_description } else { R.string.send_summary_transaction_description_no_fiat_fee diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt index 11d9d7ba78..9660b61db5 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt @@ -14,7 +14,7 @@ import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.card.common.util.cardTypesResolver -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo @@ -22,8 +22,10 @@ import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.requireColdWallet -import com.tangem.domain.tokens.* +import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase @@ -63,15 +65,13 @@ internal class NFTSendModel @Inject constructor( private val router: Router, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, - private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val createNFTTransferTransactionUseCase: CreateNFTTransferTransactionUseCase, private val getFeeUseCase: GetFeeUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val alertFactory: SendConfirmAlertFactory, private val sendFeatureToggles: SendFeatureToggles, @@ -179,16 +179,10 @@ internal class NFTSendModel @Inject constructor( ifRight = { wallet -> userWallet = wallet - cryptoCurrency = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.firstOrNull { it is CryptoCurrency.Coin && it.network == nftAsset.network } - } else { - getCryptoCurrenciesUseCase(userWalletId).getOrNull() - ?.filterIsInstance() - ?.firstOrNull { it.network == nftAsset.network } - } + cryptoCurrency = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.firstOrNull { it is CryptoCurrency.Coin && it.network == nftAsset.network } ?: return@launch getCurrenciesStatusUpdates( @@ -224,15 +218,9 @@ internal class NFTSendModel @Inject constructor( ), ) - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = - getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return - modelScope.launch { - sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo)) + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch + sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(walletMetaInfo = metaInfo)) } } @@ -251,7 +239,7 @@ internal class NFTSendModel @Inject constructor( ).getOrNull() ?: cryptoStatus if (uiState.value.destinationUM is DestinationUM.Empty) { - router.replaceAll(CommonSendRoute.Destination(isEditMode = false)) + router.replaceAll(Destination(isEditMode = false)) } }, ifLeft = { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt index 107c608bb2..4f227ac46c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt @@ -100,11 +100,12 @@ internal class SendFeeAlertFactory @Inject constructor( DialogMessage( message = resourceReference(id = R.string.send_notification_high_fee_title), dismissOnFirstAction = true, + onDismissRequest = { stopAction() }, firstActionBuilder = { - okAction { proceedAction(); onDismissRequest() } - }, - secondActionBuilder = { - cancelAction { stopAction(); onDismissRequest() } + EventMessageAction( + title = resourceReference(R.string.common_understand), + onClick = { stopAction(); onDismissRequest() }, + ) }, ), ) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt index 575ee282d4..cae468e2b2 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt @@ -6,14 +6,12 @@ import com.tangem.common.routing.AppRouter import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.GetStakingAvailabilityUseCase import com.tangem.domain.staking.GetYieldUseCase import com.tangem.domain.staking.model.StakingAvailability -import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import dagger.assisted.Assisted @@ -29,9 +27,7 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( @Assisted private val queryParams: Map, private val appRouter: AppRouter, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, - private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val getYieldUseCase: GetYieldUseCase, private val getStakingAvailabilityUseCase: GetStakingAvailabilityUseCase, ) : StakingDeepLinkHandler { @@ -56,17 +52,10 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( } scope.launch { - val cryptoCurrency = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(selectedUserWalletId), - ) - .orEmpty() - } else { - getCryptoCurrenciesUseCase(userWalletId = selectedUserWalletId).getOrElse { - Timber.e("Error on getting crypto currency list") - return@launch - } - } + val cryptoCurrency = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(selectedUserWalletId), + ) + .orEmpty() .firstOrNull { val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 31e0e9e58c..81379472ab 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -23,7 +23,7 @@ import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo @@ -32,9 +32,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.* import com.tangem.domain.models.staking.action.StakingActionType -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.staking.* import com.tangem.domain.staking.analytics.StakeScreenSource import com.tangem.domain.staking.analytics.StakingAnalyticsEvent @@ -107,7 +105,7 @@ internal class StakingModel @Inject constructor( private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, private val getAllowanceUseCase: GetAllowanceUseCase, private val vibratorHapticManager: VibratorHapticManager, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, @@ -154,7 +152,11 @@ internal class StakingModel @Inject constructor( private var feeCryptoCurrencyStatus: CryptoCurrencyStatus? = null private var minimumTransactionAmount: EnterAmountBoundary? = null - private var userWallet: UserWallet by Delegates.notNull() + private val userWallet by lazy { + requireNotNull( + getUserWalletUseCase(userWalletId).getOrNull(), + ) { "No wallet found for id: $userWalletId" } + } private var appCurrency: AppCurrency by Delegates.notNull() private val balancesToShow: List @@ -224,6 +226,7 @@ internal class StakingModel @Inject constructor( subscribeOnSelectedAppCurrency() subscribeOnBalanceHiding() subscribeOnCurrencyStatusUpdates() + stateController.initializeWithUserWallet(userWallet) } override fun onDestroy() { @@ -608,6 +611,7 @@ internal class StakingModel @Inject constructor( override fun showApprovalBottomSheet() { stateController.update( ShowApprovalBottomSheetTransformer( + userWallet = userWallet, appCurrencyProvider = Provider { appCurrency }, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, @@ -859,13 +863,8 @@ internal class StakingModel @Inject constructor( modelScope.launch { val network = cryptoCurrencyStatus.currency.network - if (userWallet is UserWallet.Hot) { - return@launch // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = - getCardInfoUseCase(userWallet.requireColdWallet().scanResponse) - .getOrElse { error("CardInfo must be not null") } + val metaInfo = + getWalletMetaInfoUseCase(userWallet.walletId).getOrElse { error("CardInfo must be not null") } val amountState = uiState.value.amountState as? AmountState.Data val confirmationState = uiState.value.confirmationState as? StakingStates.ConfirmationState.Data val validatorState = uiState.value.validatorState as? StakingStates.ValidatorState.Data @@ -887,7 +886,7 @@ internal class StakingModel @Inject constructor( ) val email = FeedbackEmailType.StakingProblem( - cardInfo = cardInfo, + walletMetaInfo = metaInfo, validatorName = validator?.name, transactionTypes = transactionsInProgress.map { it.type.name }, unsignedTransactions = transactionsInProgress.map { it.unsignedTransaction }, @@ -929,17 +928,6 @@ internal class StakingModel @Inject constructor( } private fun subscribeOnCurrencyStatusUpdates() { - getUserWalletUseCase(userWalletId).fold( - ifRight = { wallet -> - userWallet = wallet - }, - ifLeft = { - stakingEventFactory.createGenericErrorAlert(it.toString()) - stateController.update( - SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus = cryptoCurrencyStatus), - ) - }, - ) getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( userWalletId = userWalletId, currencyId = cryptoCurrencyId, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt index ed6fbd3a57..e1e93296cf 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt @@ -6,6 +6,7 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.presentation.state.events.StakingEvent import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub @@ -33,6 +34,14 @@ internal class StakingStateController @Inject constructor( private val buttonsTransformer = SetButtonsStateTransformer(urlOpener) private val titleTransformer = SetTitleTransformer + fun initializeWithUserWallet(userWallet: UserWallet) { + mutableUiState.update { + it.copy( + showColdWalletInteractionIcon = userWallet is UserWallet.Cold, + ) + } + } + fun update(function: (StakingUiState) -> StakingUiState) { mutableUiState.update(function = function) mutableUiState.update(function = buttonsTransformer::transform) @@ -88,6 +97,7 @@ internal class StakingStateController @Inject constructor( actionType = StakingActionCommonType.Enter(skipEnterAmount = false), buttonsState = NavigationButtonsState.Empty, balanceState = null, + showColdWalletInteractionIcon = true, ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt index 3f8262760b..2a9a204e5a 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt @@ -42,6 +42,7 @@ internal data class StakingUiState( val buttonsState: NavigationButtonsState, val event: StateEvent, val balanceState: BalanceState?, + val showColdWalletInteractionIcon: Boolean, ) { fun copyWrapped( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt index 4c0166c007..acaeb8948e 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt @@ -49,7 +49,7 @@ internal class SetButtonsStateTransformer( val isPrimaryButtonDisabled = prevState.isPrimaryButtonDisabled() return NavigationButton( textReference = prevState.getButtonText(), - iconRes = R.drawable.ic_tangem_24, + iconRes = R.drawable.ic_tangem_24.takeIf { prevState.showColdWalletInteractionIcon }, isDimmed = isPrimaryButtonDisabled, isIconVisible = isIconVisible, showProgress = isInProgress, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt index c39ac9a72f..4e3bac09c8 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt @@ -2,6 +2,7 @@ package com.tangem.features.staking.impl.presentation.state.transformers.approva import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.bottomsheet.permission.state.* +import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList @@ -10,6 +11,7 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.FeeState import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -18,6 +20,7 @@ import com.tangem.utils.Provider import com.tangem.utils.transformer.Transformer internal class ShowApprovalBottomSheetTransformer( + private val userWallet: UserWallet, private val appCurrencyProvider: Provider, private val cryptoCurrencyStatusProvider: Provider, private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?, @@ -75,6 +78,7 @@ internal class ShowApprovalBottomSheetTransformer( footerText = resourceReference(R.string.staking_give_permission_fee_footer), onChangeApproveType = prevState.clickIntents::onApproveTypeChange, ), + walletInteractionIcon = walletInterationIcon(userWallet), onCancel = onDismiss, ), ), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt index e1a7c81bd2..5170756b32 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt @@ -21,7 +21,7 @@ import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.StakingStep @@ -47,7 +47,7 @@ internal fun StakingScreen(uiState: StakingUiState) { .fillMaxSize() .imePadding() .systemBarsPadding() - .testTag(StakingSendScreenTestTags.SCREEN_CONTAINER), + .testTag(SendScreenTestTags.SCREEN_CONTAINER), horizontalAlignment = Alignment.CenterHorizontally, ) { StakingAppBar( diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt index 9ea883b4e5..d5360e2add 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt @@ -8,14 +8,13 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.express.models.ExpressError -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.features.swap.v2.impl.R import javax.inject.Inject @@ -24,7 +23,7 @@ import javax.inject.Inject internal class SwapAlertFactory @Inject constructor( private val uiMessageSender: UiMessageSender, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, ) { fun getGenericErrorState(expressError: ExpressError, onFailedTxEmailClick: () -> Unit, popBack: () -> Unit = {}) { @@ -96,16 +95,11 @@ internal class SwapAlertFactory @Inject constructor( ), ) - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = - getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return sendFeedbackEmailUseCase( type = FeedbackEmailType.SwapProblem( - cardInfo = cardInfo, + walletMetaInfo = metaInfo, providerName = confirmData?.quote?.provider?.name.orEmpty(), txId = txId.orEmpty(), ), diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 13b3ec8c25..82ddd80851 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -9,6 +9,7 @@ import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationUM +import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic @@ -439,7 +440,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( } else -> resourceReference(R.string.common_send) }, - iconRes = R.drawable.ic_tangem_24, + iconRes = walletInterationIcon(params.userWallet), isIconVisible = isReadyToSend, isHapticClick = isReadyToSend, isEnabled = confirmUM.isPrimaryButtonEnabled, diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt index 36ce700f4b..05d1d541e3 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt @@ -8,7 +8,7 @@ sealed class ExpressTransactionModel { abstract val fromAmount: SwapAmount abstract val toAmount: SwapAmount - abstract val txValue: String + abstract val txValue: String? abstract val txId: String abstract val txTo: String abstract val txExtraId: String? @@ -19,7 +19,7 @@ sealed class ExpressTransactionModel { data class DEX( override val fromAmount: SwapAmount, override val toAmount: SwapAmount, - override val txValue: String, + override val txValue: String?, override val txId: String, override val txTo: String, override val txExtraId: String?, @@ -32,7 +32,7 @@ sealed class ExpressTransactionModel { data class CEX( override val fromAmount: SwapAmount, override val toAmount: SwapAmount, - override val txValue: String, + override val txValue: String?, override val txId: String, override val txTo: String, override val txExtraId: String?, 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 1d9a24f713..05128a4991 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 @@ -52,6 +52,8 @@ import timber.log.Timber import java.math.BigDecimal import java.math.BigInteger import java.math.RoundingMode +import android.util.Base64 +import com.tangem.blockchainsdk.utils.toNetworkId @Suppress("LargeClass", "LongParameterList") internal class SwapInteractorImpl @AssistedInject constructor( @@ -73,7 +75,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val appCurrencyRepository: AppCurrencyRepository, private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val initialToCurrencyResolver: InitialToCurrencyResolver, private val validateTransactionUseCase: ValidateTransactionUseCase, private val estimateFeeUseCase: EstimateFeeUseCase, @@ -281,16 +282,29 @@ internal class SwapInteractorImpl @AssistedInject constructor( val networkId = fromToken.currency.network.backendId when (provider.type) { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - manageDex( - networkId = networkId, - fromToken = fromToken, - toToken = toToken, - provider = provider, - selectedFee = selectedFee, - amount = amount, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - expressOperationType = ExpressOperationType.SWAP, - ) + if (isSolana(networkId)) { + manageDexSolana( + networkId = networkId, + fromToken = fromToken, + toToken = toToken, + provider = provider, + selectedFee = selectedFee, + amount = amount, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + expressOperationType = ExpressOperationType.SWAP, + ) + } else { + manageDex( + networkId = networkId, + fromToken = fromToken, + toToken = toToken, + provider = provider, + selectedFee = selectedFee, + amount = amount, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + expressOperationType = ExpressOperationType.SWAP, + ) + } } ExchangeProviderType.CEX -> { manageCex( @@ -373,6 +387,57 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } + private suspend fun manageDexSolana( + networkId: String, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + provider: SwapProvider, + selectedFee: FeeType, + amount: SwapAmount, + isBalanceWithoutFeeEnough: Boolean, + expressOperationType: ExpressOperationType, + ): Pair { + val maybeQuotes = repository.findBestQuote( + userWallet = userWallet, + fromContractAddress = fromToken.currency.getContractAddress(), + fromNetwork = fromToken.currency.network.backendId, + toContractAddress = toToken.currency.getContractAddress(), + toNetwork = toToken.currency.network.backendId, + fromAmount = amount.toStringWithRightOffset(), + fromDecimals = amount.decimals, + toDecimals = toToken.currency.decimals, + providerId = provider.providerId, + rateType = RateType.FLOAT, + ) + + return if (isBalanceWithoutFeeEnough) { + provider to loadDexSwapData( + provider = provider, + networkId = networkId, + fromToken = fromToken, + toToken = toToken, + amount = amount, + selectedFee = selectedFee, + expressOperationType = expressOperationType, + ) + } else { + provider to getQuotesState( + provider = provider, + quoteDataModel = maybeQuotes, + amount = amount, + fromToken = fromToken, + toToken = toToken, + networkId = networkId, + isAllowedToSpend = true, + isBalanceWithoutFeeEnough = false, + txFee = TxFeeState.Empty, + transactionFee = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex + selectedFee = selectedFee, + ) + } + } + private suspend fun manageCex( networkId: String, fromToken: CryptoCurrencyStatus, @@ -539,15 +604,27 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - onSwapDex( - provider = swapProvider, - networkId = currencyToSend.currency.network.backendId, - swapData = requireNotNull(swapData), - currencyToSendStatus = currencyToSend, - currencyToGetStatus = currencyToGet, - txFee = fee, - amountToSwap = amountToSwap, - ) + val networkId = currencyToSend.currency.network.backendId + if (isSolana(networkId)) { + onSwapSolanaDex( + provider = swapProvider, + networkId = currencyToSend.currency.network.backendId, + swapData = requireNotNull(swapData), + currencyToSendStatus = currencyToSend, + currencyToGetStatus = currencyToGet, + amountToSwap = amountToSwap, + ) + } else { + onSwapDex( + provider = swapProvider, + networkId = currencyToSend.currency.network.backendId, + swapData = requireNotNull(swapData), + currencyToSendStatus = currencyToSend, + currencyToGetStatus = currencyToGet, + txFee = fee, + amountToSwap = amountToSwap, + ) + } } } } @@ -603,11 +680,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( txFee: TxFee, ): SwapTransactionState { val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } + val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" } val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) val derivationPath = currencyToSendStatus.currency.network.derivationPath.value val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX val dataToSign = dexTransaction.txData - val amountToSend = createNativeAmountForDex(swapData.transaction.txValue, currencyToSendStatus.currency.network) + val amountToSend = createNativeAmountForDex(txValue, currencyToSendStatus.currency.network) val txData = createTransactionUseCase( amount = amountToSend, fee = txFee.fee, @@ -620,7 +698,59 @@ internal class SwapInteractorImpl @AssistedInject constructor( Timber.e(it, "Failed to create swap dex tx data") return SwapTransactionState.Error.UnknownError } + return handleSwapResult( + provider = provider, + networkId = networkId, + swapData = swapData, + currencyToSendStatus = currencyToSendStatus, + currencyToGetStatus = currencyToGetStatus, + amount = amount, + derivationPath = derivationPath, + txData = txData, + payInAddress = txData.destinationAddress, + ) + } + private suspend fun onSwapSolanaDex( + provider: SwapProvider, + networkId: String, + swapData: SwapDataModel, + currencyToSendStatus: CryptoCurrencyStatus, + currencyToGetStatus: CryptoCurrencyStatus, + amountToSwap: String, + ): SwapTransactionState { + val dexTransaction = swapData.transaction as? ExpressTransactionModel.DEX + val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } + val txDataBase64 = requireNotNull(dexTransaction?.txData) { "txData is null" } + val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) + val derivationPath = currencyToSendStatus.currency.network.derivationPath.value + val compiledTransaction = TransactionData.Compiled( + value = TransactionData.Compiled.Data.Bytes(Base64.decode(txDataBase64, Base64.NO_WRAP)), + ) + return handleSwapResult( + provider = provider, + networkId = networkId, + swapData = swapData, + currencyToSendStatus = currencyToSendStatus, + currencyToGetStatus = currencyToGetStatus, + amount = amount, + derivationPath = derivationPath, + txData = compiledTransaction, + payInAddress = swapData.transaction.txTo, + ) + } + + private suspend fun handleSwapResult( + provider: SwapProvider, + networkId: String, + swapData: SwapDataModel, + currencyToSendStatus: CryptoCurrencyStatus, + currencyToGetStatus: CryptoCurrencyStatus, + amount: SwapAmount, + derivationPath: String?, + txData: TransactionData, + payInAddress: String, + ): SwapTransactionState { val result = sendTransactionUseCase( txData = txData, userWallet = userWallet, @@ -633,7 +763,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( txId = swapData.transaction.txId, fromNetwork = currencyToSendStatus.currency.network.backendId, fromAddress = currencyToSendStatus.value.networkAddress?.defaultAddress?.value.orEmpty(), - payInAddress = txData.destinationAddress, + payInAddress = payInAddress, txHash = txHash, payInExtraId = swapData.transaction.txExtraId, ) @@ -1195,13 +1325,21 @@ internal class SwapInteractorImpl @AssistedInject constructor( val otherNativeFee = transaction.otherNativeFeeWei ?.movePointLeft(nativeCoinDecimals) ?: BigDecimal.ZERO - val txFeeState = getFeeDataForDexSwap( - network = fromToken.currency.network, - transaction = transaction, - fromToken = fromToken.currency, - ) - .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) - .toTxFeeState(fromToken.currency, otherNativeFee) + val txFeeState = if (isSolana(networkId)) { + getFeeDataForSolanaDexSwap( + network = fromToken.currency.network, + transaction = transaction, + ) + .toTxFeeState(fromToken.currency, otherNativeFee) + } else { + getFeeDataForDexSwap( + network = fromToken.currency.network, + transaction = transaction, + fromToken = fromToken.currency, + ) + .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) + .toTxFeeState(fromToken.currency, otherNativeFee) + } val includeFeeInAmount = IncludeFeeInAmount.Excluded // exclude for dex val feeByPriority = selectFeeByType(feeType = selectedFee, txFeeState = txFeeState) @@ -1271,11 +1409,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( fromToken: CryptoCurrency, ): TransactionFee { return try { + val txAmountValue = transaction.txValue ?: error("unable to get txValue") val nativeBalance = userWalletManager.getNativeTokenBalance( networkId = network.backendId, derivationPath = fromToken.network.derivationPath.value, ) ?: ProxyAmount.empty() - val amountToSend = createNativeAmountForDex(transaction.txValue, fromToken.network) + val amountToSend = createNativeAmountForDex(txAmountValue, fromToken.network) // transaction.txValue is always native coin if (nativeBalance.value < amountToSend.value) { error("It's impossible to calculate fee for nativeBalance.value < amountToSend.value") @@ -1307,6 +1446,22 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } + private suspend fun getFeeDataForSolanaDexSwap( + network: Network, + transaction: ExpressTransactionModel.DEX, + ): TransactionFee { + val txData = transaction.txData + val transactionData = TransactionData.Compiled( + value = TransactionData.Compiled.Data.Bytes(Base64.decode(txData, Base64.NO_WRAP)), + ) + + return getFeeUseCase( + transactionData = transactionData, + network = network, + userWallet = userWallet, + ).getOrNull() ?: error("unable to calculate fee") + } + @Suppress("LongParameterList") private suspend fun updateBalances( provider: SwapProvider, @@ -1771,14 +1926,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (feePaidCurrency.balance > fee.multiply(percentsToFeeIncrease)) { SwapFeeState.Enough } else { - val tokens = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + val tokens = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() val token = tokens .filterIsInstance() @@ -1863,6 +2014,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( .orEmpty() } + private fun isSolana(networkId: String): Boolean { + return networkId == Blockchain.Solana.toNetworkId() + } + companion object { private const val INCREASE_GAS_LIMIT_FOR_DEX = 112 // 12% private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5% diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index b1c067828a..6a7f2b979e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -24,7 +24,7 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.express.models.ExpressOperationType -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo @@ -32,9 +32,7 @@ import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.promo.GetStoryContentUseCase import com.tangem.domain.promo.ShouldShowStoriesUseCase import com.tangem.domain.promo.models.StoryContentIds @@ -78,7 +76,6 @@ import java.text.DecimalFormat import java.text.NumberFormat import java.util.Locale import javax.inject.Inject -import kotlin.properties.Delegates typealias SuccessLoadedSwapData = Map @@ -95,7 +92,7 @@ internal class SwapModel @Inject constructor( private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, @@ -116,17 +113,22 @@ internal class SwapModel @Inject constructor( private val userWalletId = params.userWalletId private val isInitiallyReversed = params.isInitialReverseOrder + private val userWallet by lazy { + requireNotNull( + getUserWalletUseCase(userWalletId).getOrNull(), + ) { "No wallet found for id: $userWalletId" } + } private val swapInteractor = swapInteractorFactory.create(userWalletId) private lateinit var initialFromStatus: CryptoCurrencyStatus private var initialToStatus: CryptoCurrencyStatus? = null - private var userWallet: UserWallet by Delegates.notNull() private var isBalanceHidden = true private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() private val stateBuilder = StateBuilder( + userWalletProvider = Provider { userWallet }, actions = createUiActions(), isBalanceHiddenProvider = Provider { isBalanceHidden }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), @@ -186,12 +188,10 @@ internal class SwapModel @Inject constructor( val toStatus = initialCurrencyTo?.let { getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWalletId, it.id).getOrNull() } - val wallet = getUserWalletUseCase(userWalletId).getOrNull() - if (fromStatus == null || wallet == null) { + if (fromStatus == null) { uiState = stateBuilder.addAlert(uiState = uiState, onDismiss = swapRouter::back) } else { - userWallet = wallet initialFromStatus = fromStatus initialToStatus = toStatus initTokens(isInitiallyReversed) @@ -1366,15 +1366,11 @@ internal class SwapModel @Inject constructor( ), ) - if (userWallet is UserWallet.Hot) { - return@launch // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = getCardInfoUseCase(userWallet.requireColdWallet().scanResponse) + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId) .getOrElse { error("CardInfo must be not null") } val email = FeedbackEmailType.SwapProblem( - cardInfo = cardInfo, + walletMetaInfo = metaInfo, providerName = dataState.selectedProvider?.name.orEmpty(), txId = transaction?.txId.orEmpty(), ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 2364dbd0a6..8b2076a065 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -76,6 +76,7 @@ sealed class SwapCardState { } data class SwapButton( + @DrawableRes val walletInteractionIcon: Int?, val enabled: Boolean, val onClick: () -> Unit, ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 0de047350a..ec50b17698 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -6,6 +6,7 @@ import com.tangem.common.ui.alerts.models.AlertDemoModeUM import com.tangem.common.ui.bottomsheet.permission.state.* import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.swapStoriesScreen.SwapStoriesFactory +import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.event.consumedEvent @@ -19,6 +20,7 @@ import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.promo.models.StoryContent import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter import com.tangem.feature.swap.converters.TokensDataConverter @@ -53,6 +55,7 @@ import kotlin.math.min */ @Suppress("LargeClass", "TooManyFunctions") internal class StateBuilder( + private val userWalletProvider: Provider, private val actions: UiActions, private val isBalanceHiddenProvider: Provider, private val appCurrencyProvider: Provider, @@ -111,7 +114,11 @@ internal class StateBuilder( isBalanceHidden = true, ), fee = FeeItemState.Empty, - swapButton = SwapButton(enabled = false, onClick = {}), + swapButton = SwapButton( + walletInteractionIcon = walletInterationIcon(userWalletProvider()), + enabled = false, + onClick = {}, + ), onRefresh = {}, onBackClicked = actions.onBackClicked, onChangeCardsClicked = actions.onChangeCardsClicked, @@ -167,6 +174,7 @@ internal class StateBuilder( notifications = notificationsFactory.getNotAvailableStateNotifications(fromToken.currency.name), fee = FeeItemState.Empty, swapButton = SwapButton( + walletInteractionIcon = walletInterationIcon(userWalletProvider()), enabled = false, onClick = { }, ), @@ -225,7 +233,11 @@ internal class StateBuilder( ), notifications = persistentListOf(), fee = FeeItemState.Empty, - swapButton = SwapButton(enabled = false, onClick = {}), + swapButton = SwapButton( + walletInteractionIcon = walletInterationIcon(userWalletProvider()), + enabled = false, + onClick = {}, + ), providerState = ProviderState.Loading(), permissionState = uiStateHolder.permissionState, changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, @@ -338,6 +350,7 @@ internal class StateBuilder( ), fee = feeState, swapButton = SwapButton( + walletInteractionIcon = walletInterationIcon(userWalletProvider()), enabled = getSwapButtonEnabled(notifications), onClick = actions.onSwapClick, ), @@ -465,6 +478,7 @@ internal class StateBuilder( permissionState = GiveTxPermissionState.Empty, fee = FeeItemState.Empty, swapButton = SwapButton( + walletInteractionIcon = walletInterationIcon(userWalletProvider()), enabled = false, onClick = actions.onSwapClick, ), @@ -561,6 +575,7 @@ internal class StateBuilder( isInsufficientFunds = false, fee = FeeItemState.Empty, swapButton = SwapButton( + walletInteractionIcon = walletInterationIcon(userWalletProvider()), enabled = false, onClick = { }, ), @@ -973,6 +988,7 @@ internal class StateBuilder( val config = GiveTxPermissionBottomSheetConfig( data = permissionState, onCancel = onDismiss, + walletInteractionIcon = walletInterationIcon(userWalletProvider()), ) return uiState.copy( bottomSheetConfig = TangemBottomSheetConfig( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index ada4986f2f..8fe8424317 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -382,7 +382,7 @@ private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> U PrimaryButtonIconEnd( modifier = Modifier.fillMaxWidth(), text = stringResourceSafe(id = R.string.swapping_swap_action), - iconResId = R.drawable.ic_tangem_24, + iconResId = state.swapButton.walletInteractionIcon, enabled = state.swapButton.enabled, onClick = state.swapButton.onClick, ) @@ -441,7 +441,7 @@ private val state = SwapStateHolder( ), SwapNotificationUM.Warning.NoAvailableTokensToSwap("POLYGON"), ), - swapButton = SwapButton(enabled = true, onClick = {}), + swapButton = SwapButton(enabled = true, onClick = {}, walletInteractionIcon = null), onRefresh = {}, onBackClicked = {}, onChangeCardsClicked = {}, diff --git a/features/tangempay/details/api/build.gradle.kts b/features/tangempay/details/api/build.gradle.kts index 77acdd5c22..7d64c9e77f 100644 --- a/features/tangempay/details/api/build.gradle.kts +++ b/features/tangempay/details/api/build.gradle.kts @@ -13,6 +13,9 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.ui) + /** Domain */ + implementation(projects.domain.models) + /** Compose */ implementation(deps.compose.runtime) } \ No newline at end of file diff --git a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index 64e11cecbe..bd9326f7e9 100644 --- a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -2,9 +2,9 @@ package com.tangem.features.tangempay.components import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId interface TangemPayDetailsComponent : ComposableContentComponent { - @Suppress("EmptyDefaultConstructor") // Will add params in Next PRs - class Params() + data class Params(val userWalletId: UserWalletId) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/tangempay/details/impl/build.gradle.kts b/features/tangempay/details/impl/build.gradle.kts index 4f9fb1c110..84f577edf1 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -13,12 +13,18 @@ android { dependencies { /** Core */ + implementation(projects.core.configToggles) implementation(projects.core.decompose) implementation(projects.core.ui) - implementation(projects.core.configToggles) /** Features api */ implementation(projects.features.tangempay.details.api) + implementation(projects.features.txhistory.api) + + /** Domain */ + implementation(projects.domain.balanceHiding) + implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.models) /** Compose */ implementation(deps.compose.foundation) @@ -29,4 +35,8 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Other */ + implementation(deps.kotlin.immutable.collections) + implementation(deps.timber) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt index 2c55cb6a3a..cd5e1f5b79 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt @@ -1,26 +1,36 @@ package com.tangem.features.tangempay.components -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.NavigationBar3ButtonsScrim +import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent +import com.tangem.features.tangempay.model.TangemPayDetailsModel +import com.tangem.features.tangempay.ui.TangemPayDetailsScreen import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -@Suppress("UnusedPrivateMember") internal class DefaultTangemPayDetailsComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: TangemPayDetailsComponent.Params, ) : AppComponentContext by appComponentContext, TangemPayDetailsComponent { + private val model: TangemPayDetailsModel = getOrCreateModel(params = params) + private val txHistoryComponent = DefaultTangemPayTxHistoryComponent( + appComponentContext = child("txHistoryComponent"), + params = DefaultTangemPayTxHistoryComponent.Params(userWalletId = params.userWalletId), + ) + @Composable override fun Content(modifier: Modifier) { - Box(modifier.fillMaxSize().background(Color.Red)) - // TODO("[REDACTED_JIRA]") + val state by model.uiState.collectAsStateWithLifecycle() + NavigationBar3ButtonsScrim() + TangemPayDetailsScreen(state = state, txHistoryComponent = txHistoryComponent, modifier = modifier) } @AssistedFactory diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/DefaultTangemPayTxHistoryComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/DefaultTangemPayTxHistoryComponent.kt new file mode 100644 index 0000000000..c0ccc0a291 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/DefaultTangemPayTxHistoryComponent.kt @@ -0,0 +1,26 @@ +package com.tangem.features.tangempay.components.txHistory + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.tangempay.model.TangemPayTxHistoryModel +import com.tangem.features.txhistory.entity.TxHistoryUM +import com.tangem.features.txhistory.ui.txHistoryItems +import kotlinx.coroutines.flow.StateFlow + +internal class DefaultTangemPayTxHistoryComponent( + appComponentContext: AppComponentContext, + params: Params, +) : AppComponentContext by appComponentContext, TangemPayTxHistoryComponent { + + private val model: TangemPayTxHistoryModel = getOrCreateModel(params = params) + override val state: StateFlow = model.uiState + + override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) { + txHistoryItems(listState, state) + } + + data class Params(val userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt new file mode 100644 index 0000000000..61d078d853 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt @@ -0,0 +1,174 @@ +package com.tangem.features.tangempay.components.txHistory + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import com.tangem.core.ui.components.transactions.state.TransactionState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.txhistory.entity.TxHistoryUM +import com.tangem.features.txhistory.ui.txHistoryItems +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +internal class PreviewTangemPayTxHistoryComponent(txHistoryUM: TxHistoryUM) : TangemPayTxHistoryComponent { + override val state: StateFlow = MutableStateFlow(txHistoryUM) + + override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) { + txHistoryItems(listState, state) + } + + companion object { + val loadingUM = TxHistoryUM.Loading(isBalanceHidden = true, onExploreClick = {}) + val emptyUM = TxHistoryUM.Empty(isBalanceHidden = true, onExploreClick = {}) + val contentUM = TxHistoryUM.Content( + isBalanceHidden = false, + loadMore = { false }, + items = persistentListOf( + TxHistoryUM.TxHistoryItemUM.Title(onExploreClick = {}), + TxHistoryUM.TxHistoryItemUM.GroupTitle(title = "Today", itemKey = "Today"), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-4.99 USD", + time = "16:41", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Starbucks"), + subtitle = stringReference("Food&Drinks"), + timestamp = 3464, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-126.20 USD", + time = "12:04", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + timestamp = 3465, + ), + ), + TxHistoryUM.TxHistoryItemUM.GroupTitle(title = "Yesterday", itemKey = "Yesterday"), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-4.99 USD", + time = "21:41", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Starbucks"), + subtitle = stringReference("Food&Drinks"), + timestamp = 3464, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-126.20 USD", + time = "10:04", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + timestamp = 3465, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-4.99 USD", + time = "19:41", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Starbucks"), + subtitle = stringReference("Food&Drinks"), + timestamp = 3464, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-126.20 USD", + time = "18:04", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + timestamp = 3465, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-4.99 USD", + time = "17:41", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Starbucks"), + subtitle = stringReference("Food&Drinks"), + timestamp = 3464, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-126.20 USD", + time = "16:04", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + timestamp = 3465, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-4.99 USD", + time = "15:41", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Starbucks"), + subtitle = stringReference("Food&Drinks"), + timestamp = 3464, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-126.20 USD", + time = "14:04", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + timestamp = 3465, + ), + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryComponent.kt new file mode 100644 index 0000000000..3b538ef7fd --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryComponent.kt @@ -0,0 +1,11 @@ +package com.tangem.features.tangempay.components.txHistory + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import com.tangem.features.txhistory.entity.TxHistoryUM +import kotlinx.coroutines.flow.StateFlow + +internal interface TangemPayTxHistoryComponent { + val state: StateFlow + fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt new file mode 100644 index 0000000000..ef72f17c45 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -0,0 +1,26 @@ +package com.tangem.features.tangempay.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.tangempay.model.TangemPayDetailsModel +import com.tangem.features.tangempay.model.TangemPayTxHistoryModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface TangemPayModelModule { + + @Binds + @IntoMap + @ClassKey(TangemPayDetailsModel::class) + fun bindTangemPayDetailsModel(model: TangemPayDetailsModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayTxHistoryModel::class) + fun bindTangemPayTxHistoryModel(model: TangemPayTxHistoryModel): Model +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt new file mode 100644 index 0000000000..85908dce5c --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt @@ -0,0 +1,9 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem +import kotlinx.collections.immutable.ImmutableList + +internal data class TangemPayDetailsTopBarConfig( + val onBackClick: () -> Unit, + val items: ImmutableList?, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt new file mode 100644 index 0000000000..dbb7977543 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -0,0 +1,35 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import kotlinx.collections.immutable.ImmutableList + +internal data class TangemPayDetailsUM( + val topBarConfig: TangemPayDetailsTopBarConfig, + val pullToRefreshConfig: PullToRefreshConfig, + val balanceBlockState: TangemPayDetailsBalanceBlockState, + val cardDetailsUM: TangemPayCardDetailsUM, + val isBalanceHidden: Boolean, +) + +data class TangemPayCardDetailsUM(val number: String, val expiry: String, val cvv: String, val onReveal: () -> Unit) + +internal sealed class TangemPayDetailsBalanceBlockState { + + abstract val actionButtons: ImmutableList + + data class Loading( + override val actionButtons: ImmutableList, + ) : TangemPayDetailsBalanceBlockState() + + data class Content( + override val actionButtons: ImmutableList, + val cryptoBalance: String, + val fiatBalance: String, + val isBalanceFlickering: Boolean, + ) : TangemPayDetailsBalanceBlockState() + + data class Error( + override val actionButtons: ImmutableList, + ) : TangemPayDetailsBalanceBlockState() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt new file mode 100644 index 0000000000..a39519d4a9 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -0,0 +1,61 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.model.transformers.TangemPayDetailsRefreshTransformer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import com.tangem.utils.transformer.update +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayDetailsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, +) : Model() { + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + private val refreshStateJobHolder = JobHolder() + + @Suppress("MagicNumber", "UnusedPrivateMember") + private fun onRefreshSwipe(refreshState: ShowRefreshState) { + uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = true)) + modelScope.launch { + // simulate update logic + delay(2000) + uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false)) + }.saveIn(refreshStateJobHolder) + } + + private fun getInitialState(): TangemPayDetailsUM { + return TangemPayDetailsUM( + topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = router::pop, items = null), + pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = ::onRefreshSwipe), + balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(actionButtons = persistentListOf()), + cardDetailsUM = TangemPayCardDetailsUM( + number = "•••• •••• •••• 1245", + expiry = "••/••", + cvv = "•••", + onReveal = {}, + ), + isBalanceHidden = false, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt new file mode 100644 index 0000000000..e8532b5dbe --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt @@ -0,0 +1,62 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent +import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent +import com.tangem.features.txhistory.entity.TxHistoryUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayTxHistoryModel @Inject constructor( + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, +) : Model() { + + private val params: DefaultTangemPayTxHistoryComponent.Params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + init { + handleBalanceHiding() + subscribeToUiItemChanges() + } + + @Suppress("MagicNumber") + private fun subscribeToUiItemChanges() { + modelScope.launch { + Timber.d("subscribeToUiItemChanges: ${params.userWalletId}") + delay(2000) + uiState.update { PreviewTangemPayTxHistoryComponent.contentUM } + } + } + + private fun handleBalanceHiding() { + getBalanceHidingSettingsUseCase() + .onEach { uiState.update { state -> state.copySealed(isBalanceHidden = it.isBalanceHidden) } } + .launchIn(modelScope) + } + + private fun onExploreClick() { + Timber.d("onExploreClick: open explorer") + } + + private fun getInitialState(): TxHistoryUM { + return TxHistoryUM.Loading(isBalanceHidden = true, onExploreClick = ::onExploreClick) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayDetailsRefreshTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayDetailsRefreshTransformer.kt new file mode 100644 index 0000000000..336a2b3a11 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayDetailsRefreshTransformer.kt @@ -0,0 +1,10 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.utils.transformer.Transformer + +internal class TangemPayDetailsRefreshTransformer(private val isRefreshing: Boolean) : Transformer { + override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + return prevState.copy(pullToRefreshConfig = prevState.pullToRefreshConfig.copy(isRefreshing = isRefreshing)) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt new file mode 100644 index 0000000000..f68617ebd5 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -0,0 +1,385 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.buttons.HorizontalActionChips +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TokenDetailsTopBarTestTags +import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent +import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.utils.StringsSigns.DASH_SIGN +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun TangemPayDetailsScreen( + state: TangemPayDetailsUM, + txHistoryComponent: TangemPayTxHistoryComponent, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { TangemPayDetailsTopAppBar(config = state.topBarConfig) }, + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), + containerColor = TangemTheme.colors.background.secondary, + ) { scaffoldPaddings -> + val listState = rememberLazyListState() + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + val txHistoryState by txHistoryComponent.state.collectAsStateWithLifecycle() + + TangemPullToRefreshContainer( + config = state.pullToRefreshConfig, + modifier = Modifier.padding(scaffoldPaddings), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues( + bottom = TangemTheme.dimens.spacing16 + bottomBarHeight, + ), + ) { + item( + key = TangemPayDetailsBalanceBlockState::class.java, + content = { + TangemPayDetailsBalanceBlock( + modifier = modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + state = state.balanceBlockState, + isBalanceHidden = state.isBalanceHidden, + ) + }, + ) + item( + key = TangemPayCardDetailsUM::class.java, + content = { + TangemPayCardDetailsBlock( + modifier = modifier + .padding(top = TangemTheme.dimens.spacing12) + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + state = state.cardDetailsUM, + ) + }, + ) + + with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) } + } + } + } +} + +// region Balance block +@Composable +internal fun TangemPayDetailsBalanceBlock( + state: TangemPayDetailsBalanceBlockState, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.roundedCornersMedium, + ) + .padding(vertical = 12.dp), + ) { + Text( + modifier = Modifier.padding(start = 12.dp), + text = "Tangem Pay", + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.subtitle2, + ) + FiatBalance( + modifier = Modifier.padding(start = 12.dp, top = 4.dp), + state = state, + isBalanceHidden = isBalanceHidden, + ) + CryptoBalance( + modifier = Modifier.padding(start = 12.dp, top = 4.dp), + state = state, + isBalanceHidden = isBalanceHidden, + ) + if (state.actionButtons.isNotEmpty()) { + HorizontalActionChips( + modifier = Modifier.padding(top = 12.dp), + buttons = state.actionButtons, + containerColor = TangemTheme.colors.background.primary, + contentPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing12), + ) + } + } +} + +@Composable +private fun FiatBalance( + state: TangemPayDetailsBalanceBlockState, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + when (state) { + is TangemPayDetailsBalanceBlockState.Loading -> RectangleShimmer( + modifier = modifier.size( + width = TangemTheme.dimens.size102, + height = TangemTheme.dimens.size32, + ), + ) + is TangemPayDetailsBalanceBlockState.Content -> Text( + modifier = modifier, + text = state.fiatBalance.orMaskWithStars(isBalanceHidden), + style = TangemTheme.typography.h2.applyBladeBrush( + isEnabled = state.isBalanceFlickering, + textColor = TangemTheme.colors.text.primary1, + ), + ) + is TangemPayDetailsBalanceBlockState.Error -> Text( + modifier = modifier, + text = DASH_SIGN.orMaskWithStars(isBalanceHidden), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + } +} + +@Composable +private fun CryptoBalance( + state: TangemPayDetailsBalanceBlockState, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + when (state) { + is TangemPayDetailsBalanceBlockState.Loading -> RectangleShimmer( + modifier = modifier.size( + width = TangemTheme.dimens.size70, + height = TangemTheme.dimens.size16, + ), + ) + is TangemPayDetailsBalanceBlockState.Content -> Text( + modifier = modifier, + text = state.cryptoBalance.orMaskWithStars(isBalanceHidden), + style = TangemTheme.typography.caption2.applyBladeBrush( + isEnabled = state.isBalanceFlickering, + textColor = TangemTheme.colors.text.tertiary, + ), + ) + is TangemPayDetailsBalanceBlockState.Error -> Text( + modifier = modifier, + text = DASH_SIGN.orMaskWithStars(isBalanceHidden), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } +} +// endregion + +// region Card details block +@Composable +private fun TangemPayCardDetailsBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.roundedCornersMedium, + ) + .padding(all = 12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Card details", + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.subtitle2, + ) + Text( + modifier = Modifier.clickable(onClick = state.onReveal), + text = "Reveal", + color = TangemTheme.colors.text.accent, + style = TangemTheme.typography.body2, + ) + } + CardDetailsTextContainer( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + text = state.number, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CardDetailsTextContainer(modifier = Modifier.weight(1f), text = state.expiry) + CardDetailsTextContainer(modifier = Modifier.weight(1f), text = state.cvv) + } + } +} + +@Composable +private fun CardDetailsTextContainer(text: String, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .heightIn(min = 48.dp) + .background(color = TangemTheme.colors.field.primary, shape = RoundedCornerShape(16.dp)), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp), + text = text, + maxLines = 1, + color = TangemTheme.colors.text.disabled, + style = TangemTheme.typography.body2, + ) + } +} + +// endregion + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modifier: Modifier = Modifier) { + var showDropdownMenu by rememberSaveable { mutableStateOf(false) } + TopAppBar( + modifier = modifier, + navigationIcon = { + IconButton(onClick = config.onBackClick) { + Icon( + painter = painterResource(id = R.drawable.ic_back_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = "Back", + ) + } + }, + title = {}, + actions = { + AnimatedVisibility(visible = config.items != null && config.items.isNotEmpty()) { + IconButton(onClick = { showDropdownMenu = true }) { + Icon( + painter = painterResource(id = R.drawable.ic_more_vertical_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = "More", + modifier = Modifier.testTag(TokenDetailsTopBarTestTags.MORE_BUTTON), + ) + } + } + + TangemDropdownMenu( + expanded = showDropdownMenu, + modifier = Modifier.background(TangemTheme.colors.background.primary), + onDismissRequest = { showDropdownMenu = false }, + content = { + config.items?.fastForEach { + TangemDropdownItem( + item = it, + dismissParent = { showDropdownMenu = false }, + ) + } + }, + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = TangemTheme.colors.background.secondary, + titleContentColor = TangemTheme.colors.icon.primary1, + actionIconContentColor = TangemTheme.colors.icon.primary1, + ), + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + ) +} + +@Preview(device = Devices.PIXEL_7_PRO, group = "day") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO, group = "night") +@Composable +private fun TangemPayDetailsScreenPreview( + @PreviewParameter(TangemPayDetailsUMProvider::class) state: TangemPayDetailsUM, +) { + TangemThemePreview { + TangemPayDetailsScreen( + state = state, + txHistoryComponent = PreviewTangemPayTxHistoryComponent( + txHistoryUM = PreviewTangemPayTxHistoryComponent.contentUM, + ), + ) + } +} + +private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( + collection = listOf( + TangemPayDetailsUM( + topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, items = null), + pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), + balanceBlockState = TangemPayDetailsBalanceBlockState.Content( + actionButtons = persistentListOf( + ActionButtonConfig( + text = resourceReference(id = R.string.common_receive), + iconResId = R.drawable.ic_arrow_down_24, + onClick = {}, + ), + ), + cryptoBalance = "1234.56 USDT", + fiatBalance = "$1234.56", + isBalanceFlickering = false, + ), + cardDetailsUM = TangemPayCardDetailsUM( + number = "•••• •••• •••• 1245", + expiry = "••/••", + cvv = "•••", + onReveal = {}, + ), + isBalanceHidden = false, + ), + TangemPayDetailsUM( + topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, items = null), + pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), + balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(actionButtons = persistentListOf()), + cardDetailsUM = TangemPayCardDetailsUM( + number = "•••• •••• •••• 1245", + expiry = "••/••", + cvv = "•••", + onReveal = {}, + ), + isBalanceHidden = false, + ), + ), +) \ No newline at end of file diff --git a/features/tangempay/onboarding/api/.gitignore b/features/tangempay/onboarding/api/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/tangempay/onboarding/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/tangempay/onboarding/api/build.gradle.kts b/features/tangempay/onboarding/api/build.gradle.kts new file mode 100644 index 0000000000..a52e5bbccd --- /dev/null +++ b/features/tangempay/onboarding/api/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.tangempay.onboarding.api" +} + +dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayOnboardingComponent.kt b/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayOnboardingComponent.kt new file mode 100644 index 0000000000..d2257362fb --- /dev/null +++ b/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayOnboardingComponent.kt @@ -0,0 +1,13 @@ +package com.tangem.features.tangempay.components + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface TangemPayOnboardingComponent : ComposableContentComponent { + + data class Params( + val deeplink: String, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/deeplink/OnboardVisaDeepLinkHandler.kt b/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/deeplink/OnboardVisaDeepLinkHandler.kt new file mode 100644 index 0000000000..eb9bd4ac28 --- /dev/null +++ b/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/deeplink/OnboardVisaDeepLinkHandler.kt @@ -0,0 +1,10 @@ +package com.tangem.features.tangempay.deeplink + +import android.net.Uri + +interface OnboardVisaDeepLinkHandler { + + interface Factory { + fun create(uri: Uri): OnboardVisaDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/.gitignore b/features/tangempay/onboarding/impl/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/tangempay/onboarding/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/tangempay/onboarding/impl/build.gradle.kts b/features/tangempay/onboarding/impl/build.gradle.kts new file mode 100644 index 0000000000..1d205ff513 --- /dev/null +++ b/features/tangempay/onboarding/impl/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.tangempay.onboarding.impl" +} + +dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Common */ + implementation(projects.common.routing) + implementation(projects.common.ui) + + /** Features api */ + implementation(projects.features.tangempay.onboarding.api) + implementation(projects.features.tangempay.details.api) + implementation(projects.features.kyc.api) + + /** Compose */ + implementation(deps.compose.foundation) + implementation(deps.compose.material3) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayOnboardingComponent.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayOnboardingComponent.kt new file mode 100644 index 0000000000..c770c768d1 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayOnboardingComponent.kt @@ -0,0 +1,35 @@ +package com.tangem.features.tangempay.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.tangempay.model.TangemPayOnboardingModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import com.tangem.features.tangempay.ui.TandemPayOnboardingScreen + +internal class DefaultTangemPayOnboardingComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: TangemPayOnboardingComponent.Params, +) : TangemPayOnboardingComponent, AppComponentContext by appComponentContext { + + private val model: TangemPayOnboardingModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.screenState.collectAsStateWithLifecycle() + TandemPayOnboardingScreen(modifier = modifier, state = state) + } + + @AssistedFactory + interface Factory : TangemPayOnboardingComponent.Factory { + override fun create( + context: AppComponentContext, + params: TangemPayOnboardingComponent.Params, + ): DefaultTangemPayOnboardingComponent + } +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultOnboardVisaDeepLinkHandler.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultOnboardVisaDeepLinkHandler.kt new file mode 100644 index 0000000000..3b10b702a9 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultOnboardVisaDeepLinkHandler.kt @@ -0,0 +1,29 @@ +package com.tangem.features.tangempay.deeplink + +import android.net.Uri +import dagger.assisted.Assisted +import com.tangem.common.routing.AppRoute +import com.tangem.features.tangempay.TangemPayFeatureToggles +import com.tangem.common.routing.AppRouter +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultOnboardVisaDeepLinkHandler @AssistedInject constructor( + @Assisted uri: Uri, + appRouter: AppRouter, + tangemPayFeatureToggles: TangemPayFeatureToggles, +) : OnboardVisaDeepLinkHandler { + + init { + if (tangemPayFeatureToggles.isTangemPayEnabled) { + appRouter.push(AppRoute.TangemPayOnboarding(uri.toString())) + } else { + appRouter.push(AppRoute.Home()) + } + } + + @AssistedFactory + interface Factory : OnboardVisaDeepLinkHandler.Factory { + override fun create(uri: Uri): DefaultOnboardVisaDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDeeplinkModule.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDeeplinkModule.kt new file mode 100644 index 0000000000..a1d13bcabc --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDeeplinkModule.kt @@ -0,0 +1,18 @@ +package com.tangem.features.tangempay.di + +import com.tangem.features.tangempay.deeplink.DefaultOnboardVisaDeepLinkHandler +import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler +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 TangemPayDeeplinkModule { + + @Binds + @Singleton + fun bindDeepLinkHandlerFactory(impl: DefaultOnboardVisaDeepLinkHandler.Factory): OnboardVisaDeepLinkHandler.Factory +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingFeatureModule.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingFeatureModule.kt new file mode 100644 index 0000000000..6e36c8215a --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingFeatureModule.kt @@ -0,0 +1,16 @@ +package com.tangem.features.tangempay.di + +import com.tangem.features.tangempay.components.DefaultTangemPayOnboardingComponent +import com.tangem.features.tangempay.components.TangemPayOnboardingComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface TangemPayOnboardingFeatureModule { + + @Binds + fun bindFactory(impl: DefaultTangemPayOnboardingComponent.Factory): TangemPayOnboardingComponent.Factory +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingModelsModule.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingModelsModule.kt new file mode 100644 index 0000000000..b4b1892e56 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingModelsModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.tangempay.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.tangempay.model.TangemPayOnboardingModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface TangemPayOnboardingModelsModule { + + @Binds + @IntoMap + @ClassKey(TangemPayOnboardingModel::class) + fun bindModel(model: TangemPayOnboardingModel): Model +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt new file mode 100644 index 0000000000..482864d146 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt @@ -0,0 +1,26 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.tangempay.components.TangemPayOnboardingComponent +import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayOnboardingModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + @Suppress("UnusedPrivateMember") + private val params = paramsContainer.require() + + val screenState: StateFlow + field = MutableStateFlow(TangemPayOnboardingScreenState()) +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt new file mode 100644 index 0000000000..42882bafb0 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt @@ -0,0 +1,44 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +internal fun TandemPayOnboardingScreen(state: TangemPayOnboardingScreenState, modifier: Modifier = Modifier) { + Scaffold( + modifier = modifier, + topBar = { + AppBarWithBackButton( + modifier = Modifier.statusBarsPadding(), + onBackClick = {}, + iconRes = R.drawable.ic_back_24, + ) + }, + content = { paddingValues -> + TangemPayOnboardingContent( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + state = state, + ) + }, + ) +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewDarkTheme() { + TangemThemePreview { + TandemPayOnboardingScreen(state = TangemPayOnboardingScreenState()) + } +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlock.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlock.kt new file mode 100644 index 0000000000..94dc71b1ba --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlock.kt @@ -0,0 +1,48 @@ +package com.tangem.features.tangempay.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun TangemPayOnboardingBlock( + @DrawableRes painterRes: Int, + titleRef: TextReference, + descriptionRef: TextReference, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .padding(start = 32.dp, end = 32.dp), + ) { + Icon( + painter = painterResource(id = painterRes), + contentDescription = null, + modifier = Modifier + .width(24.dp) + .height(24.dp), + ) + Column( + modifier = Modifier.padding(start = 12.dp), + ) { + Text( + text = titleRef.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = descriptionRef.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlocks.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlocks.kt new file mode 100644 index 0000000000..71ae0aadcc --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlocks.kt @@ -0,0 +1,36 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.extensions.TextReference + +@Composable +internal fun TangemPayOnboardingBLocks(modifier: Modifier = Modifier) { + TangemPayOnboardingBlock( + modifier = modifier, + painterRes = R.drawable.ic_security_check_22, + titleRef = TextReference.Res(R.string.tangempay_onboarding_security_title), + descriptionRef = TextReference.Res(R.string.tangempay_onboarding_security_description), + ) + + SpacerH(18.dp) + + TangemPayOnboardingBlock( + modifier = modifier, + painterRes = R.drawable.ic_shopping_basket_22, + titleRef = TextReference.Res(R.string.tangempay_onboarding_purchases_title), + descriptionRef = TextReference.Res(R.string.tangempay_onboarding_purchases_description), + ) + + SpacerH(18.dp) + + TangemPayOnboardingBlock( + modifier = modifier, + painterRes = R.drawable.ic_credit_card_add_22, + titleRef = TextReference.Res(R.string.tangempay_onboarding_pay_title), + descriptionRef = TextReference.Res(R.string.tangempay_onboarding_pay_description), + ) +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingContent.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingContent.kt new file mode 100644 index 0000000000..ee247407a6 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingContent.kt @@ -0,0 +1,80 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.navigationButtons.NavigationButton +import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tangempay.onboarding.impl.R + +@Composable +internal fun TangemPayOnboardingContent(state: TangemPayOnboardingScreenState, modifier: Modifier = Modifier) { + if (state.fullScreenLoading) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier, + color = TangemTheme.colors.icon.primary1, + ) + } + } else { + Column( + modifier + .fillMaxSize() + .navigationBarsPadding(), + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(top = 24.dp, bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = R.drawable.img_tangem_pay_onboarding), + contentDescription = null, + modifier = Modifier + .height(250.dp) + .fillMaxWidth(), + ) + + Text( + text = stringResourceSafe(R.string.tangempay_onboarding_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + + SpacerH(32.dp) + + TangemPayOnboardingBLocks() + } + + NavigationPrimaryButton( + modifier = Modifier + .imePadding() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .fillMaxWidth(), + primaryButton = NavigationButton( + textReference = resourceReference(R.string.tangempay_onboarding_get_card_button_text), + iconRes = R.drawable.ic_tangem_24, + isIconVisible = true, + showProgress = state.buttonLoading, + onClick = {}, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt new file mode 100644 index 0000000000..8745cf3db9 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt @@ -0,0 +1,9 @@ +package com.tangem.features.tangempay.ui + +import javax.annotation.concurrent.Immutable + +@Immutable +internal data class TangemPayOnboardingScreenState( + val fullScreenLoading: Boolean = true, + val buttonLoading: Boolean = false, +) \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/res/drawable-night/img_tangem_pay_onboarding.webp b/features/tangempay/onboarding/impl/src/main/res/drawable-night/img_tangem_pay_onboarding.webp new file mode 100644 index 0000000000..97f81422fd Binary files /dev/null and b/features/tangempay/onboarding/impl/src/main/res/drawable-night/img_tangem_pay_onboarding.webp differ diff --git a/features/tangempay/onboarding/impl/src/main/res/drawable/img_tangem_pay_onboarding.webp b/features/tangempay/onboarding/impl/src/main/res/drawable/img_tangem_pay_onboarding.webp new file mode 100644 index 0000000000..2240694474 Binary files /dev/null and b/features/tangempay/onboarding/impl/src/main/res/drawable/img_tangem_pay_onboarding.webp differ diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt index 213bfbc246..3c2015978a 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt @@ -3,6 +3,7 @@ package com.tangem.feature.tester.presentation.excludedblockchains import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tangem.blockchain.common.Blockchain +import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.core.ui.components.fields.entity.SearchBarUM @@ -25,11 +26,11 @@ import javax.inject.Inject @HiltViewModel internal class ExcludedBlockchainsViewModel @Inject constructor( private val appVersionProvider: AppVersionProvider, - excludedBlockchainsManager: MutableExcludedBlockchainsManager?, + excludedBlockchainsManager: ExcludedBlockchainsManager, ) : ViewModel() { private val excludedBlockchainsManager: MutableExcludedBlockchainsManager = - requireNotNull(excludedBlockchainsManager) { + requireNotNull(excludedBlockchainsManager as? MutableExcludedBlockchainsManager) { "Mutable excluded blockchains manager can't be null when tester actions is available" } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt index a88858e751..c8458fac52 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt @@ -12,13 +12,15 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.domain.notifications.models.NotificationType -import com.tangem.domain.tokens.* -import com.tangem.domain.tokens.wallet.WalletBalanceFetcher import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.notifications.models.NotificationType +import com.tangem.domain.tokens.FetchCurrencyStatusUseCase +import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase +import com.tangem.domain.tokens.GetCryptoCurrencyUseCase +import com.tangem.domain.tokens.wallet.WalletBalanceFetcher import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.SelectWalletUseCase import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents @@ -45,9 +47,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( private val walletDeepLinkActionTrigger: WalletDeepLinkActionTrigger, private val analyticsEventHandler: AnalyticsEventHandler, private val getUserWalletUseCase: GetUserWalletUseCase, - private val tokensFeatureToggles: TokensFeatureToggles, private val walletBalanceFetcher: WalletBalanceFetcher, - private val fetchCardTokenListUseCase: FetchCardTokenListUseCase, ) : TokenDetailsDeepLinkHandler { init { @@ -119,24 +119,15 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) { val isMultiCurrency = userWallet.isMultiCurrency // single-currency wallet with token (NODL) - val isSingleWalletWithToken = userWallet is UserWallet.Cold && + userWallet is UserWallet.Cold && userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() when { isMultiCurrency -> fetchCurrencyStatusUseCase.invoke( userWalletId = userWallet.walletId, id = cryptoCurrency.id, ) - !isMultiCurrency && tokensFeatureToggles.isWalletBalanceFetcherEnabled -> - walletBalanceFetcher(params = WalletBalanceFetcher.Params(userWalletId = userWallet.walletId)) - // remove below after delete tokensFeatureToggles.isWalletBalanceFetcherEnabled - !isMultiCurrency && userWallet is UserWallet.Cold && isSingleWalletWithToken -> - fetchCardTokenListUseCase.invoke( - userWalletId = userWallet.walletId, - refresh = true, - ) - !isMultiCurrency -> fetchCurrencyStatusUseCase.invoke( - userWalletId = userWallet.walletId, - refresh = true, + !isMultiCurrency -> walletBalanceFetcher( + params = WalletBalanceFetcher.Params(userWalletId = userWallet.walletId), ) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt index 375e60ec26..01040f4abc 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt @@ -1,14 +1,11 @@ package com.tangem.feature.tokendetails.presentation.tokendetails import androidx.compose.ui.graphics.Color -import androidx.paging.PagingData import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -18,7 +15,6 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.* import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsActionButton import com.tangem.features.tokendetails.impl.R import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.flow.MutableStateFlow import java.math.BigDecimal @Suppress("LargeClass") @@ -168,130 +164,6 @@ internal object TokenDetailsPreviewData { onRefresh = {}, ) - private val txHistoryItems = listOf( - TxHistoryState.TxHistoryItemState.Title(onExploreClick = {}), - TxHistoryState.TxHistoryItemState.GroupTitle( - title = "Today", - itemKey = "Today", - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "1", - amount = "-0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.OUTGOING, - onClick = {}, - iconRes = R.drawable.ic_arrow_up_24, - title = stringReference(value = "Sending"), - subtitle = stringReference(value = "to: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "2", - amount = "+0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.INCOMING, - onClick = {}, - iconRes = R.drawable.ic_arrow_down_24, - title = stringReference(value = "Receiving"), - subtitle = stringReference(value = "from: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "3", - amount = "+0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.INCOMING, - onClick = {}, - iconRes = R.drawable.ic_doc_24, - title = stringReference(value = "Approving"), - subtitle = stringReference(value = "from: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "4", - amount = "+0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.INCOMING, - onClick = {}, - iconRes = R.drawable.ic_exchange_vertical_24, - title = stringReference(value = "Swapping"), - subtitle = stringReference(value = "contract: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.GroupTitle( - title = "Yesterday", - itemKey = "Yesterday", - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "5", - amount = "-0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.OUTGOING, - onClick = {}, - iconRes = R.drawable.ic_arrow_up_24, - title = stringReference(value = "Sending"), - subtitle = stringReference(value = "to: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "6", - amount = "+0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.INCOMING, - onClick = {}, - iconRes = R.drawable.ic_arrow_down_24, - title = stringReference(value = "Receiving"), - subtitle = stringReference(value = "from: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "7", - amount = "+0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.INCOMING, - onClick = {}, - iconRes = R.drawable.ic_doc_24, - title = stringReference(value = "Approving"), - subtitle = stringReference(value = "from: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "8", - amount = "+0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.INCOMING, - onClick = {}, - iconRes = R.drawable.ic_exchange_vertical_24, - title = stringReference(value = "Swapping"), - subtitle = stringReference(value = "contract: 33BddS...ga2B"), - timestamp = 0, - ), - ), - ) - val tokenDetailsState_1 = TokenDetailsState( topAppBarConfig = tokenDetailsTopAppBarConfig, tokenInfoBlockState = tokenInfoBlockState, @@ -299,13 +171,7 @@ internal object TokenDetailsPreviewData { marketPriceBlockState = marketPriceLoading, stakingBlocksState = stakingLoadingBlock, notifications = persistentListOf(), - txHistoryState = TxHistoryState.Content( - contentItems = MutableStateFlow( - value = TxHistoryState.getDefaultLoadingTransactions {}, - ), - ), dialogConfig = null, - pendingTxs = persistentListOf(), expressTxs = persistentListOf(), expressTxsToDisplay = persistentListOf(), pullToRefreshConfig = pullToRefreshConfig, @@ -330,12 +196,7 @@ internal object TokenDetailsPreviewData { ), stakingBlocksState = stakingAvailableBlock, notifications = persistentListOf(), - txHistoryState = TxHistoryState.NotSupported( - onExploreClick = {}, - pendingTransactions = persistentListOf(), - ), dialogConfig = null, - pendingTxs = persistentListOf(), expressTxs = persistentListOf(), expressTxsToDisplay = persistentListOf(), pullToRefreshConfig = pullToRefreshConfig, @@ -344,12 +205,5 @@ internal object TokenDetailsPreviewData { isMarketPriceAvailable = true, ) - val tokenDetailsState_3 = tokenDetailsState_2.copy( - txHistoryState = TxHistoryState.Content( - contentItems = MutableStateFlow( - value = PagingData.from(txHistoryItems), - ), - ), - stakingBlocksState = stakingBalanceBlock, - ) + val tokenDetailsState_3 = tokenDetailsState_2.copy(stakingBlocksState = stakingBalanceBlock) } \ No newline at end of file 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 dbe31e24b9..c02b4801b9 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 @@ -38,7 +38,7 @@ internal class TokenDetailsNotificationsAnalyticsSender( currency = cryptoCurrency, ) is TokenDetailsNotification.SwapPromo -> TokenSwapPromoAnalyticsEvent.NoticePromotionBanner( - programName = TokenSwapPromoAnalyticsEvent.ProgramName.Empty, // Use it on new promo action + program = TokenSwapPromoAnalyticsEvent.Program.Empty, // Use it on new promo action source = AnalyticsParam.ScreensSources.Token, ) is TokenDetailsNotification.KaspaIncompleteTransactionWarning -> TokenDetailsAnalyticsEvent.Notice.Reveal( 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 e22dcb5181..3e626329c4 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 @@ -1,7 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model import androidx.compose.runtime.Stable -import androidx.paging.cachedIn import arrow.core.getOrElse import arrow.core.merge import com.arkivanov.decompose.router.slot.SlotNavigation @@ -24,7 +23,6 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.share.ShareManager import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -66,8 +64,6 @@ import com.tangem.domain.transaction.error.OpenTrustlineError import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.usecase.* import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase -import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase -import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -105,8 +101,6 @@ internal class TokenDetailsModel @Inject constructor( private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, - private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, - private val txHistoryItemsUseCase: GetTxHistoryItemsUseCase, private val getExploreUrlUseCase: GetExploreUrlUseCase, private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, private val removeCurrencyUseCase: RemoveCurrencyUseCase, @@ -176,8 +170,6 @@ internal class TokenDetailsModel @Inject constructor( networkHasDerivationUseCase = networkHasDerivationUseCase, getUserWalletUseCase = getUserWalletUseCase, userWalletId = userWalletId, - symbol = cryptoCurrency.symbol, - decimals = cryptoCurrency.decimals, ) private val expressStatusFactory by lazy(mode = LazyThreadSafetyMode.NONE) { @@ -247,7 +239,6 @@ internal class TokenDetailsModel @Inject constructor( private fun updateContent() { subscribeOnCurrencyStatusUpdates() subscribeOnExpressTransactionsUpdates() - updateTxHistory(refresh = false, showItemsLoading = true, initialUpdating = true) } private fun handleBalanceHiding() { @@ -379,39 +370,8 @@ internal class TokenDetailsModel @Inject constructor( } } - /** - * @param refresh - invalidate cache and get data from remote - * @param showItemsLoading - show loading items placeholder. - */ - private fun updateTxHistory(refresh: Boolean, showItemsLoading: Boolean, initialUpdating: Boolean = false) { - modelScope.launch { - if (!initialUpdating) { - txHistoryContentUpdateEmitter.triggerUpdate() - } else { - val txHistoryItemsCountEither = txHistoryItemsCountUseCase( - userWalletId = userWalletId, - currency = cryptoCurrency, - ) - - // if countEither is left, handling error state run inside getLoadingTxHistoryState - if (showItemsLoading || txHistoryItemsCountEither.isLeft()) { - internalUiState.value = stateFactory.getLoadingTxHistoryState( - itemsCountEither = txHistoryItemsCountEither, - pendingTransactions = internalUiState.value.pendingTxs, - ) - } - - txHistoryItemsCountEither.onRight { - val maybeTxHistory = txHistoryItemsUseCase( - userWalletId = userWalletId, - currency = cryptoCurrency, - refresh = refresh, - ).map { it.cachedIn(modelScope) } - - internalUiState.value = stateFactory.getLoadedTxHistoryState(maybeTxHistory) - } - } - } + private fun updateTxHistory() { + modelScope.launch { txHistoryContentUpdateEmitter.triggerUpdate() } } private fun subscribeOnUpdateStakingInfo(cryptoCurrencyStatus: CryptoCurrencyStatus) { @@ -549,8 +509,7 @@ internal class TokenDetailsModel @Inject constructor( override fun onReloadClick() { analyticsEventsHandler.send(TokenScreenAnalyticsEvent.ButtonReload(cryptoCurrency.symbol)) - internalUiState.value = stateFactory.getLoadingTxHistoryState() - updateTxHistory(refresh = true, showItemsLoading = true) + updateTxHistory() } override fun onSendClick(unavailabilityReason: ScenarioUnavailabilityReason) { @@ -797,10 +756,7 @@ internal class TokenDetailsModel @Inject constructor( listOf( async { fetchCurrencyStatusUseCase(userWalletId = userWalletId, id = cryptoCurrency.id) }, async { - updateTxHistory( - refresh = true, - showItemsLoading = internalUiState.value.txHistoryState !is TxHistoryState.Content, - ) + updateTxHistory() subscribeOnExpressTransactionsUpdates() }, ).awaitAll() @@ -847,7 +803,7 @@ internal class TokenDetailsModel @Inject constructor( analyticsEventsHandler.send( TokenSwapPromoAnalyticsEvent.PromotionBannerClicked( source = AnalyticsParam.ScreensSources.Token, - programName = TokenSwapPromoAnalyticsEvent.ProgramName.Empty, // Use it on new promo action + program = TokenSwapPromoAnalyticsEvent.Program.Empty, // Use it on new promo action action = TokenSwapPromoAnalyticsEvent.PromotionBannerClicked.BannerAction.Closed, ), ) @@ -860,7 +816,7 @@ internal class TokenDetailsModel @Inject constructor( analyticsEventsHandler.send( TokenSwapPromoAnalyticsEvent.PromotionBannerClicked( source = AnalyticsParam.ScreensSources.Token, - programName = TokenSwapPromoAnalyticsEvent.ProgramName.Empty, // Use it on new promo action + program = TokenSwapPromoAnalyticsEvent.Program.Empty, // Use it on new promo action action = TokenSwapPromoAnalyticsEvent.PromotionBannerClicked.BannerAction.Clicked, ), ) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt index 594aba1cca..d3a16f09cd 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt @@ -2,10 +2,8 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.marketprice.MarketPriceBlockState -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification import kotlinx.collections.immutable.ImmutableList @@ -18,10 +16,8 @@ internal data class TokenDetailsState( val marketPriceBlockState: MarketPriceBlockState, val stakingBlocksState: StakingBlockUM?, val notifications: ImmutableList, - val pendingTxs: PersistentList, val expressTxsToDisplay: PersistentList, val expressTxs: PersistentList, - val txHistoryState: TxHistoryState, val dialogConfig: TokenDetailsDialogConfig?, val pullToRefreshConfig: PullToRefreshConfig, val bottomSheetConfig: TangemBottomSheetConfig?, 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 297bc67dfc..35937498ee 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 @@ -1,12 +1,14 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.components import androidx.compose.runtime.Immutable +import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.features.tokendetails.impl.R import org.joda.time.DateTime @@ -212,6 +214,7 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) { ) data class KaspaIncompleteTransactionWarning( + private val userWallet: UserWallet, private val currency: CryptoCurrency, private val amount: String, private val currencySymbol: String, @@ -227,7 +230,7 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) { buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( text = resourceReference(R.string.alert_button_try_again), onClick = onRetryIncompleteTransactionClick, - iconResId = R.drawable.ic_tangem_24, + iconResId = walletInterationIcon(userWallet), ), onCloseClick = onDismissIncompleteTransactionClick, ) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt index 03fbaa9901..86527f1f30 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt @@ -5,7 +5,6 @@ import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter -import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.format.bigdecimal.* import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.StatusSource @@ -18,29 +17,20 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.BalanceTy import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification -import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsTxHistoryTransactionStateConverter import com.tangem.feature.tokendetails.presentation.tokendetails.state.utils.getBalance import com.tangem.utils.Provider import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.converter.Converter import com.tangem.utils.isNullOrZero import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList import java.math.BigDecimal -@Suppress("LongParameterList") internal class TokenDetailsLoadedBalanceConverter( private val currentStateProvider: Provider, private val appCurrencyProvider: Provider, - private val symbol: String, - private val decimals: Int, private val clickIntents: TokenDetailsClickIntents, ) : Converter, TokenDetailsState> { - private val txHistoryItemConverter by lazy { - TokenDetailsTxHistoryTransactionStateConverter(symbol, decimals, clickIntents) - } - override fun convert(value: Either): TokenDetailsState { return value.fold( ifLeft = { convertError() }, @@ -64,7 +54,6 @@ internal class TokenDetailsLoadedBalanceConverter( private fun convert(status: CryptoCurrencyStatus): TokenDetailsState { val state = currentStateProvider() val currencyName = state.marketPriceBlockState.currencySymbol - val pendingTxs = status.value.pendingTransactions.map(txHistoryItemConverter::convert).toPersistentList() return state.copy( tokenBalanceBlockState = getBalanceState( @@ -73,12 +62,6 @@ internal class TokenDetailsLoadedBalanceConverter( ), stakingBlocksState = state.stakingBlocksState, marketPriceBlockState = getMarketPriceState(status = status.value, currencySymbol = currencyName), - pendingTxs = pendingTxs, - txHistoryState = if (state.txHistoryState is TxHistoryState.NotSupported) { - state.txHistoryState.copy(pendingTransactions = pendingTxs) - } else { - state.txHistoryState - }, ) } 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 67215ebe35..a192ef46d0 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 @@ -1,5 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory +import arrow.core.getOrElse import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.core.ui.extensions.resourceReference @@ -7,9 +8,11 @@ import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.shorted import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.tokens.model.warnings.HederaWarnings import com.tangem.domain.tokens.model.warnings.KaspaWarnings +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification @@ -23,6 +26,8 @@ import timber.log.Timber import java.math.BigDecimal internal class TokenDetailsNotificationConverter( + private val userWalletId: UserWalletId, + private val getUserWalletUseCase: GetUserWalletUseCase, private val clickIntents: TokenDetailsClickIntents, ) : Converter, ImmutableList> { @@ -131,6 +136,8 @@ internal class TokenDetailsNotificationConverter( onOpenClick = clickIntents::onOpenTrustlineClick, ) is KaspaWarnings.IncompleteTransaction -> KaspaIncompleteTransactionWarning( + userWallet = getUserWalletUseCase.invoke(userWalletId) + .getOrElse { error("Cannot find user wallet with id: ${userWalletId.stringValue}") }, currency = warning.currency, amount = warning.amount.format { crypto(symbol = "", decimals = warning.currencyDecimals) }, currencySymbol = warning.currencySymbol, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt index 3b3acb4d24..ee0b33124a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt @@ -4,7 +4,6 @@ import arrow.core.getOrElse import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.marketprice.MarketPriceBlockState -import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.resourceReference @@ -24,7 +23,6 @@ import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.MutableStateFlow internal class TokenDetailsSkeletonStateConverter( private val clickIntents: TokenDetailsClickIntents, @@ -64,14 +62,8 @@ internal class TokenDetailsSkeletonStateConverter( marketPriceBlockState = MarketPriceBlockState.Loading(value.symbol), stakingBlocksState = StakingBlockUM.Loading(iconState).takeIf { isSupportedInMobileApp }, notifications = persistentListOf(), - pendingTxs = persistentListOf(), expressTxs = persistentListOf(), expressTxsToDisplay = persistentListOf(), - txHistoryState = TxHistoryState.Content( - contentItems = MutableStateFlow( - value = TxHistoryState.getDefaultLoadingTransactions(clickIntents::onExploreClick), - ), - ), dialogConfig = null, pullToRefreshConfig = createPullToRefresh(), bottomSheetConfig = null, 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 22365b4892..77eb685821 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 @@ -1,14 +1,11 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory -import androidx.paging.PagingData import arrow.core.Either import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig import com.tangem.common.ui.tokens.getUnavailabilityReasonText import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme @@ -18,7 +15,6 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress -import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingAvailability @@ -27,8 +23,6 @@ import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenActionsState import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning -import com.tangem.domain.txhistory.models.TxHistoryListError -import com.tangem.domain.txhistory.models.TxHistoryStateError import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents @@ -36,14 +30,9 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBala import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsAppBarMenuConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig -import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadedTxHistoryConverter -import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadingTxHistoryConverter -import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadingTxHistoryConverter.TokenDetailsLoadingTxHistoryModel import com.tangem.features.tokendetails.impl.R import com.tangem.utils.Provider import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow @Suppress("TooManyFunctions", "LargeClass", "LongParameterList") internal class TokenDetailsStateFactory( @@ -54,8 +43,6 @@ internal class TokenDetailsStateFactory( private val networkHasDerivationUseCase: NetworkHasDerivationUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val userWalletId: UserWalletId, - symbol: String, - decimals: Int, ) { private val skeletonStateConverter by lazy { @@ -68,15 +55,17 @@ internal class TokenDetailsStateFactory( } private val notificationConverter by lazy { - TokenDetailsNotificationConverter(clickIntents = clickIntents) + TokenDetailsNotificationConverter( + userWalletId = userWalletId, + getUserWalletUseCase = getUserWalletUseCase, + clickIntents = clickIntents, + ) } private val tokenDetailsLoadedBalanceConverter by lazy { TokenDetailsLoadedBalanceConverter( currentStateProvider = currentStateProvider, appCurrencyProvider = appCurrencyProvider, - symbol = symbol, - decimals = decimals, clickIntents = clickIntents, ) } @@ -88,22 +77,6 @@ internal class TokenDetailsStateFactory( ) } - private val loadingTransactionsStateConverter by lazy { - TokenDetailsLoadingTxHistoryConverter( - currentStateProvider = currentStateProvider, - clickIntents = clickIntents, - ) - } - - private val loadedTxHistoryConverter by lazy { - TokenDetailsLoadedTxHistoryConverter( - currentStateProvider = currentStateProvider, - clickIntents = clickIntents, - symbol = symbol, - decimals = decimals, - ) - } - private val refreshStateConverter by lazy { TokenDetailsRefreshStateConverter( currentStateProvider = currentStateProvider, @@ -147,36 +120,6 @@ internal class TokenDetailsStateFactory( return tokenDetailsButtonsConverter.convert(actions) } - fun getLoadingTxHistoryState(): TokenDetailsState { - return currentStateProvider().copy( - txHistoryState = TxHistoryState.Content( - contentItems = MutableStateFlow( - value = TxHistoryState.getDefaultLoadingTransactions(clickIntents::onExploreClick), - ), - ), - ) - } - - fun getLoadingTxHistoryState( - itemsCountEither: Either, - pendingTransactions: List, - ): TokenDetailsState { - return loadingTransactionsStateConverter.convert( - value = TokenDetailsLoadingTxHistoryModel( - historyLoadingState = itemsCountEither, - pendingTransactions = pendingTransactions, - ), - ) - } - - fun getLoadedTxHistoryState( - txHistoryEither: Either>>, - ): TokenDetailsState { - return currentStateProvider().copy( - txHistoryState = loadedTxHistoryConverter.convert(txHistoryEither), - ) - } - fun getStateWithClosedDialog(): TokenDetailsState { val state = currentStateProvider() return state.copy(dialogConfig = state.dialogConfig?.copy(isShow = false)) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadedTxHistoryConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadedTxHistoryConverter.kt deleted file mode 100644 index 7a0e028348..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadedTxHistoryConverter.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory - -import androidx.paging.PagingData -import arrow.core.Either -import com.tangem.core.ui.components.transactions.state.TxHistoryState -import com.tangem.domain.models.network.TxInfo -import com.tangem.domain.txhistory.models.TxHistoryListError -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState -import com.tangem.utils.Provider -import com.tangem.utils.converter.Converter -import kotlinx.coroutines.flow.Flow - -internal class TokenDetailsLoadedTxHistoryConverter( - private val currentStateProvider: Provider, - private val clickIntents: TokenDetailsClickIntents, - symbol: String, - decimals: Int, -) : Converter>>, TxHistoryState> { - - private val txHistoryItemFlowConverter by lazy { - TokenDetailsTxHistoryItemFlowConverter( - currentStateProvider = currentStateProvider, - symbol = symbol, - decimals = decimals, - clickIntents = clickIntents, - ) - } - - override fun convert(value: Either>>): TxHistoryState { - return value.fold(ifLeft = ::convertError, ifRight = ::convert) - } - - private fun convertError(error: TxHistoryListError): TxHistoryState { - return when (error) { - is TxHistoryListError.DataError -> { - TxHistoryState.Error( - onReloadClick = clickIntents::onReloadClick, - onExploreClick = clickIntents::onExploreClick, - ) - } - } - } - - private fun convert(items: Flow>): TxHistoryState { - return txHistoryItemFlowConverter.convert(value = items) - } -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadingTxHistoryConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadingTxHistoryConverter.kt deleted file mode 100644 index 479a510db6..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadingTxHistoryConverter.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory - -import androidx.paging.PagingData -import arrow.core.Either -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.components.transactions.state.TxHistoryState -import com.tangem.domain.txhistory.models.TxHistoryStateError -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadingTxHistoryConverter.TokenDetailsLoadingTxHistoryModel -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents -import com.tangem.utils.Provider -import com.tangem.utils.converter.Converter -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update - -internal class TokenDetailsLoadingTxHistoryConverter( - private val currentStateProvider: Provider, - private val clickIntents: TokenDetailsClickIntents, -) : Converter { - - override fun convert(value: TokenDetailsLoadingTxHistoryModel): TokenDetailsState { - return value.historyLoadingState.fold( - ifLeft = { convertError(error = it, pendingTransactions = value.pendingTransactions) }, - ifRight = ::convert, - ) - } - - private fun convertError( - error: TxHistoryStateError, - pendingTransactions: List, - ): TokenDetailsState { - return currentStateProvider().copy( - txHistoryState = when (error) { - is TxHistoryStateError.EmptyTxHistories -> TxHistoryState.Empty(clickIntents::onExploreClick) - is TxHistoryStateError.DataError -> TxHistoryState.Error( - onReloadClick = clickIntents::onReloadClick, - onExploreClick = clickIntents::onExploreClick, - ) - is TxHistoryStateError.TxHistoryNotImplemented -> { - TxHistoryState.NotSupported( - pendingTransactions = pendingTransactions.toImmutableList(), - onExploreClick = clickIntents::onExploreClick, - ) - } - }, - ) - } - - private fun convert(value: Int): TokenDetailsState { - val state = currentStateProvider() - - return if (state.txHistoryState is TxHistoryState.Content) { - state.txHistoryState.contentItems.update { - PagingData.from(data = createLoadingItems(value)) - } - state - } else { - val txHistoryContent = TxHistoryState.Content( - contentItems = MutableStateFlow( - value = PagingData.from(data = createLoadingItems(value)), - ), - ) - state.copy(txHistoryState = txHistoryContent) - } - } - - private fun createLoadingItems(size: Int): List { - return buildList { - add(TxHistoryState.TxHistoryItemState.Title(onExploreClick = clickIntents::onExploreClick)) - (1..size).forEach { - add(TxHistoryState.TxHistoryItemState.Transaction(state = TransactionState.Loading(it.toString()))) - } - } - } - - data class TokenDetailsLoadingTxHistoryModel( - val historyLoadingState: Either, - val pendingTransactions: List, - ) -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryItemFlowConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryItemFlowConverter.kt deleted file mode 100644 index c1cd9f825b..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryItemFlowConverter.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory - -import androidx.paging.* -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.components.transactions.state.TxHistoryState -import com.tangem.core.ui.components.transactions.state.TxHistoryState.TxHistoryItemState -import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday -import com.tangem.domain.models.network.TxInfo -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState -import com.tangem.utils.Provider -import com.tangem.utils.converter.Converter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* -import java.util.UUID - -internal class TokenDetailsTxHistoryItemFlowConverter( - private val currentStateProvider: Provider, - private val symbol: String, - private val decimals: Int, - private val clickIntents: TokenDetailsClickIntents, -) : Converter>, TxHistoryState> { - - private val txHistoryItemConverter by lazy { - TokenDetailsTxHistoryTransactionStateConverter( - symbol = symbol, - decimals = decimals, - clickIntents = clickIntents, - ) - } - - override fun convert(value: Flow>): TxHistoryState { - val state = currentStateProvider() - val txHistoryContent = if (state.txHistoryState is TxHistoryState.Content) { - state.txHistoryState - } else { - TxHistoryState.Content(contentItems = MutableStateFlow(PagingData.empty())) - } - // FIXME: TxHistoryRepository should send loading transactions - // [REDACTED_JIRA] - value - .onEach { txHistoryStatePagingData -> - txHistoryContent.contentItems.update { - txHistoryStatePagingData - .map { item -> - // [createTransactionState] returns timestamp without formatting - TxHistoryItemState.Transaction(state = createTransactionState(item)) - } - .insertHeaderItem( - terminalSeparatorType = TerminalSeparatorType.SOURCE_COMPLETE, - item = TxHistoryItemState.Title(clickIntents::onExploreClick), - ) - .insertGroupTitle() - } - } - .launchIn(CoroutineScope(Dispatchers.IO)) - - return txHistoryContent - } - - private fun createTransactionState(item: TxInfo): TransactionState { - return txHistoryItemConverter.convert(value = item) - } - - private fun PagingData.insertGroupTitle(): PagingData { - return insertSeparators(terminalSeparatorType = TerminalSeparatorType.SOURCE_COMPLETE) { before, after -> - // Use raw timestamp to get date - - // If [afterDate] is the first transaction in the flow, add the group title - val afterDate = after.getTimestamp()?.toDateFormatWithTodayYesterday() ?: return@insertSeparators null - if (before is TxHistoryItemState.Title) { - return@insertSeparators TxHistoryItemState.GroupTitle( - title = afterDate, - itemKey = UUID.randomUUID().toString(), - ) - } - - /* - * If [beforeDate] is not equals to [afterDate], then [afterDate] is first transaction in - * the new group - */ - val beforeDate = before.getTimestamp()?.toDateFormatWithTodayYesterday() ?: return@insertSeparators null - return@insertSeparators if (beforeDate != afterDate) { - TxHistoryItemState.GroupTitle( - title = afterDate, - itemKey = UUID.randomUUID().toString(), - ) - } else { - null - } - } - } - - private fun TxHistoryItemState?.getTimestamp(): Long? { - return if (this is TxHistoryItemState.Transaction && this.state is TransactionState.Content) { - val txContent = this.state as TransactionState.Content - txContent.timestamp - } else { - null - } - } -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryTransactionStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryTransactionStateConverter.kt deleted file mode 100644 index 74c1021d7c..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryTransactionStateConverter.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory - -import com.tangem.common.extensions.isZero -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.components.transactions.state.TransactionState.Content.Direction -import com.tangem.core.ui.extensions.TextReference -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.core.ui.utils.toTimeFormat -import com.tangem.domain.models.network.TxInfo -import com.tangem.domain.models.network.TxInfo.* -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents -import com.tangem.features.tokendetails.impl.R -import com.tangem.utils.StringsSigns.MINUS -import com.tangem.utils.StringsSigns.PLUS -import com.tangem.utils.converter.Converter -import com.tangem.utils.toBriefAddressFormat - -internal class TokenDetailsTxHistoryTransactionStateConverter( - private val symbol: String, - private val decimals: Int, - private val clickIntents: TokenDetailsClickIntents, -) : Converter { - - override fun convert(value: TxInfo): TransactionState { - return createTransactionStateItem(item = value) - } - - @Suppress("LongMethod") - private fun createTransactionStateItem(item: TxInfo): TransactionState { - return TransactionState.Content( - txHash = item.txHash, - amount = item.getAmount(), - time = item.timestampInMillis.toTimeFormat(), - status = item.status.tiUiStatus(), - direction = item.extractDirection(), - iconRes = item.extractIcon(), - title = item.extractTitle(), - subtitle = item.extractSubtitle(), - timestamp = item.timestampInMillis, - onClick = { clickIntents.onTransactionClick(item.txHash) }, - ) - } - - private fun TxInfo.extractIcon(): Int = if (status == TransactionStatus.Failed) { - R.drawable.ic_close_24 - } else { - when (type) { - is TransactionType.Approve -> R.drawable.ic_doc_24 - is TransactionType.Staking.Stake, - is TransactionType.Staking.Vote, - is TransactionType.Staking.Restake, - -> R.drawable.ic_transaction_history_staking_24 - is TransactionType.Staking.ClaimRewards, - -> R.drawable.ic_transaction_history_claim_rewards_24 - is TransactionType.Staking.Unstake, - is TransactionType.Staking.Withdraw, - -> R.drawable.ic_transaction_history_unstaking_24 - is TransactionType.Operation, - is TransactionType.Swap, - is TransactionType.Transfer, - is TransactionType.UnknownOperation, - -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 - } - } - - private fun TxInfo.extractTitle(): TextReference = when (val type = type) { - is TransactionType.Approve -> resourceReference(R.string.common_approval) - is TransactionType.Operation -> stringReference(type.name) - is TransactionType.Swap -> resourceReference(R.string.common_swap) - is TransactionType.Transfer -> resourceReference(R.string.common_transfer) - is TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation) - is TransactionType.Staking.Stake -> resourceReference(R.string.common_stake) - is TransactionType.Staking.Unstake -> resourceReference(R.string.common_unstake) - is TransactionType.Staking.Vote -> resourceReference(R.string.staking_vote) - is TransactionType.Staking.ClaimRewards -> resourceReference(R.string.common_claim_rewards) - is TransactionType.Staking.Withdraw -> resourceReference(R.string.staking_withdraw) - is TransactionType.Staking.Restake -> resourceReference(R.string.staking_restake) - } - - private fun TxInfo.extractSubtitle(): TextReference = when (val interactionAddress = interactionAddressType) { - is InteractionAddressType.Contract -> resourceReference( - id = R.string.transaction_history_contract_address, - formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), - ) - is InteractionAddressType.Multiple -> resourceReference( - id = if (isOutgoing) { - R.string.transaction_history_transaction_to_address - } else { - R.string.transaction_history_transaction_from_address - }, - formatArgs = wrappedList(resourceReference(R.string.transaction_history_multiple_addresses)), - ) - is InteractionAddressType.User -> resourceReference( - id = if (isOutgoing) { - R.string.transaction_history_transaction_to_address - } else { - R.string.transaction_history_transaction_from_address - }, - formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), - ) - is InteractionAddressType.Validator -> resourceReference( - id = R.string.transaction_history_transaction_validator, - formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), - ) - null -> { - TextReference.EMPTY - } - } - - private fun TxInfo.extractDirection() = if (isOutgoing) Direction.OUTGOING else Direction.INCOMING - - private fun TransactionStatus.tiUiStatus() = when (this) { - TransactionStatus.Confirmed -> TransactionState.Content.Status.Confirmed - TransactionStatus.Failed -> TransactionState.Content.Status.Failed - TransactionStatus.Unconfirmed -> TransactionState.Content.Status.Unconfirmed - } - - private fun TxInfo.getAmount(): String { - if (type is TransactionType.Staking.Vote || - type == TransactionType.Staking.ClaimRewards || - type == TransactionType.Staking.Withdraw - ) { - return "" - } - val prefix = when { - status == TransactionStatus.Failed -> "" - this.amount.isZero() -> "" - else -> if (isOutgoing) MINUS else PLUS - } - return prefix + amount.format { crypto(symbol = symbol, decimals = decimals) } - } -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index a64108bc51..14a17ee2e1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -6,7 +6,7 @@ import androidx.compose.foundation.lazy.* import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -14,8 +14,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.paging.compose.LazyPagingItems -import androidx.paging.compose.collectAsLazyPagingItems import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheet import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet @@ -26,8 +24,6 @@ import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefres import com.tangem.core.ui.components.marketprice.MarketPriceBlock import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.components.transactions.state.TxHistoryState -import com.tangem.core.ui.components.transactions.txHistoryItems import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TokenDetailsScreenTestTags @@ -46,7 +42,6 @@ import com.tangem.features.txhistory.component.TxHistoryComponent import com.tangem.features.txhistory.entity.TxHistoryUM import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlin.reflect.KProperty // TODO: Split to blocks [REDACTED_JIRA] @Suppress("LongMethod") @@ -63,11 +58,6 @@ internal fun TokenDetailsScreen( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), containerColor = TangemTheme.colors.background.secondary, ) { scaffoldPaddings -> - val txHistoryItems = if (state.txHistoryState is TxHistoryState.Content) { - state.txHistoryState.contentItems.collectAsLazyPagingItems() - } else { - null - } val listState = rememberLazyListState() val txHistoryComponentState by txHistoryComponent.txHistoryState.collectAsStateWithLifecycle() val betweenItemsPadding = TangemTheme.dimens.spacing12 @@ -160,14 +150,7 @@ internal fun TokenDetailsScreen( modifier = itemModifier, ) - txHistoryItems( - listState = listState, - txHistoryComponent = txHistoryComponent, - txHistoryComponentState = txHistoryComponentState, - txHistoryState = state.txHistoryState, - txHistoryItems = txHistoryItems, - isBalanceHidden = state.isBalanceHidden, - ) + with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryComponentState) } } } @@ -189,28 +172,6 @@ internal fun TokenDetailsScreen( } } -@Suppress("LongParameterList") -private fun LazyListScope.txHistoryItems( - listState: LazyListState, - txHistoryComponent: TxHistoryComponent, - txHistoryComponentState: TxHistoryUM?, - txHistoryState: TxHistoryState, - txHistoryItems: LazyPagingItems?, - isBalanceHidden: Boolean, -) { - if (txHistoryComponentState != null) { - with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryComponentState) } - } else { - txHistoryItems( - state = txHistoryState, - isBalanceHidden = isBalanceHidden, - txHistoryItems = txHistoryItems, - ) - } -} - -private inline operator fun State?.getValue(thisObj: Any?, property: KProperty<*>): T? = this?.value - // region Preview @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt index 9a9960fc8e..ad33da1dc0 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt @@ -1,6 +1,7 @@ package com.tangem.features.txhistory.entity import com.tangem.core.ui.components.transactions.state.TransactionState +import com.tangem.core.ui.components.transactions.state.TxHistoryState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -81,7 +82,9 @@ sealed interface TxHistoryUM { data class GroupTitle( val title: String, val itemKey: String, - ) : TxHistoryItemUM + ) : TxHistoryItemUM { + val legacyGroupTitle = TxHistoryState.TxHistoryItemState.GroupTitle(title = title, itemKey = itemKey) + } /** * Transaction item diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt similarity index 83% rename from features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt rename to features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt index 9525d0ede2..aafd528437 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt @@ -1,20 +1,16 @@ package com.tangem.features.txhistory.ui -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.components.transactions.PendingTxsBlock import com.tangem.core.ui.components.transactions.Transaction +import com.tangem.core.ui.components.transactions.TxHistoryGroupTitle import com.tangem.core.ui.components.transactions.TxHistoryTitle import com.tangem.core.ui.components.transactions.empty.EmptyTransactionBlock import com.tangem.core.ui.components.transactions.empty.EmptyTransactionsBlockState @@ -25,7 +21,7 @@ import com.tangem.features.txhistory.entity.TxHistoryUM private const val LOAD_ITEMS_BUFFER = 20 -internal fun LazyListScope.txHistoryItems(listState: LazyListState, state: TxHistoryUM) { +fun LazyListScope.txHistoryItems(listState: LazyListState, state: TxHistoryUM) { when (state) { is TxHistoryUM.Content -> contentItems(listState, state) is TxHistoryUM.Empty -> nonContentItem(state = EmptyTransactionsBlockState.Empty(state.onExploreClick)) @@ -127,7 +123,7 @@ internal fun TxHistoryListItem( ) { when (state) { is TxHistoryUM.TxHistoryItemUM.GroupTitle -> { - TxHistoryGroupTitle(config = state, modifier = modifier) + TxHistoryGroupTitle(config = state.legacyGroupTitle, modifier = modifier) } is TxHistoryUM.TxHistoryItemUM.Title -> { TxHistoryTitle(onExploreClick = state.onExploreClick, modifier = modifier) @@ -140,25 +136,4 @@ internal fun TxHistoryListItem( ) } } -} - -@Composable -private fun TxHistoryGroupTitle(config: TxHistoryUM.TxHistoryItemUM.GroupTitle, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .background(TangemTheme.colors.background.primary) - .padding( - vertical = TangemTheme.dimens.spacing8, - horizontal = TangemTheme.dimens.spacing12, - ) - .fillMaxWidth() - .heightIn(min = TangemTheme.dimens.size24), - contentAlignment = Alignment.CenterStart, - ) { - Text( - text = config.title, - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography.body2, - ) - } } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt index 7ec4d11055..907f834388 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt @@ -2,7 +2,6 @@ package com.tangem.features.txhistory.component import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.* import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.txhistory.entity.TxHistoryUM diff --git a/features/wallet-settings/impl/build.gradle.kts b/features/wallet-settings/impl/build.gradle.kts index b69105d2c8..3bb8904189 100644 --- a/features/wallet-settings/impl/build.gradle.kts +++ b/features/wallet-settings/impl/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(projects.features.onboardingV2.api) implementation(projects.features.pushNotifications.api) implementation(projects.features.hotWallet.api) + implementation(projects.features.wallet.api) /* Project - Core */ implementation(projects.core.decompose) @@ -29,6 +30,7 @@ dependencies { implementation(projects.core.analytics) implementation(projects.core.analytics.models) implementation(projects.common.routing) + implementation(projects.common.ui) /* Project - Domain */ implementation(projects.domain.legacy) @@ -39,7 +41,6 @@ dependencies { implementation(projects.domain.demo) implementation(projects.domain.nft) implementation(projects.domain.settings) - implementation(projects.domain.notifications.toggles) implementation(projects.domain.notifications.models) implementation(projects.domain.notifications) @@ -68,4 +69,5 @@ dependencies { /** Tangem libraries */ implementation(tangemDeps.hot.core) + implementation(tangemDeps.card.core) } \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/impl/DefaultWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/impl/DefaultWalletSettingsComponent.kt index 49f23d0f8c..1cb8c2c145 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/impl/DefaultWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/impl/DefaultWalletSettingsComponent.kt @@ -1,6 +1,7 @@ package com.tangem.feature.walletsettings.component.impl import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -13,6 +14,7 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableDialogComponent +import com.tangem.core.ui.utils.requestPermission import com.tangem.feature.walletsettings.component.NetworksAvailableForNotificationsComponent import com.tangem.feature.walletsettings.component.RenameWalletComponent import com.tangem.feature.walletsettings.component.WalletSettingsComponent @@ -20,6 +22,7 @@ import com.tangem.feature.walletsettings.entity.DialogConfig import com.tangem.feature.walletsettings.entity.NetworksAvailableForNotificationBSConfig import com.tangem.feature.walletsettings.model.WalletSettingsModel import com.tangem.feature.walletsettings.ui.WalletSettingsScreen +import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -61,6 +64,18 @@ internal class DefaultWalletSettingsComponent @AssistedInject constructor( dialog = { dialog.child?.instance?.Dialog() }, ) + val requestPushPermission = requestPermission( + onAllow = { state.onPushNotificationPermissionGranted(true) }, + onDeny = { state.onPushNotificationPermissionGranted(false) }, + permission = PUSH_PERMISSION, + ) + + if (state.requestPushNotificationsPermission) { + LaunchedEffect(Unit) { + requestPushPermission() + } + } + bottomSheet.child?.instance?.BottomSheet() } diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt index 453120249f..e716a35761 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt @@ -2,12 +2,21 @@ package com.tangem.feature.walletsettings.component.preview import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.tangem.common.ui.account.AccountIconPreviewData +import com.tangem.common.ui.userwallet.state.UserWalletItemUM.ImageState import com.tangem.core.analytics.DummyAnalyticsEventHandler import com.tangem.core.decompose.navigation.DummyRouter +import com.tangem.core.ui.components.block.model.BlockUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.walletsettings.component.WalletSettingsComponent +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM.Footer.AddAccountUM +import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.entity.WalletSettingsUM +import com.tangem.feature.walletsettings.impl.R import com.tangem.feature.walletsettings.ui.WalletSettingsScreen import com.tangem.feature.walletsettings.utils.ItemsBuilder import com.tangem.hot.sdk.model.HotWalletId @@ -27,14 +36,11 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { wallets = null, backedUp = false, ), - userWalletName = "My Wallet", isReferralAvailable = true, isLinkMoreCardsAvailable = true, - isRenameWalletAvailable = false, isNFTFeatureEnabled = true, isNFTEnabled = true, onCheckedNFTChange = {}, - renameWallet = {}, forgetWallet = {}, onLinkMoreCardsClick = {}, onReferralClick = {}, @@ -45,11 +51,54 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { onNotificationsDescriptionClick = {}, isNotificationsPermissionGranted = false, onAccessCodeClick = {}, + walletUpgradeDismissed = false, + onUpgradeWalletClick = {}, + onDismissUpgradeWalletClick = {}, + accountsUM = previewAccounts(), + cardItem = previewCardBlock(), ), requestPushNotificationsPermission = false, onPushNotificationPermissionGranted = {}, ) + private fun previewAccounts() = buildList { + WalletSettingsAccountsUM.Header( + id = "accounts_header", + text = resourceReference(R.string.common_accounts), + ).let(::add) + WalletSettingsAccountsUM.Account( + id = "accountId", + accountName = stringReference("Main account"), + accountIconUM = AccountIconPreviewData.randomAccountIcon(), + tokensInfo = stringReference("10 tokens"), + networksInfo = stringReference("2 networks"), + onClick = {}, + ).let(::add) + WalletSettingsAccountsUM.Footer( + id = "accounts_footer", + addAccount = AddAccountUM( + title = resourceReference(R.string.account_form_title_create), + addAccountEnabled = true, + onAddAccountClick = {}, + ), + archivedAccounts = BlockUM( + text = resourceReference(R.string.account_archived_accounts), + iconRes = R.drawable.ic_archive_24, + onClick = {}, + ), + description = resourceReference(R.string.account_reorder_description), + ).let(::add) + } + + private fun previewCardBlock() = WalletSettingsItemUM.CardBlock( + id = "wallet_name", + title = resourceReference(id = R.string.user_wallet_list_rename_popup_placeholder), + text = stringReference("Wallet Name"), + isEnabled = true, + onClick = { }, + imageState = ImageState.MobileWallet, + ) + @Composable override fun Content(modifier: Modifier) { WalletSettingsScreen( diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt index 490fb2b00e..d2cb70fe36 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt @@ -1,6 +1,8 @@ package com.tangem.feature.walletsettings.entity import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.common.ui.userwallet.state.UserWalletItemUM.ImageState import com.tangem.core.ui.components.block.model.BlockUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList @@ -23,11 +25,12 @@ internal sealed class WalletSettingsItemUM { val onCheckedChange: (Boolean) -> Unit, ) : WalletSettingsItemUM() - data class WithText( + data class CardBlock( override val id: String, val title: TextReference, val text: TextReference, val isEnabled: Boolean, + val imageState: ImageState, val onClick: () -> Unit, ) : WalletSettingsItemUM() @@ -43,4 +46,44 @@ internal sealed class WalletSettingsItemUM { val title: TextReference, val description: TextReference, ) : WalletSettingsItemUM() + + data class UpgradeWallet( + override val id: String, + val title: TextReference, + val description: TextReference, + val onClick: () -> Unit, + val onDismissClick: () -> Unit, + ) : WalletSettingsItemUM() +} + +@Immutable +internal sealed class WalletSettingsAccountsUM : WalletSettingsItemUM() { + + data class Header( + override val id: String, + val text: TextReference, + ) : WalletSettingsAccountsUM() + + data class Account( + override val id: String, + val accountName: TextReference, + val accountIconUM: CryptoPortfolioIconUM, + val tokensInfo: TextReference, + val networksInfo: TextReference, + val onClick: () -> Unit, + ) : WalletSettingsAccountsUM() + + data class Footer( + override val id: String, + val addAccount: AddAccountUM, + val archivedAccounts: BlockUM, + val description: TextReference, + ) : WalletSettingsAccountsUM() { + + data class AddAccountUM( + val title: TextReference, + val addAccountEnabled: Boolean, + val onAddAccountClick: () -> Unit, + ) + } } \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsUM.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsUM.kt index 39bd16ae56..04014e4662 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsUM.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsUM.kt @@ -10,4 +10,5 @@ internal data class WalletSettingsUM( val requestPushNotificationsPermission: Boolean = false, val onPushNotificationPermissionGranted: (Boolean) -> Unit, val isWalletBackedUp: Boolean = true, + val walletUpgradeDismissed: Boolean = false, ) \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index f9d0b633b2..0a86e4509c 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -1,6 +1,7 @@ package com.tangem.feature.walletsettings.model import android.os.Build +import arrow.core.Either import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate @@ -15,11 +16,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.settings.SettingsManager -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 -import com.tangem.core.ui.components.bottomsheets.message.icon -import com.tangem.core.ui.components.bottomsheets.message.infoBlock -import com.tangem.core.ui.components.bottomsheets.message.onClick -import com.tangem.core.ui.components.bottomsheets.message.secondaryButton +import com.tangem.core.ui.components.bottomsheets.message.* import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction @@ -29,14 +26,12 @@ import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.nft.DisableWalletNFTUseCase import com.tangem.domain.nft.EnableWalletNFTUseCase import com.tangem.domain.nft.GetWalletNFTEnabledUseCase -import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase import com.tangem.domain.notifications.repository.NotificationsRepository -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.settings.repositories.PermissionRepository -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.* import com.tangem.feature.walletsettings.analytics.Settings import com.tangem.feature.walletsettings.analytics.WalletSettingsAnalyticEvents @@ -46,8 +41,11 @@ import com.tangem.feature.walletsettings.entity.NetworksAvailableForNotification import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.entity.WalletSettingsUM import com.tangem.feature.walletsettings.impl.R +import com.tangem.feature.walletsettings.utils.AccountItemsDelegate import com.tangem.feature.walletsettings.utils.ItemsBuilder +import com.tangem.feature.walletsettings.utils.WalletCardItemDelegate import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents +import com.tangem.hot.sdk.model.HotWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf @@ -56,35 +54,38 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @ModelScoped internal class WalletSettingsModel @Inject constructor( - getWalletUseCase: GetUserWalletUseCase, paramsContainer: ParamsContainer, private val router: Router, private val messageSender: UiMessageSender, private val deleteWalletUseCase: DeleteWalletUseCase, private val itemsBuilder: ItemsBuilder, + private val accountItemsDelegate: AccountItemsDelegate, override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsContextProxy: AnalyticsContextProxy, - private val getShouldSaveUserWalletsSyncUseCase: ShouldSaveUserWalletsSyncUseCase, + walletCardItemDelegateFactory: WalletCardItemDelegate.Factory, private val isDemoCardUseCase: IsDemoCardUseCase, getWalletNFTEnabledUseCase: GetWalletNFTEnabledUseCase, private val enableWalletNFTUseCase: EnableWalletNFTUseCase, private val disableWalletNFTUseCase: DisableWalletNFTUseCase, - private val notificationsToggles: NotificationsFeatureToggles, getWalletNotificationsEnabledUseCase: GetWalletNotificationsEnabledUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase, private val settingsManager: SettingsManager, private val permissionsRepository: PermissionRepository, private val notificationsRepository: NotificationsRepository, - private val getIsHuaweiDeviceWithoutGoogleServicesUseCase: GetIsHuaweiDeviceWithoutGoogleServicesUseCase, + private val isUpgradeWalletNotificationEnabledUseCase: IsUpgradeWalletNotificationEnabledUseCase, + private val dismissUpgradeWalletNotificationUseCase: DismissUpgradeWalletNotificationUseCase, + private val unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase, ) : Model() { val params: WalletSettingsComponent.Params = paramsContainer.require() val dialogNavigation = SlotNavigation() val bottomSheetNavigation: SlotNavigation = SlotNavigation() + private val walletCardItemDelegate = walletCardItemDelegateFactory.create(dialogNavigation) val state: MutableStateFlow = MutableStateFlow( value = WalletSettingsUM( @@ -93,6 +94,7 @@ internal class WalletSettingsModel @Inject constructor( requestPushNotificationsPermission = false, onPushNotificationPermissionGranted = ::onPushNotificationPermissionGranted, isWalletBackedUp = true, + walletUpgradeDismissed = false, ), ) @@ -116,34 +118,35 @@ internal class WalletSettingsModel @Inject constructor( } init { - combine( - getWalletUseCase.invokeFlow(params.userWalletId).distinctUntilChanged(), + fun combineUI(wallet: UserWallet) = combine( getWalletNFTEnabledUseCase.invoke(params.userWalletId), getWalletNotificationsEnabledUseCase(params.userWalletId), - ) { maybeWallet, nftEnabled, notificationsEnabled -> - val wallet = maybeWallet.getOrNull() ?: return@combine - val isRenameWalletAvailable = getShouldSaveUserWalletsSyncUseCase() + isUpgradeWalletNotificationEnabledUseCase(params.userWalletId), + walletCardItemDelegate.cardItemFlow(wallet), + ) { nftEnabled, notificationsEnabled, isUpgradeNotificationEnabled, cardItem -> val isWalletBackedUp = when (wallet) { is UserWallet.Hot -> wallet.backedUp is UserWallet.Cold -> true } - val isNeedShowNotifications = notificationsToggles.isNotificationsEnabled && - !getIsHuaweiDeviceWithoutGoogleServicesUseCase() state.update { value -> value.copy( items = buildItems( userWallet = wallet, - dialogNavigation = dialogNavigation, - isRenameWalletAvailable = isRenameWalletAvailable, + cardItem = cardItem, isNFTEnabled = nftEnabled, isNotificationsEnabled = notificationsEnabled, - isNotificationsFeatureEnabled = isNeedShowNotifications, + isNotificationsFeatureEnabled = true, isNotificationsPermissionGranted = isNotificationsPermissionGranted(), + isUpgradeNotificationEnabled = isUpgradeNotificationEnabled, ), isWalletBackedUp = isWalletBackedUp, ) } } + getUserWalletUseCase.invokeFlow(params.userWalletId) + .distinctUntilChanged() + .filterIsInstance>() + .flatMapLatest { combineUI(it.value) } .launchIn(modelScope) } @@ -159,12 +162,12 @@ internal class WalletSettingsModel @Inject constructor( private fun buildItems( userWallet: UserWallet, - dialogNavigation: SlotNavigation, - isRenameWalletAvailable: Boolean, + cardItem: WalletSettingsItemUM.CardBlock, isNFTEnabled: Boolean, isNotificationsFeatureEnabled: Boolean, isNotificationsEnabled: Boolean, isNotificationsPermissionGranted: Boolean, + isUpgradeNotificationEnabled: Boolean, ): PersistentList { val isMultiCurrency = when (userWallet) { is UserWallet.Cold -> userWallet.isMultiCurrency @@ -172,7 +175,7 @@ internal class WalletSettingsModel @Inject constructor( } return itemsBuilder.buildItems( userWallet = userWallet, - userWalletName = userWallet.name, + cardItem = cardItem, isReferralAvailable = when (userWallet) { is UserWallet.Cold -> userWallet.cardTypesResolver.isTangemWallet() is UserWallet.Hot -> false @@ -182,8 +185,6 @@ internal class WalletSettingsModel @Inject constructor( is UserWallet.Hot -> false }, isManageTokensAvailable = isMultiCurrency, - isRenameWalletAvailable = isRenameWalletAvailable, - renameWallet = { openRenameWalletDialog(userWallet, dialogNavigation) }, isNFTFeatureEnabled = isMultiCurrency, isNFTEnabled = isNFTEnabled, onCheckedNFTChange = ::onCheckedNFTChange, @@ -215,18 +216,13 @@ internal class WalletSettingsModel @Inject constructor( onCheckedNotificationsChanged = ::onCheckedNotificationsChange, onNotificationsDescriptionClick = ::onNotificationsDescriptionClick, onAccessCodeClick = ::onAccessCodeClick, + walletUpgradeDismissed = isUpgradeNotificationEnabled, + onUpgradeWalletClick = ::onUpgradeWalletClick, + onDismissUpgradeWalletClick = ::onDismissUpgradeWalletClick, + accountsUM = with(accountItemsDelegate) { listOf() }, // todo account ) } - private fun openRenameWalletDialog(userWallet: UserWallet, dialogNavigation: SlotNavigation) { - val config = DialogConfig.RenameWallet( - userWalletId = userWallet.walletId, - currentName = userWallet.name, - ) - - dialogNavigation.activate(config) - } - private fun forgetWallet() = modelScope.launch { val hasUserWallets = deleteWalletUseCase(params.userWalletId).getOrElse { Timber.e("Unable to delete wallet: $it") @@ -275,10 +271,6 @@ internal class WalletSettingsModel @Inject constructor( private fun onCheckedNotificationsChange(isChecked: Boolean) { modelScope.launch { if (isChecked) { - if (getIsHuaweiDeviceWithoutGoogleServicesUseCase()) { - showHuaweiDialog() - return@launch - } state.update { value -> value.copy( requestPushNotificationsPermission = true, @@ -291,21 +283,6 @@ internal class WalletSettingsModel @Inject constructor( } } - private fun showHuaweiDialog() { - val message = DialogMessage( - message = resourceReference(R.string.wallet_settings_push_notifications_huawei_warning), - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.common_ok), - warning = true, - onClick = {}, - ) - }, - ) - - messageSender.send(message) - } - private fun onNotificationsDescriptionClick() { bottomSheetNavigation.activate(NetworksAvailableForNotificationBSConfig) } @@ -364,7 +341,45 @@ internal class WalletSettingsModel @Inject constructor( if (!state.value.isWalletBackedUp) { messageSender.send(makeBackupAtFirstAlertBS) } else { - router.push(AppRoute.UpdateAccessCode(params.userWalletId)) + unlockWalletIfNeedAndProceed { + router.push(AppRoute.UpdateAccessCode(params.userWalletId)) + } + } + } + + private fun onUpgradeWalletClick() { + unlockWalletIfNeedAndProceed { + router.push(AppRoute.UpgradeWallet(params.userWalletId)) + } + } + + private fun onDismissUpgradeWalletClick() { + modelScope.launch { + dismissUpgradeWalletNotificationUseCase.invoke(params.userWalletId) + } + } + + private fun unlockWalletIfNeedAndProceed(action: () -> Unit) { + val userWallet = getUserWalletUseCase(params.userWalletId) + .getOrElse { error("User wallet with id ${params.userWalletId} not found") } + if (userWallet is UserWallet.Hot) { + val hotWalletId = userWallet.hotWalletId + when (hotWalletId.authType) { + HotWalletId.AuthType.NoPassword -> { + action() + } + HotWalletId.AuthType.Password, + HotWalletId.AuthType.Biometry, + -> modelScope.launch { + unlockHotWalletContextualUseCase.invoke(hotWalletId) + .onLeft { + Timber.e(it, "Unable to unlock wallet with id ${params.userWalletId}") + } + .onRight { + action() + } + } + } } } } \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt index fe95f8129a..c0d5e0cd86 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt @@ -2,39 +2,52 @@ package com.tangem.feature.walletsettings.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.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountRow +import com.tangem.common.ui.userwallet.CardImage +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.TangemSwitch import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.components.block.BlockItem +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.components.items.DescriptionItem import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.WalletSettingsScreenTestTags -import com.tangem.core.ui.utils.requestPermission import com.tangem.feature.walletsettings.component.preview.PreviewWalletSettingsComponent +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.entity.WalletSettingsUM import com.tangem.feature.walletsettings.impl.R -import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION @Composable internal fun WalletSettingsScreen( @@ -70,7 +83,6 @@ internal fun WalletSettingsScreen( private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { LazyColumn( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), contentPadding = PaddingValues( top = TangemTheme.dimens.spacing16, bottom = TangemTheme.dimens.spacing16, @@ -88,8 +100,17 @@ private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { items = state.items, key = WalletSettingsItemUM::id, ) { item -> - val itemModifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16) + val offsetModifier = when (item) { + is WalletSettingsAccountsUM.Account, + is WalletSettingsAccountsUM.Footer, + -> Modifier.padding(horizontal = TangemTheme.dimens.spacing16) + else -> Modifier.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing16, + ) + } + val itemModifier = offsetModifier .fillMaxWidth() .testTag(WalletSettingsScreenTestTags.SCREEN_ITEM) @@ -98,7 +119,7 @@ private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { modifier = itemModifier, model = item, ) - is WalletSettingsItemUM.WithText -> TextBlock( + is WalletSettingsItemUM.CardBlock -> CardBlock( modifier = itemModifier, model = item, ) @@ -116,21 +137,16 @@ private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { modifier = itemModifier, model = item, ) + is WalletSettingsItemUM.UpgradeWallet -> UpgradeWalletBlock( + modifier = itemModifier, + model = item, + ) + is WalletSettingsAccountsUM.Header -> AccountsHeader(item, itemModifier) + is WalletSettingsAccountsUM.Account -> AccountItem(item, itemModifier) + is WalletSettingsAccountsUM.Footer -> AccountsFooter(item, itemModifier) } } } - - val requestPushPermission = requestPermission( - onAllow = { state.onPushNotificationPermissionGranted(true) }, - onDeny = { state.onPushNotificationPermissionGranted(false) }, - permission = PUSH_PERMISSION, - ) - - if (state.requestPushNotificationsPermission) { - LaunchedEffect(Unit) { - requestPushPermission() - } - } } @Composable @@ -168,30 +184,40 @@ private fun ItemsBlock(model: WalletSettingsItemUM.WithItems, modifier: Modifier } @Composable -private fun TextBlock(model: WalletSettingsItemUM.WithText, modifier: Modifier = Modifier) { +private fun CardBlock(model: WalletSettingsItemUM.CardBlock, modifier: Modifier = Modifier) { BlockCard( modifier = modifier.fillMaxWidth(), enabled = model.isEnabled, onClick = model.onClick, ) { - Column( + Row( modifier = Modifier.padding(all = TangemTheme.dimens.spacing12), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = model.title.resolveReference(), - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography.subtitle2, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - Text( - text = model.text.resolveReference(), - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.body1, - overflow = TextOverflow.Ellipsis, + CardImage(model.imageState) + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.title.resolveReference(), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = model.text.resolveReference(), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle1, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + SecondarySmallButton( + config = SmallButtonConfig( + enabled = model.isEnabled, + text = resourceReference(R.string.common_rename), + onClick = model.onClick, + ), ) } } @@ -225,6 +251,21 @@ private fun SwitchBlock(model: WalletSettingsItemUM.WithSwitch, modifier: Modifi } } +@Composable +private fun UpgradeWalletBlock(model: WalletSettingsItemUM.UpgradeWallet, modifier: Modifier = Modifier) { + Notification( + config = NotificationConfig( + title = model.title, + subtitle = model.description, + iconResId = R.drawable.ic_hardware_backup_36, + iconSize = 36.dp, + onClick = model.onClick, + onCloseClick = model.onDismissClick, + ), + modifier = modifier, + ) +} + @Composable private fun NotificationAlertBlock(model: WalletSettingsItemUM.NotificationPermission, modifier: Modifier = Modifier) { Notification( @@ -237,6 +278,134 @@ private fun NotificationAlertBlock(model: WalletSettingsItemUM.NotificationPermi ) } +@Composable +private fun AccountsHeader(model: WalletSettingsAccountsUM.Header, modifier: Modifier = Modifier) { + Text( + modifier = modifier + .background( + shape = RoundedCornerShape( + topStart = TangemTheme.dimens.radius16, + topEnd = TangemTheme.dimens.radius16, + ), + color = TangemTheme.colors.background.primary, + ) + .padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing8, + bottom = TangemTheme.dimens.spacing4, + ), + text = model.text.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun AccountItem(model: WalletSettingsAccountsUM.Account, modifier: Modifier = Modifier) { + val subtitle = stringResourceSafe( + id = R.string.account_label_tokens_info, + formatArgs = arrayOf( + model.tokensInfo.resolveReference(), + model.networksInfo.resolveReference(), + ), + ) + AccountRow( + modifier = modifier + .background(color = TangemTheme.colors.background.primary) + .clickable(onClick = model.onClick) + .padding(12.dp), + title = model.accountName, + subtitle = stringReference(subtitle), + icon = model.accountIconUM, + ) +} + +@Composable +private fun AccountsFooter(model: WalletSettingsAccountsUM.Footer, modifier: Modifier = Modifier) { + Column(modifier) { + Column( + modifier = Modifier.background( + shape = RoundedCornerShape( + bottomStart = TangemTheme.dimens.radius16, + bottomEnd = TangemTheme.dimens.radius16, + ), + color = TangemTheme.colors.background.primary, + ), + ) { + AddAccountRow(model.addAccount) + SpacerH( + height = TangemTheme.dimens.size0_5, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing12) + .background(TangemTheme.colors.stroke.primary), + ) + BlockItem( + modifier = Modifier.fillMaxWidth(), + model = model.archivedAccounts, + ) + } + SpacerH8() + Text( + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing12), + text = model.description.resolveReference(), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun AddAccountRow(model: WalletSettingsAccountsUM.Footer.AddAccountUM, modifier: Modifier = Modifier) { + val iconTint: Color + val backgroundColor: Color + val textColor: Color + if (model.addAccountEnabled) { + iconTint = TangemTheme.colors.icon.accent + backgroundColor = TangemTheme.colors.icon.accent.copy(alpha = 0.1f) + textColor = TangemTheme.colors.text.accent + } else { + iconTint = TangemTheme.colors.icon.inactive + backgroundColor = TangemTheme.colors.field.primary + textColor = TangemTheme.colors.text.disabled + } + Row( + modifier = modifier + .clickable(onClick = model.onAddAccountClick) + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(backgroundColor) + .clickable(onClick = { model.onAddAccountClick() }), + ) { + Icon( + modifier = Modifier.size(18.dp), + tint = iconTint, + imageVector = ImageVector.vectorResource(id = R.drawable.ic_plus_24), + contentDescription = null, + ) + } + + Text( + text = model.title.resolveReference(), + color = textColor, + style = TangemTheme.typography.subtitle1, + ) + } +} + @Composable private fun DescriptionWithMoreBlock( model: WalletSettingsItemUM.DescriptionWithMore, @@ -266,4 +435,21 @@ private fun Preview_WalletSettingsScreen() { PreviewWalletSettingsComponent().Content(modifier = Modifier.fillMaxSize()) } } + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_WalletSettingsScreen1() { + TangemThemePreview { + UpgradeWalletBlock( + model = WalletSettingsItemUM.UpgradeWallet( + id = "upgrade_wallet", + title = stringReference("Upgrade wallet with a hardware backup"), + description = stringReference("Keep your crypto safe with Tangem’s best-in-class hardware wallet."), + onClick = {}, + onDismissClick = {}, + ), + ) + } +} // endregion Preview \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt new file mode 100644 index 0000000000..0857416626 --- /dev/null +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt @@ -0,0 +1,110 @@ +package com.tangem.feature.walletsettings.utils + +import com.tangem.common.routing.AppRoute +import com.tangem.common.ui.account.toUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.block.model.BlockUM +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM.Footer.AddAccountUM +import com.tangem.feature.walletsettings.impl.R +import javax.inject.Inject + +@ModelScoped +internal class AccountItemsDelegate @Inject constructor( + private val router: Router, + private val messageSender: UiMessageSender, +) { + + fun buildUiList(userWalletId: UserWalletId, accounts: List): List = buildList { + WalletSettingsAccountsUM.Header( + id = "accounts_header", + text = resourceReference(R.string.common_accounts), + ).let(::add) + + addAll(accounts.map(::mapAccount)) + + val addAccountEnabled = true // todo account + WalletSettingsAccountsUM.Footer( + id = "accounts_footer", + addAccount = AddAccountUM( + title = resourceReference(R.string.account_form_title_create), + addAccountEnabled = addAccountEnabled, + onAddAccountClick = { + if (addAccountEnabled) openAddAccount(userWalletId) else canNotAddAccountDialog() + }, + ), + archivedAccounts = BlockUM( + text = resourceReference(R.string.account_archived_accounts), + iconRes = R.drawable.ic_archive_24, + onClick = { openArchivedAccounts(userWalletId) }, + ), + description = resourceReference(R.string.account_reorder_description), + ).let(::add) + } + + private fun mapAccount(account: Account): WalletSettingsAccountsUM = when (account) { + is Account.CryptoPortfolio -> account.mapCryptoPortfolio() + } + + private fun Account.CryptoPortfolio.mapCryptoPortfolio(): WalletSettingsAccountsUM { + return WalletSettingsAccountsUM.Account( + id = accountId.value, + accountName = accountName.toUM().value, + accountIconUM = icon.toUM(), + tokensInfo = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + networksInfo = pluralReference( + R.plurals.common_networks_count, + count = networksCount, + formatArgs = wrappedList(networksCount), + ), + onClick = { openAccountDetails(this) }, + ) + } + + private fun openAccountDetails(account: Account) { + router.push(AppRoute.AccountDetails(account)) + } + + private fun openArchivedAccounts(userWalletId: UserWalletId) { + router.push(AppRoute.ArchivedAccountList(userWalletId)) + } + + private fun openAddAccount(userWalletId: UserWalletId) { + router.push(AppRoute.CreateAccount(userWalletId)) + } + + private fun canNotAddAccountDialog() { + val firstAction = EventMessageAction( + title = resourceReference(R.string.common_got_it), + onClick = { }, + ) + messageSender.send( + DialogMessage( + title = resourceReference(R.string.account_add_limit_dialog_title), + message = resourceReference( + id = R.string.account_add_limit_dialog_description, + formatArgs = wrappedList(MAX_ACCOUNT_COUNT.toString()), + ), + firstActionBuilder = { firstAction }, + ), + ) + } + + companion object { + // todo account use domain const? + private const val MAX_ACCOUNT_COUNT = 20 + } +} \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt index 5086f22579..6d2bf36754 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt @@ -9,9 +9,9 @@ import com.tangem.core.ui.components.block.model.BlockUM import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.walletsettings.analytics.Settings +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.impl.R import com.tangem.hot.sdk.model.HotWalletId @@ -29,11 +29,11 @@ internal class ItemsBuilder @Inject constructor( @Suppress("LongParameterList") fun buildItems( userWallet: UserWallet, - userWalletName: String, + cardItem: WalletSettingsItemUM.CardBlock, + accountsUM: List, isLinkMoreCardsAvailable: Boolean, isReferralAvailable: Boolean, isManageTokensAvailable: Boolean, - isRenameWalletAvailable: Boolean, isNFTFeatureEnabled: Boolean, isNFTEnabled: Boolean, onCheckedNFTChange: (Boolean) -> Unit, @@ -43,13 +43,24 @@ internal class ItemsBuilder @Inject constructor( onCheckedNotificationsChanged: (Boolean) -> Unit, onNotificationsDescriptionClick: () -> Unit, forgetWallet: () -> Unit, - renameWallet: () -> Unit, onLinkMoreCardsClick: () -> Unit, onReferralClick: () -> Unit, onAccessCodeClick: () -> Unit, + walletUpgradeDismissed: Boolean, + onUpgradeWalletClick: () -> Unit, + onDismissUpgradeWalletClick: () -> Unit, ): PersistentList = persistentListOf() - .add(buildNameItem(userWalletName, isRenameWalletAvailable, renameWallet)) + .add(cardItem) + .addAll( + buildUpgradeWalletItem( + userWallet = userWallet, + walletUpgradeDismissed = walletUpgradeDismissed, + onUpgradeWalletClick = onUpgradeWalletClick, + onDismissUpgradeWalletClick = onDismissUpgradeWalletClick, + ), + ) .addAll(buildAccessCodeItem(userWallet, onAccessCodeClick)) + .addAll(accountsUM) .add( buildCardItem( userWallet = userWallet, @@ -107,15 +118,6 @@ internal class ItemsBuilder @Inject constructor( } } - private fun buildNameItem(walletName: String, isRenameWalletAvailable: Boolean, renameWallet: () -> Unit) = - WalletSettingsItemUM.WithText( - id = "wallet_name", - title = resourceReference(id = R.string.settings_wallet_name_title), - text = stringReference(walletName), - isEnabled = isRenameWalletAvailable, - onClick = renameWallet, - ) - private fun buildNFTItem(isNFTEnabled: Boolean, onCheckedNFTChange: (Boolean) -> Unit) = WalletSettingsItemUM.WithSwitch( id = "nft", @@ -124,6 +126,28 @@ internal class ItemsBuilder @Inject constructor( onCheckedChange = onCheckedNFTChange, ) + private fun buildUpgradeWalletItem( + userWallet: UserWallet, + walletUpgradeDismissed: Boolean, + onUpgradeWalletClick: () -> Unit, + onDismissUpgradeWalletClick: () -> Unit, + ): List = when (userWallet) { + is UserWallet.Cold -> emptyList() + is UserWallet.Hot -> if (!walletUpgradeDismissed) { + listOf( + WalletSettingsItemUM.UpgradeWallet( + id = "upgrade_wallet", + title = resourceReference(id = R.string.hw_upgrade_to_cold_banner_title), + description = resourceReference(id = R.string.hw_upgrade_to_cold_banner_description), + onClick = onUpgradeWalletClick, + onDismissClick = onDismissUpgradeWalletClick, + ), + ) + } else { + emptyList() + } + } + private fun buildNotificationsPermissionItem() = WalletSettingsItemUM.NotificationPermission( id = "notifications_permission", title = resourceReference(id = R.string.transaction_notifications_warning_title), diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/WalletCardItemDelegate.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/WalletCardItemDelegate.kt new file mode 100644 index 0000000000..322eaaac62 --- /dev/null +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/WalletCardItemDelegate.kt @@ -0,0 +1,55 @@ +package com.tangem.feature.walletsettings.utils + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsSyncUseCase +import com.tangem.feature.walletsettings.entity.DialogConfig +import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM +import com.tangem.feature.walletsettings.impl.R +import com.tangem.features.wallet.utils.UserWalletImageFetcher +import com.tangem.operations.attestation.ArtworkSize +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow + +internal class WalletCardItemDelegate @AssistedInject constructor( + private val getShouldSaveUserWalletsSyncUseCase: ShouldSaveUserWalletsSyncUseCase, + private val walletImageFetcher: UserWalletImageFetcher, + @Assisted private val dialogNavigation: SlotNavigation, +) { + + fun cardItemFlow(wallet: UserWallet): Flow = combine( + flow = walletImageFetcher.walletImage(wallet, ArtworkSize.SMALL), + flow2 = flow { emit(getShouldSaveUserWalletsSyncUseCase()) }, + transform = { imageState, isRenameAvailable -> + val walletName = wallet.name + WalletSettingsItemUM.CardBlock( + id = "wallet_name", + title = resourceReference(id = R.string.user_wallet_list_rename_popup_placeholder), + text = stringReference(walletName), + isEnabled = isRenameAvailable, + onClick = { openRenameWalletDialog(wallet) }, + imageState = imageState, + ) + }, + ) + + private fun openRenameWalletDialog(userWallet: UserWallet) { + val config = DialogConfig.RenameWallet( + userWalletId = userWallet.walletId, + currentName = userWallet.name, + ) + dialogNavigation.activate(config) + } + + @AssistedFactory + interface Factory { + fun create(dialogNavigation: SlotNavigation): WalletCardItemDelegate + } +} \ No newline at end of file diff --git a/features/wallet/api/build.gradle.kts b/features/wallet/api/build.gradle.kts index 3b1f5eeea2..4c23e065f7 100644 --- a/features/wallet/api/build.gradle.kts +++ b/features/wallet/api/build.gradle.kts @@ -15,6 +15,9 @@ dependencies { /** Project - Domain */ implementation(projects.domain.models) + /** Tangem libraries */ + implementation(tangemDeps.card.core) + /** Core */ implementation(projects.core.ui) implementation(projects.core.decompose) diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt new file mode 100644 index 0000000000..42738f4e11 --- /dev/null +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt @@ -0,0 +1,20 @@ +package com.tangem.features.wallet.utils + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.operations.attestation.ArtworkSize +import kotlinx.coroutines.flow.Flow + +interface UserWalletImageFetcher { + + fun walletImage(walletId: UserWalletId, size: ArtworkSize): Flow + fun walletImage(cardDTO: CardDTO, size: ArtworkSize): Flow + fun walletImage(wallet: UserWallet, size: ArtworkSize): Flow + + fun walletsImage( + wallets: Collection, + size: ArtworkSize, + ): Flow> +} \ No newline at end of file diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletsFetcher.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletsFetcher.kt index 674541796f..97868f231b 100644 --- a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletsFetcher.kt +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletsFetcher.kt @@ -14,7 +14,7 @@ interface UserWalletsFetcher { fun create( messageSender: UiMessageSender, onlyMultiCurrency: Boolean, - authMode: Boolean, + isAuthMode: Boolean, onWalletClick: (UserWalletId) -> Unit, ): UserWalletsFetcher } diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index 1df9de0f95..daafa8122d 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -95,7 +95,6 @@ dependencies { implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) implementation(projects.domain.notifications) - implementation(projects.domain.notifications.toggles) implementation(projects.domain.transaction) /** Feature Apis */ diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 14324845f1..4921d64c1e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -10,7 +10,7 @@ import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked @@ -18,11 +18,8 @@ import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.nft.ObserveAndClearNFTCacheIfNeedUseCase import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase import com.tangem.domain.notifications.repository.NotificationsRepository -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.settings.* -import com.tangem.domain.tokens.FetchCurrencyStatusUseCase import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase -import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.wallets.usecase.* import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.router.InnerWalletRouter @@ -39,6 +36,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.transformers.* import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider import com.tangem.features.biometry.AskBiometryComponent +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener @@ -62,7 +60,7 @@ internal class WalletModel @Inject constructor( private val walletScreenContentLoader: WalletScreenContentLoader, private val getSelectedWalletUseCase: GetSelectedWalletUseCase, private val getWalletsUseCase: GetWalletsUseCase, - private val shouldShowSaveWalletScreenUseCase: ShouldShowSaveWalletScreenUseCase, + private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase, private val shouldShowMarketsTooltipUseCase: ShouldShowMarketsTooltipUseCase, private val setWalletFirstTimeUsageUseCase: SetWalletFirstTimeUsageUseCase, private val canUseBiometryUseCase: CanUseBiometryUseCase, @@ -76,17 +74,16 @@ internal class WalletModel @Inject constructor( private val tokenListStore: MultiWalletTokenListStore, private val onrampStatusFactory: OnrampStatusFactory, private val analyticsEventsHandler: AnalyticsEventHandler, - private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, private val walletContentFetcher: WalletContentFetcher, - private val tokensFeatureToggles: TokensFeatureToggles, private val observeAndClearNFTCacheIfNeedUseCase: ObserveAndClearNFTCacheIfNeedUseCase, private val walletDeepLinkActionListener: WalletDeepLinkActionListener, private val notificationsRepository: NotificationsRepository, private val getWalletsListForEnablingUseCase: GetWalletsForAutomaticallyPushEnablingUseCase, private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase, - private val notificationsFeatureToggles: NotificationsFeatureToggles, private val shouldSaveUserWalletsSyncUseCase: ShouldSaveUserWalletsSyncUseCase, private val getIsHuaweiDeviceWithoutGoogleServicesUseCase: GetIsHuaweiDeviceWithoutGoogleServicesUseCase, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val userWalletsListRepository: UserWalletsListRepository, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -144,7 +141,7 @@ internal class WalletModel @Inject constructor( modelScope.launch(dispatchers.main) { withContext(dispatchers.io) { delay(timeMillis = 1_800) } - if (isShowSaveWalletScreenEnabled()) { + if (shouldShowAskBiometryBottomSheet()) { innerWalletRouter.dialogNavigation.activate( configuration = WalletDialogConfig.AskForBiometry, ) @@ -166,8 +163,15 @@ internal class WalletModel @Inject constructor( } } - private suspend fun isShowSaveWalletScreenEnabled(): Boolean { - return innerWalletRouter.isWalletLastScreen() && shouldShowSaveWalletScreenUseCase() && canUseBiometryUseCase() + private suspend fun shouldShowAskBiometryBottomSheet(): Boolean { + return if (hotWalletFeatureToggles.isHotWalletEnabled) { + userWalletsListRepository.userWalletsSync().any { it is UserWallet.Cold } && + innerWalletRouter.isWalletLastScreen() && + shouldShowAskBiometryUseCase() && + canUseBiometryUseCase() + } else { + innerWalletRouter.isWalletLastScreen() && shouldShowAskBiometryUseCase() && canUseBiometryUseCase() + } } private fun subscribeToUserWalletsUpdates() = channelFlow { @@ -223,7 +227,6 @@ internal class WalletModel @Inject constructor( "isHuaweiDevice $isHuaweiDevice", ) if (!isBiometricsEnabled) return@launch - if (isHuaweiDevice) return@launch if (!shouldShowBottomSheet) return@launch delay(timeMillis = 1_800) @@ -363,12 +366,29 @@ internal class WalletModel @Inject constructor( is WalletsUpdateActionResolver.Action.RenameWallets -> { stateHolder.update(transformer = RenameWalletsTransformer(renamedWallets = action.renamedWallets)) } + is WalletsUpdateActionResolver.Action.ReloadWarningsForWallets -> { + reloadWarnings(action) + } + WalletsUpdateActionResolver.Action.EmptyWallets -> { + Timber.w("Wallets list is empty!") + } is WalletsUpdateActionResolver.Action.Unknown -> { Timber.w("Unable to perform action: $action") } } } + private fun reloadWarnings(action: WalletsUpdateActionResolver.Action.ReloadWarningsForWallets) { + action.wallets.forEach { + walletScreenContentLoader.load( + userWallet = it, + clickIntents = clickIntents, + coroutineScope = modelScope, + isRefresh = true, + ) + } + } + private suspend fun initializeWallets(action: WalletsUpdateActionResolver.Action.InitializeWallets) { stateHolder.update( transformer = InitializeWalletsTransformer( @@ -389,13 +409,11 @@ internal class WalletModel @Inject constructor( val otherWallets = action.wallets.minus(action.selectedWallet) - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - otherWallets - .filterNot(UserWallet::isLocked) - .onEach { userWallet -> - modelScope.launch { walletContentFetcher(userWalletId = userWallet.walletId) } - } - } + otherWallets + .filterNot(UserWallet::isLocked) + .onEach { userWallet -> + modelScope.launch { walletContentFetcher(userWalletId = userWallet.walletId) } + } if (action.wallets.size > 1 && isWalletsScrollPreviewEnabled()) { val direction = if (action.selectedWalletIndex == action.wallets.lastIndex) { @@ -553,32 +571,18 @@ internal class WalletModel @Inject constructor( } private suspend fun fetchWalletContent(userWallet: UserWallet) { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - if (userWallet.isLocked) return + if (userWallet.isLocked) return - /* - * Updating the balance of the current wallet is an essential part of InitializationWallets, - * so the coroutine is launched in the current context - */ - supervisorScope { - launch { walletContentFetcher(userWalletId = userWallet.walletId) } - } - } else { - fetchIfSingleWallet(userWallet = userWallet) - } - } - - private fun fetchIfSingleWallet(userWallet: UserWallet) { - if (userWallet is UserWallet.Cold && userWallet.scanResponse.cardTypesResolver.isSingleWallet()) { - modelScope.launch { - fetchCurrencyStatusUseCase(userWalletId = userWallet.walletId) - .onLeft { Timber.e(it.toString()) } - } + /* + * Updating the balance of the current wallet is an essential part of InitializationWallets, + * so the coroutine is launched in the current context + */ + supervisorScope { + launch { walletContentFetcher(userWalletId = userWallet.walletId) } } } private fun enableNotificationsIfNeeded() { - if (!notificationsFeatureToggles.isNotificationsEnabled) return modelScope.launch { val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications() if (isUserAllowToEnableNotifications) { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt index 48ee23027a..562c8a99a8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt @@ -9,6 +9,7 @@ import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.feature.wallet.presentation.wallet.state.model.NOT_INITIALIZED_WALLET_INDEX import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import timber.log.Timber @@ -25,6 +26,10 @@ internal class WalletsUpdateActionResolver @Inject constructor( ) { fun resolve(wallets: List, currentState: WalletScreenState): Action { + if (wallets.isEmpty()) { + return Action.EmptyWallets + } + val selectedWallet = getSelectedWalletSyncUseCase().getOrElse { /* Selected user wallet can be null after reset if remaining user wallets is locked */ return Action.Unknown @@ -71,10 +76,21 @@ internal class WalletsUpdateActionResolver @Inject constructor( isAnyWalletNameChanged(state, wallets) -> { getRenameWalletsAction(state, wallets) } + isAnyHotWalletBackedUpChange(state, wallets) -> { + getHotWalletsBackedUpAction(state, wallets) + } else -> getUpdateSelectedWalletAction(state, wallets, selectedWallet) } } + private fun isAnyHotWalletBackedUpChange(state: WalletScreenState, wallets: List): Boolean { + val incompleteActivationWalletIds = state.incompleteActivationWalletIds() + val walletsToUpdate = wallets.filter { + it is UserWallet.Hot && it.backedUp == incompleteActivationWalletIds.contains(it.walletId) + } + return walletsToUpdate.isNotEmpty() + } + private fun isWalletsCountChanged(state: WalletScreenState, wallets: List): Boolean { val prevWalletsSize = state.wallets.size val walletsSize = wallets.size @@ -141,6 +157,19 @@ internal class WalletsUpdateActionResolver @Inject constructor( return prevWalletsIds == newWalletsIds && isAnyNameChanged } + private fun getHotWalletsBackedUpAction( + state: WalletScreenState, + wallets: List, + ): Action.ReloadWarningsForWallets { + val incompleteActivationWalletIds = state.incompleteActivationWalletIds() + + val walletsToUpdate = wallets.filter { + it is UserWallet.Hot && it.backedUp == incompleteActivationWalletIds.contains(it.walletId) + } + + return Action.ReloadWarningsForWallets(walletsToUpdate) + } + private fun getRenameWalletsAction(state: WalletScreenState, wallets: List): Action.RenameWallets { val prevWallets = state.wallets.map { it.walletCardState.id to it.walletCardState.title } val newWallets = wallets.map { it.walletId to it.name } @@ -195,6 +224,16 @@ internal class WalletsUpdateActionResolver @Inject constructor( ?: error("Previous selected wallet is not found") } + private fun WalletScreenState.incompleteActivationWalletIds(): List { + return wallets.mapNotNull { + if (it.warnings.any { it is WalletNotification.FinishWalletActivation }) { + it.walletCardState.id + } else { + null + } + } + } + private fun List.indexOfWallet(id: UserWalletId): Int { val selectedIndex = indexOfFirst { it.walletId == id } @@ -319,6 +358,12 @@ internal class WalletsUpdateActionResolver @Inject constructor( } } + data class ReloadWarningsForWallets( + val wallets: List, + ) : Action() + + data object EmptyWallets : Action() + data object Unknown : Action() } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt index 4a492e3a06..0c9b0a698c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt @@ -4,7 +4,7 @@ import arrow.core.getOrElse import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.visa.GetVisaCurrencyUseCase @@ -39,7 +39,7 @@ internal class VisaWalletIntentsImplementor @Inject constructor( private val eventSender: WalletEventSender, private val getVisaCurrencyUseCase: GetVisaCurrencyUseCase, private val getVisaTxDetailsUseCase: GetVisaTxDetailsUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val getUserWalletsUseCase: GetWalletsUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val dispatchers: CoroutineDispatcherProvider, @@ -101,13 +101,13 @@ internal class VisaWalletIntentsImplementor @Inject constructor( val userWalletId = stateController.getSelectedWalletId() val userWallet = getUserWalletsUseCase.invokeSync() .firstOrNull { it.walletId == userWalletId } ?: return@launch - val cardInfo = getCardInfoUseCase.invoke( + val cardInfo = getWalletMetaInfoUseCase.invoke( userWallet.requireColdWallet().scanResponse, ).getOrNull() ?: return@launch sendFeedbackEmailUseCase( FeedbackEmailType.Visa.Dispute( - cardInfo = cardInfo, + walletMetaInfo = cardInfo, visaTxDetails = txDetails, ), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletClickIntents.kt index 55fd1893e3..115b1114ba 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletClickIntents.kt @@ -1,18 +1,10 @@ package com.tangem.feature.wallet.child.wallet.model.intents import com.tangem.core.decompose.di.ModelScoped -import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase -import com.tangem.domain.appcurrency.extenstions.unwrap -import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.exchange.RampStateManager -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.onramp.FetchHotCryptoUseCase import com.tangem.domain.settings.NeverToShowWalletsScrollPreview -import com.tangem.domain.tokens.FetchCardTokenListUseCase -import com.tangem.domain.tokens.FetchCurrencyStatusUseCase -import com.tangem.domain.tokens.FetchTokenListUseCase -import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.SelectWalletUseCase import com.tangem.feature.wallet.presentation.router.InnerWalletRouter @@ -23,7 +15,6 @@ import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContent import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetRefreshStateTransformer -import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetTokenListErrorTransformer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -43,12 +34,7 @@ internal class WalletClickIntents @Inject constructor( private val walletScreenContentLoader: WalletScreenContentLoader, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val selectWalletUseCase: SelectWalletUseCase, - private val fetchTokenListUseCase: FetchTokenListUseCase, private val walletContentFetcher: WalletContentFetcher, - private val tokensFeatureToggles: TokensFeatureToggles, - private val fetchCardTokenListUseCase: FetchCardTokenListUseCase, - private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, - private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val neverToShowWalletsScrollPreview: NeverToShowWalletsScrollPreview, private val rampStateManager: RampStateManager, private val fetchHotCryptoUseCase: FetchHotCryptoUseCase, @@ -88,7 +74,7 @@ internal class WalletClickIntents @Inject constructor( stateHolder.update { it.copy(selectedWalletIndex = index) } maybeUserWallet.onRight { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled && !it.isLocked) { + if (!it.isLocked) { launch { walletContentFetcher(userWalletId = it.walletId) } } @@ -131,28 +117,7 @@ internal class WalletClickIntents @Inject constructor( ) modelScope.launch { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - walletContentFetcher(userWalletId = userWallet.walletId, forceUpdate = true) - } else { - val isSingleWalletWithToken = userWallet is UserWallet.Cold && - userWallet.cardTypesResolver.isSingleWalletWithToken() - - val maybeFetchResult = if (isSingleWalletWithToken) { - fetchCardTokenListUseCase(userWalletId = userWallet.walletId, refresh = true) - } else { - fetchTokenListUseCase(userWalletId = userWallet.walletId) - } - - maybeFetchResult.onLeft { - stateHolder.update( - SetTokenListErrorTransformer( - selectedWallet = userWallet, - error = it, - appCurrency = getSelectedAppCurrencyUseCase.unwrap(), - ), - ) - } - } + walletContentFetcher(userWalletId = userWallet.walletId, forceUpdate = true) buildList { async { rampStateManager.fetchSellServiceData() }.let(::add) @@ -177,11 +142,7 @@ internal class WalletClickIntents @Inject constructor( ) modelScope.launch { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - walletContentFetcher(userWalletId = userWallet.walletId, forceUpdate = true) - } else { - fetchCurrencyStatusUseCase(userWallet.walletId, refresh = true) - } + walletContentFetcher(userWalletId = userWallet.walletId, forceUpdate = true) onrampStatusFactory.updateOnrmapTransactionStatuses(userWallet) walletScreenContentLoader.load( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index cc5a440140..da6c773a77 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -24,6 +24,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBot import com.tangem.feature.wallet.presentation.wallet.state.transformers.OpenBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.MultiWalletCurrencyActionsConverter import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.take @@ -76,9 +77,16 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( private val reduxStateHolder: ReduxStateHolder, private val walletEventSender: WalletEventSender, private val analyticsEventHandler: AnalyticsEventHandler, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : BaseWalletClickIntents(), WalletContentClickIntents { override fun onDetailsClick() { + if (hotWalletFeatureToggles.isHotWalletEnabled) { + router.openDetailsScreen(stateHolder.getSelectedWalletId()) + return + } + + // Will be removed after Hot Wallet release modelScope.launch(dispatchers.main) { val userWalletId = stateHolder.getSelectedWalletId() val userWallet = getUserWalletUseCase(userWalletId).getOrElse { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 5e3757b744..55390d5b9e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -11,7 +11,8 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase import com.tangem.domain.card.SetCardWasScannedUseCase -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency @@ -19,6 +20,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.promo.ShouldShowPromoWalletUseCase import com.tangem.domain.promo.models.PromoId import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher @@ -43,6 +45,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletBottomShe import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -75,7 +78,7 @@ internal interface WalletWarningsClickIntents { fun onClosePromoClick(promoId: PromoId) - fun onPromoClick(promoId: PromoId) + fun onPromoClick(promoId: PromoId, cryptoCurrency: CryptoCurrency? = null) fun onSupportClick() @@ -92,7 +95,7 @@ internal interface WalletWarningsClickIntents { fun onFinishWalletActivationClick() } -@Suppress("LongParameterList") +@Suppress("LargeClass", "LongParameterList") @ModelScoped internal class WalletWarningsClickIntentsImplementor @Inject constructor( private val stateHolder: WalletStateController, @@ -107,7 +110,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val dispatchers: CoroutineDispatcherProvider, private val shouldShowPromoWalletUseCase: ShouldShowPromoWalletUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val seedPhraseNotificationUseCase: SeedPhraseNotificationUseCase, private val urlOpener: UrlOpener, @@ -116,6 +119,8 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, private val stakingIdFactory: StakingIdFactory, private val appRouter: AppRouter, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val userWalletsListRepository: UserWalletsListRepository, ) : BaseWalletClickIntents(), WalletWarningsClickIntents { override fun onAddBackupCardClick() { @@ -167,6 +172,22 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( override fun onOpenUnlockWalletsBottomSheetClick() { analyticsEventHandler.send(MainScreen.WalletUnlockTapped) + if (hotWalletFeatureToggles.isHotWalletEnabled) { + modelScope.launch { + userWalletsListRepository.unlockAllWallets() + .onLeft { + val selectedUserWallet = getSelectedUserWallet() ?: return@onLeft + val method = when (selectedUserWallet) { + is UserWallet.Cold -> UserWalletsListRepository.UnlockMethod.Scan() + is UserWallet.Hot -> UserWalletsListRepository.UnlockMethod.AccessCode + } + userWalletsListRepository.unlock(stateHolder.getSelectedWalletId(), method) + } + } + return + } + + // Will be removed after hot wallet release stateHolder.showBottomSheet( WalletBottomSheetConfig.UnlockWallets( onUnlockClick = this::onUnlockWalletClick, @@ -175,6 +196,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( ) } + @Deprecated("Will be removed with hot wallet release") override fun onUnlockWalletClick() { analyticsEventHandler.send(MainScreen.UnlockAllWithBiometrics) @@ -202,6 +224,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( walletEventSender.send(event) } + @Deprecated("Will be removed with hot wallet release") override fun onScanToUnlockWalletClick() { analyticsEventHandler.send(MainScreen.UnlockWithCardScan) openScanCardDialog() @@ -244,15 +267,9 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( neverToSuggestRateAppUseCase() val userWallet = getSelectedUserWallet() ?: return@launch + val cardInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch - if (userWallet is UserWallet.Hot) { - return@launch // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val scanResponse = userWallet.requireColdWallet().scanResponse - val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch - - sendFeedbackEmailUseCase(type = FeedbackEmailType.RateCanBeBetter(cardInfo = cardInfo)) + sendFeedbackEmailUseCase(type = FeedbackEmailType.RateCanBeBetter(walletMetaInfo = cardInfo)) } } @@ -266,41 +283,55 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( override fun onClosePromoClick(promoId: PromoId) { analyticsEventHandler.send( - if (promoId == PromoId.Referral) { - MainScreen.ReferralPromoButtonDismiss - } else { - TokenSwapPromoAnalyticsEvent.PromotionBannerClicked( + when (promoId) { + PromoId.Referral -> MainScreen.ReferralPromoButtonDismiss + PromoId.Sepa -> TokenSwapPromoAnalyticsEvent.PromotionBannerClicked( source = AnalyticsParam.ScreensSources.Main, - programName = TokenSwapPromoAnalyticsEvent.ProgramName.Empty, // Use it on new promo action + program = TokenSwapPromoAnalyticsEvent.Program.Sepa, action = TokenSwapPromoAnalyticsEvent.PromotionBannerClicked.BannerAction.Closed, ) }, + ) modelScope.launch(dispatchers.main) { shouldShowPromoWalletUseCase.neverToShow(promoId) } } - override fun onPromoClick(promoId: PromoId) { - if (promoId == PromoId.Referral) { - val userWallet = getSelectedUserWallet() ?: return - analyticsEventHandler.send(MainScreen.ReferralPromoButtonParticipate) - appRouter.push(AppRoute.ReferralProgram(userWalletId = userWallet.walletId)) + override fun onPromoClick(promoId: PromoId, cryptoCurrency: CryptoCurrency?) { + val userWallet = getSelectedUserWallet() ?: return + when (promoId) { + PromoId.Referral -> { + analyticsEventHandler.send(MainScreen.ReferralPromoButtonParticipate) + appRouter.push(AppRoute.ReferralProgram(userWalletId = userWallet.walletId)) + } + PromoId.Sepa -> { + analyticsEventHandler.send( + TokenSwapPromoAnalyticsEvent.PromotionBannerClicked( + source = AnalyticsParam.ScreensSources.Main, + program = TokenSwapPromoAnalyticsEvent.Program.Sepa, + action = TokenSwapPromoAnalyticsEvent.PromotionBannerClicked.BannerAction.Clicked, + ), + ) + cryptoCurrency ?: return + appRouter.push( + AppRoute.Onramp( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + source = OnrampSource.SEPA_BANNER, + launchSepa = true, + ), + ) + } } } override fun onSupportClick() { val userWallet = getSelectedUserWallet() ?: return - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val scanResponse = userWallet.requireColdWallet().scanResponse - val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return - modelScope.launch { - sendFeedbackEmailUseCase(type = FeedbackEmailType.DirectUserRequest(cardInfo = cardInfo)) + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch + sendFeedbackEmailUseCase(type = FeedbackEmailType.DirectUserRequest(walletMetaInfo = metaInfo)) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt index 74a02c34b7..1a925f467e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt @@ -4,8 +4,10 @@ import com.tangem.core.decompose.model.Model import com.tangem.feature.wallet.DefaultWalletEntryComponent import com.tangem.feature.wallet.child.organizetokens.model.OrganizeTokensModel import com.tangem.feature.wallet.child.wallet.model.WalletModel +import com.tangem.feature.wallet.utils.DefaultUserWalletImageFetcher import com.tangem.feature.wallet.utils.DefaultUserWalletsFetcher import com.tangem.features.wallet.WalletEntryComponent +import com.tangem.features.wallet.utils.UserWalletImageFetcher import com.tangem.features.wallet.utils.UserWalletsFetcher import dagger.Binds import dagger.Module @@ -13,6 +15,7 @@ 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) @@ -24,6 +27,10 @@ internal interface WalletFeatureModule { @Binds fun bindUserWalletsFetcher(impl: DefaultUserWalletsFetcher.Factory): UserWalletsFetcher.Factory + @Binds + @Singleton + fun bindUserWalletImageFetcher(impl: DefaultUserWalletImageFetcher): UserWalletImageFetcher + @Binds @IntoMap @ClassKey(WalletModel::class) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt index d57ed3079f..8dee141518 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt @@ -3,13 +3,17 @@ package com.tangem.feature.wallet.presentation.common.preview import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState +import com.tangem.core.ui.components.token.AccountItemPreviewData import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.event.consumedEvent 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.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.common.WalletPreviewData.topBarConfig import com.tangem.feature.wallet.presentation.wallet.state.model.* @@ -79,6 +83,26 @@ internal object WalletScreenPreviewData { ), ) + private val portfolioContentState = WalletTokensListState.ContentState.PortfolioContent( + items = persistentListOf( + TokensListItemUM.Portfolio( + tokens = textContentTokensState.items.filterIsInstance(), + isExpanded = false, + state = AccountItemPreviewData.accountItem + .copy(iconState = AccountItemPreviewData.accountLetterIcon), + ), + TokensListItemUM.Portfolio( + tokens = textContentTokensState.items.filterIsInstance(), + isExpanded = true, + state = AccountItemPreviewData.accountItem, + ), + ), + organizeTokensButtonConfig = WalletTokensListState.OrganizeTokensButtonConfig( + isEnabled = true, + onClick = {}, + ), + ) + private val noteLockedCard by lazy { WalletCardState.LockedContent( id = UserWalletId(stringValue = "1"), @@ -117,7 +141,13 @@ internal object WalletScreenPreviewData { buttons = persistentListOf(buyButton), warnings = persistentListOf( WalletNotification.Warning.SomeNetworksUnreachable, - WalletNotification.FinishWalletActivation { }, + WalletNotification.FinishWalletActivation( + iconTint = NotificationConfig.IconTint.Attention, + buttonsState = ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.hw_activation_need_finish), + onClick = { }, + ), + ), ), bottomSheetConfig = null, tokensListState = textContentTokensState, @@ -166,4 +196,12 @@ internal object WalletScreenPreviewData { showMarketsOnboarding = false, onDismissMarketsOnboarding = {}, ) + + internal val accountScreenState = + walletScreenState.copy( + wallets = persistentListOf( + singleWalletLockedState, + multiWalletState.copy(tokensListState = portfolioContentState), + ), + ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt index d75237b768..bd539e7397 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt @@ -5,7 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent -import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent.ProgramName +import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent.Program import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState @@ -51,7 +51,11 @@ internal class WalletWarningsAnalyticsSender @Inject constructor( is WalletNotification.NoteMigration -> MainScreen.NotePromo is WalletNotification.SwapPromo -> TokenSwapPromoAnalyticsEvent.NoticePromotionBanner( source = AnalyticsParam.ScreensSources.Main, - programName = ProgramName.Empty, // Use it on new promo action + program = Program.Empty, // Use it on new promo action + ) + is WalletNotification.Sepa -> TokenSwapPromoAnalyticsEvent.NoticePromotionBanner( + source = AnalyticsParam.ScreensSources.Main, + program = Program.Sepa, ) is WalletNotification.ReferralPromo -> MainScreen.ReferralPromo is WalletNotification.UnlockWallets -> null // See [SelectedWalletAnalyticsSender] diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index 248f54a3e6..14395672c1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -1,6 +1,13 @@ +@file:Suppress("MaximumLineLength") + package com.tangem.feature.wallet.presentation.wallet.domain +import arrow.core.getOrElse +import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState +import com.tangem.core.ui.components.notifications.NotificationConfig.IconTint +import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.core.lce.Lce @@ -11,19 +18,26 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.onramp.GetOnrampCountryUseCase +import com.tangem.domain.onramp.OnrampSepaAvailableUseCase import com.tangem.domain.promo.ShouldShowPromoWalletUseCase import com.tangem.domain.promo.models.PromoId import com.tangem.domain.settings.IsReadyToShowRateAppUseCase +import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase import com.tangem.domain.wallets.usecase.SeedPhraseNotificationUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents +import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification +import com.tangem.lib.crypto.BlockchainUtils.isBitcoin +import com.tangem.utils.coroutines.combine6 +import com.tangem.utils.extensions.isPositive import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine +import timber.log.Timber import javax.inject.Inject @Suppress("LongParameterList") @@ -36,29 +50,34 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( private val backupValidator: BackupValidator, private val seedPhraseNotificationUseCase: SeedPhraseNotificationUseCase, private val shouldShowPromoWalletUseCase: ShouldShowPromoWalletUseCase, + private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase, + private val onrampSepaAvailableUseCase: OnrampSepaAvailableUseCase, + private val getOnrampCountryUseCase: GetOnrampCountryUseCase, ) { - @Suppress("MagicNumber", "MaximumLineLength") fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow> { val cardTypesResolver = (userWallet as? UserWallet.Cold)?.scanResponse?.cardTypesResolver - return combine( - flow = tokenListStore.getOrThrow(userWallet.walletId), + return combine6( + flow1 = tokenListStore.getOrThrow(userWallet.walletId), flow2 = isReadyToShowRateAppUseCase(), flow3 = isNeedToBackupUseCase(userWallet.walletId), flow4 = seedPhraseNotificationUseCase(userWalletId = userWallet.walletId), flow5 = shouldShowPromoWalletUseCase(userWalletId = userWallet.walletId, promoId = PromoId.Referral), - ) { maybeTokenList, isReadyToShowRating, isNeedToBackup, seedPhraseIssueStatus, shouldShowReferralPromo -> + flow6 = shouldShowPromoWalletUseCase(userWalletId = userWallet.walletId, promoId = PromoId.Sepa), + ) { maybeTokenList, isReadyToShowRating, isNeedToBackup, seedPhraseIssueStatus, shouldShowReferralPromo, shouldShowSepaBanner -> buildList { addUsedOutdatedDataNotification(maybeTokenList) addCriticalNotifications(userWallet, seedPhraseIssueStatus, clickIntents) - addFinishWalletActivationNotification(userWallet, clickIntents) + addFinishWalletActivationNotification(userWallet, maybeTokenList, clickIntents) addReferralPromoNotification(cardTypesResolver, clickIntents, shouldShowReferralPromo) - addInformationalNotifications(cardTypesResolver, maybeTokenList, clickIntents) + addSepaPromoNotification(userWallet, clickIntents, shouldShowSepaBanner) + + addInformationalNotifications(userWallet, cardTypesResolver, maybeTokenList, clickIntents) addWarningNotifications(cardTypesResolver, maybeTokenList, isNeedToBackup, clickIntents) @@ -159,6 +178,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( } private fun MutableList.addInformationalNotifications( + userWallet: UserWallet, cardTypesResolver: CardTypesResolver?, maybeTokenList: Lce, clickIntents: WalletClickIntents, @@ -168,10 +188,11 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( condition = cardTypesResolver != null && isDemoCardUseCase(cardId = cardTypesResolver.getCardId()), ) - addMissingAddressesNotification(maybeTokenList, clickIntents) + addMissingAddressesNotification(userWallet, maybeTokenList, clickIntents) } private fun MutableList.addMissingAddressesNotification( + userWallet: UserWallet, maybeTokenList: Lce, clickIntents: WalletClickIntents, ) { @@ -180,6 +201,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( addIf( element = WalletNotification.Informational.MissingAddresses( + tangemIcon = walletInterationIcon(userWallet), missingAddressesCount = currencies.count(), onGenerateClick = { clickIntents.onGenerateMissedAddressesClick(missedAddressCurrencies = currencies) @@ -212,6 +234,39 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( ) } + private suspend fun MutableList.addSepaPromoNotification( + userWallet: UserWallet, + clickIntents: WalletClickIntents, + shouldShowSepaPromo: Boolean, + ) { + val currencies = getCryptoCurrenciesUseCase(userWalletId = userWallet.walletId).getOrElse { + Timber.e("Error on getting crypto currency list") + return + } + + val bitcoinCurrency = currencies.find { isBitcoin(it.network.rawId) } ?: return + + val country = getOnrampCountryUseCase.invokeSync(userWallet).getOrElse { + Timber.e("Error on getting onramp country") + return + } + + val isSepaAvailable = onrampSepaAvailableUseCase( + userWallet = userWallet, + country = country, + currency = country.defaultCurrency, + cryptoCurrency = bitcoinCurrency, + ) + + addIf( + element = WalletNotification.Sepa( + onCloseClick = { clickIntents.onClosePromoClick(promoId = PromoId.Sepa) }, + onClick = { clickIntents.onPromoClick(promoId = PromoId.Sepa, bitcoinCurrency) }, + ), + condition = shouldShowSepaPromo && isSepaAvailable, + ) + } + private fun MutableList.addWarningNotifications( cardTypesResolver: CardTypesResolver?, tokenList: Lce, @@ -256,26 +311,55 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( ) } - private fun MutableList.addIf(element: WalletNotification, condition: Boolean) { - if (condition) add(element = element) - } - private fun MutableList.addFinishWalletActivationNotification( userWallet: UserWallet, + maybeTokenList: Lce, clickIntents: WalletClickIntents, ) { if (userWallet !is UserWallet.Hot) return val shouldShowFinishActivation = !userWallet.backedUp + val iconTint = maybeTokenList.fold( + ifLoading = { + if ((it?.totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isPositive() == true) { + IconTint.Warning + } else { + IconTint.Attention + } + }, + ifContent = { + if ((it.totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isPositive() == true) { + IconTint.Warning + } else { + IconTint.Attention + } + }, + ifError = { IconTint.Attention }, + ) + addIf( element = WalletNotification.FinishWalletActivation( - onFinishClick = clickIntents::onFinishWalletActivationClick, + iconTint = iconTint, + buttonsState = when (iconTint) { + IconTint.Warning -> ButtonsState.PrimaryButtonConfig( + text = resourceReference(R.string.hw_activation_need_finish), + onClick = clickIntents::onFinishWalletActivationClick, + ) + else -> ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.hw_activation_need_finish), + onClick = clickIntents::onFinishWalletActivationClick, + ) + }, ), condition = shouldShowFinishActivation, ) } + private fun MutableList.addIf(element: WalletNotification, condition: Boolean) { + if (condition) add(element = element) + } + private companion object { const val MAX_REMAINING_SIGNATURES_COUNT = 10 } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt index 965b55df30..c7bfe00b92 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt @@ -1,7 +1,11 @@ package com.tangem.feature.wallet.presentation.wallet.state.model +import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState +import com.tangem.core.ui.components.notifications.NotificationConfig.IconTint import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.resourceReference @@ -149,7 +153,11 @@ sealed class WalletNotification(val config: NotificationConfig) { ), ) { - data class MissingAddresses(val missingAddressesCount: Int, val onGenerateClick: () -> Unit) : Informational( + data class MissingAddresses( + @DrawableRes val tangemIcon: Int?, + val missingAddressesCount: Int, + val onGenerateClick: () -> Unit, + ) : Informational( title = resourceReference(id = R.string.warning_missing_derivation_title), subtitle = pluralReference( id = R.plurals.warning_missing_derivation_message, @@ -158,7 +166,7 @@ sealed class WalletNotification(val config: NotificationConfig) { ), buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig( text = resourceReference(id = R.string.common_generate_addresses), - iconResId = R.drawable.ic_tangem_24, + iconResId = tangemIcon, onClick = onGenerateClick, ), ) @@ -256,16 +264,15 @@ sealed class WalletNotification(val config: NotificationConfig) { ) data class FinishWalletActivation( - val onFinishClick: () -> Unit, + val iconTint: IconTint, + val buttonsState: ButtonsState, ) : WalletNotification( config = NotificationConfig( title = resourceReference(R.string.hw_activation_need_title), subtitle = resourceReference(R.string.hw_activation_need_description), iconResId = R.drawable.img_knight_shield_32, - buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( - text = resourceReference(R.string.hw_activation_need_finish), - onClick = onFinishClick, - ), + iconTint = iconTint, + buttonsState = buttonsState, ), ) @@ -282,6 +289,24 @@ sealed class WalletNotification(val config: NotificationConfig) { text = resourceReference(R.string.notification_referral_promo_button), onClick = onClick, ), + iconSize = 54.dp, + ), + ) + + data class Sepa( + val onCloseClick: () -> Unit, + val onClick: () -> Unit, + ) : WalletNotification( + config = NotificationConfig( + title = resourceReference(R.string.notification_sepa_title), + subtitle = resourceReference(R.string.notification_sepa_text), + iconResId = R.drawable.img_notification_sepa, + onCloseClick = onCloseClick, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.notification_sepa_button), + onClick = onClick, + ), + iconSize = 54.dp, ), ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt index 196ec4d3ba..82050bd5fb 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt @@ -29,8 +29,13 @@ internal sealed class WalletTokensListState { override val organizeTokensButtonConfig: OrganizeTokensButtonConfig?, ) : ContentState() + data class PortfolioContent( + override val items: ImmutableList, + override val organizeTokensButtonConfig: OrganizeTokensButtonConfig?, + ) : ContentState() + data object Locked : ContentState() { - override val items = persistentListOf( + override val items: ImmutableList = persistentListOf( TokensListItemUM.GroupTitle( id = 42, text = resourceReference( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt index 2accc6f5b7..751185ccf4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt @@ -135,6 +135,7 @@ internal class SetVisaInfoTransformer( fiatRate = visaCurrency.fiatRate, priceChange = visaCurrency.priceChange, yieldBalance = null, + yieldSupplyStatus = null, hasCurrentNetworkTransactions = false, pendingTransactions = emptySet(), networkAddress = visaCurrency.paymentAccountAddress, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt index 1725952db7..a8c3a91d43 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach internal class MultiWalletWarningsSubscriber( @@ -31,6 +32,11 @@ internal class MultiWalletWarningsSubscriber( .onEach { warnings -> val displayedState = stateHolder.getWalletState(userWallet.walletId) + // Wait until the wallet appears in the list + stateHolder.uiState.first { + it.wallets.any { walletState -> walletState.walletCardState.id == userWallet.walletId } + } + stateHolder.update(SetWarningsTransformer(userWallet.walletId, warnings)) walletWarningsAnalyticsSender.send(displayedState, warnings) walletWarningsSingleEventSender.send( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 9f053d4423..133702c815 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -40,16 +40,16 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.paging.compose.collectAsLazyPagingItems +import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheet +import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheet import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.common.ui.expressStatus.expressTransactionsItems import com.tangem.core.ui.components.atoms.Hand import com.tangem.core.ui.components.atoms.handComposableComponentHeight import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheet -import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig -import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet -import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer import com.tangem.core.ui.components.rememberIsKeyboardVisible import com.tangem.core.ui.components.sheetscaffold.* @@ -67,18 +67,14 @@ import com.tangem.core.ui.test.MarketTooltipTestTags import com.tangem.core.ui.utils.lineTo import com.tangem.core.ui.utils.moveTo import com.tangem.core.ui.utils.toPx -import com.tangem.feature.wallet.presentation.wallet.state.model.ActionsBottomSheetConfig -import com.tangem.feature.wallet.presentation.wallet.state.model.BalancesAndLimitsBottomSheetConfig -import com.tangem.feature.wallet.presentation.wallet.state.model.VisaTxDetailsBottomSheetConfig -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAlertState import com.tangem.feature.wallet.impl.R +import com.tangem.feature.wallet.presentation.common.preview.WalletScreenPreviewData.accountScreenState import com.tangem.feature.wallet.presentation.common.preview.WalletScreenPreviewData.walletScreenState import com.tangem.feature.wallet.presentation.wallet.state.model.* import com.tangem.feature.wallet.presentation.wallet.state.model.holder.TxHistoryStateHolder import com.tangem.feature.wallet.presentation.wallet.ui.components.TokenActionsBottomSheet import com.tangem.feature.wallet.presentation.wallet.ui.components.WalletsList import com.tangem.feature.wallet.presentation.wallet.ui.components.common.* -import com.tangem.feature.wallet.presentation.wallet.ui.components.common.actions import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.nftCollections import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.organizeTokensButton import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.marketPriceBlock @@ -740,6 +736,7 @@ private class WalletScreenPreviewProvider : PreviewParameterProvider Unit) { DropdownMenuItem( text = { Text(text = text.resolveReference(), style = TangemTheme.typography.subtitle2) }, - modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + modifier = Modifier + .background(color = TangemTheme.colors.background.secondary) + .testTag(MainScreenTestTags.TOTAL_BALANCE_MENU_ITEM), trailingIcon = { Icon(imageVector = imageVector, contentDescription = null) }, onClick = onClick, colors = MenuDefaults.itemColors( @@ -258,7 +261,7 @@ private fun MenuItem(text: TextReference, imageVector: ImageVector, onClick: () private fun TitleText(text: String, modifier: Modifier = Modifier) { Text( text = text, - modifier = modifier, + modifier = modifier.testTag(MainScreenTestTags.CARD_TITLE), color = TangemTheme.colors.text.tertiary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -271,7 +274,7 @@ private fun Balance(state: WalletCardState, isBalanceHidden: Boolean, modifier: AnimatedContent( targetState = (state as? WalletCardState.Content)?.balance?.orMaskWithStars(isBalanceHidden).orEmpty(), label = "Update the balance", - modifier = modifier, + modifier = modifier.testTag(MainScreenTestTags.WALLET_BALANCE), transitionSpec = { fadeIn(animationSpec = tween(durationMillis = 220, delayMillis = 90)) togetherWith fadeOut(animationSpec = tween(durationMillis = 90)) @@ -280,9 +283,7 @@ private fun Balance(state: WalletCardState, isBalanceHidden: Boolean, modifier: when (state) { is WalletCardState.Content -> { ResizableText( - modifier = Modifier - .defaultMinSize(minHeight = TangemTheme.dimens.size32) - .testTag(MainScreenTestTags.WALLET_BALANCE), + modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32), text = balance, fontSizeRange = FontSizeRange(min = 16.sp, max = TangemTheme.typography.h2.fontSize), overflow = TextOverflow.Ellipsis, @@ -374,7 +375,9 @@ private fun Image(@DrawableRes id: Int?, modifier: Modifier = Modifier) { Image( painter = painterResource(id = imageRes), contentDescription = null, - modifier = Modifier.width(width = TangemTheme.dimens.size120), + modifier = Modifier + .width(width = TangemTheme.dimens.size120) + .testTag(MainScreenTestTags.CARD_IMAGE), contentScale = ContentScale.FillWidth, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt index 0227a5339a..172ab84fbe 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt @@ -3,7 +3,6 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.common import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.notifications.NoteMigrationNotification import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.res.TangemTheme @@ -35,15 +34,16 @@ internal fun LazyListScope.notifications(configs: ImmutableList { + Notification( + config = it.config, + modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), + ) + } else -> { Notification( config = it.config, modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), - iconSize = if (it is WalletNotification.ReferralPromo) { - 54.dp - } else { - 20.dp - }, iconTint = when (it) { is WalletNotification.Critical -> TangemTheme.colors.icon.warning is WalletNotification.Informational -> TangemTheme.colors.icon.accent diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt new file mode 100644 index 0000000000..140a458bfc --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt @@ -0,0 +1,160 @@ +package com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency + +import androidx.compose.animation.* +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import com.tangem.core.ui.components.tokenlist.PortfolioListItem +import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.MainScreenTestTags +import com.tangem.core.ui.utils.lazyListItemPosition +import kotlinx.collections.immutable.ImmutableList + +internal fun LazyListScope.portfolioContentItems( + items: ImmutableList, + modifier: Modifier = Modifier, + isBalanceHidden: Boolean, +) { + items.forEachIndexed { index, item -> + portfolioTokensList( + portfolio = item, + modifier = modifier, + portfolioIndex = index, + isBalanceHidden = isBalanceHidden, + ) + } +} + +internal fun LazyListScope.portfolioTokensList( + portfolio: TokensListItemUM.Portfolio, + modifier: Modifier, + portfolioIndex: Int, + isBalanceHidden: Boolean, +) { + val tokens = portfolio.tokens + val isExpanded = portfolio.isExpanded + + portfolioItem( + portfolio = portfolio, + modifier = modifier, + portfolioIndex = portfolioIndex, + isBalanceHidden = isBalanceHidden, + ) + if (!isExpanded) return + itemsIndexed( + items = tokens, + key = { _, item -> item.id }, + contentType = { _, item -> item::class.java }, + itemContent = { tokenIndex, token -> + val indexWithHeader = tokenIndex.inc() + val isPreview = LocalInspectionMode.current + val appear = remember { + MutableTransitionState(isPreview).apply { targetState = true } + } + SlideInItemVisibility( + modifier = modifier + .testModifier(indexWithHeader) + .animateItem() + .roundedShapeItemDecoration( + currentIndex = indexWithHeader, + lastIndex = tokens.lastIndex.inc(), + backgroundColor = TangemTheme.colors.background.primary, + ), + visibleState = appear, + ) { + PortfolioTokensListItem( + state = token, + isBalanceHidden = isBalanceHidden, + modifier = modifier, + ) + } + }, + ) +} + +private fun LazyListScope.portfolioItem( + portfolio: TokensListItemUM.Portfolio, + modifier: Modifier, + portfolioIndex: Int, + isBalanceHidden: Boolean, +) { + val tokens = portfolio.tokens + val isExpanded = portfolio.isExpanded + + item( + key = "account-${portfolio.id}-isExpanded$isExpanded", + contentType = "account-isExpanded$isExpanded", + ) { + val anchorModifier = modifier + .testModifier(portfolioIndex) + .animateItem() + .roundedShapeItemDecoration( + currentIndex = 0, + lastIndex = if (isExpanded) tokens.lastIndex.inc() else 0, + backgroundColor = TangemTheme.colors.background.primary, + ) + val isPreview = LocalInspectionMode.current + val appear = remember { + MutableTransitionState(isPreview).apply { targetState = true } + } + if (isExpanded) { + SlideInItemVisibility( + modifier = anchorModifier, + visibleState = appear, + ) { + PortfolioListItem( + state = portfolio, + isBalanceHidden = isBalanceHidden, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + ) + } + } else { + AnimatedVisibility( + modifier = anchorModifier, + visibleState = appear, + enter = fadeIn(), + exit = ExitTransition.None, + ) { + PortfolioListItem( + state = portfolio, + isBalanceHidden = isBalanceHidden, + ) + } + } + } +} + +@Composable +private fun SlideInItemVisibility( + visibleState: MutableTransitionState, + modifier: Modifier = Modifier, + content: @Composable() AnimatedVisibilityScope.() -> Unit, +) { + AnimatedVisibility( + modifier = modifier, + visibleState = visibleState, + enter = slideInVertically( + animationSpec = tween(easing = LinearOutSlowInEasing), + initialOffsetY = { it }, + ) + fadeIn(), + exit = ExitTransition.None, + ) { + content() + } +} + +private fun Modifier.testModifier(index: Int): Modifier = this + .testTag(MainScreenTestTags.TOKEN_LIST_ITEM) + .semantics { lazyListItemPosition = index } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt index d10cd0f107..e8362d171b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt @@ -41,13 +41,19 @@ internal fun LazyListScope.tokensListItems( isBalanceHidden: Boolean, ) { when (state) { - is WalletTokensListState.ContentState -> { - contentItems( - items = state.items, - isBalanceHidden = isBalanceHidden, - modifier = modifier, - ) - } + is WalletTokensListState.ContentState.PortfolioContent -> portfolioContentItems( + items = state.items, + isBalanceHidden = isBalanceHidden, + modifier = modifier, + ) + is WalletTokensListState.ContentState.Content, + is WalletTokensListState.ContentState.Loading, + is WalletTokensListState.ContentState.Locked, + -> contentItems( + items = state.items, + isBalanceHidden = isBalanceHidden, + modifier = modifier, + ) WalletTokensListState.Empty -> nonContentItem(modifier = modifier) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyNFTCollections.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyNFTCollections.kt index ba720b16af..e6257855e9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyNFTCollections.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyNFTCollections.kt @@ -10,7 +10,7 @@ private const val NFT_COLLECTIONS_CONTENT_TYPE = "NFTCollections" internal fun LazyListScope.nftCollections(state: WalletNFTItemUM, modifier: Modifier = Modifier) { item(key = NFT_COLLECTIONS_CONTENT_TYPE, contentType = NFT_COLLECTIONS_CONTENT_TYPE) { WalletNFTItem( - modifier = modifier, + modifier = modifier.animateItem(), state = state, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt index 8597700ca0..d207cb203c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt @@ -26,7 +26,7 @@ internal fun LazyListScope.organizeTokensButton( ) { item(key = ORGANIZE_BUTTON_CONTENT_TYPE, contentType = ORGANIZE_BUTTON_CONTENT_TYPE) { RoundedActionButton( - modifier = modifier.testTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON), + modifier = modifier.animateItem().testTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON), config = ActionButtonConfig( text = resourceReference(id = R.string.organize_tokens_title), iconResId = R.drawable.ic_filter_24, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt new file mode 100644 index 0000000000..f6f11319dd --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt @@ -0,0 +1,89 @@ +package com.tangem.feature.wallet.utils + +import arrow.core.Either +import com.tangem.common.ui.userwallet.converter.ArtworkUMConverter +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.artwork.ArtworkUM +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.GetCardImageUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.wallet.utils.UserWalletImageFetcher +import com.tangem.operations.attestation.ArtworkSize +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +class DefaultUserWalletImageFetcher @Inject constructor( + private val getCardImageUseCase: GetCardImageUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val artworkUMConverter: ArtworkUMConverter, +) : UserWalletImageFetcher { + + private val smallCache = MutableStateFlow(mapOf()) + private val largeCache = MutableStateFlow(mapOf()) + + override fun walletImage(wallet: UserWallet, size: ArtworkSize): Flow = when (wallet) { + is UserWallet.Cold -> walletImage(wallet.scanResponse.card, size) + is UserWallet.Hot -> flowOf(UserWalletItemUM.ImageState.MobileWallet) + } + + override fun walletsImage( + wallets: Collection, + size: ArtworkSize, + ): Flow> = wallets + .map { userWallet -> walletImage(userWallet, size).map { imageState -> userWallet.walletId to imageState } } + .merge() + .runningFold(mapOf()) { map, newState -> map.plus(newState) } + .filter { it.size >= wallets.size } // prevent spam, waiting full map + .distinctUntilChanged() + + override fun walletImage(walletId: UserWalletId, size: ArtworkSize): Flow = flow { + val imagesFlow = getUserWalletUseCase.invokeFlow(walletId) + // emit Loading and wait wallet + .onEach { if (it.isLeft()) emit(UserWalletItemUM.ImageState.Loading) } + .filterIsInstance>() + .map { it.value } + .distinctUntilChanged() + .flatMapLatest { wallet -> walletImage(wallet, size) } + emitAll(imagesFlow) + }.distinctUntilChanged() + + override fun walletImage(cardDTO: CardDTO, size: ArtworkSize): Flow = + internalGetCardImage( + cardInfo = cardDTO, + size = size, + ).distinctUntilChanged() + + private fun internalGetCardImage(cardInfo: CardDTO, size: ArtworkSize): Flow = flow { + emit(cacheOrLoading(cardInfo.cardId, size)) + + val artwork = getCardImageUseCase.invoke( + cardId = cardInfo.cardId, + cardPublicKey = cardInfo.cardPublicKey, + size = size, + manufacturerName = cardInfo.manufacturer.name, + firmwareVersion = cardInfo.firmwareVersion.toSdkFirmwareVersion(), + ) + .let { artworkUMConverter.convert(it) } + .also { save(cardInfo.cardId, size, it) } + emit(UserWalletItemUM.ImageState.Image(artwork)) + } + + private fun cacheOrLoading(cardId: String, size: ArtworkSize): UserWalletItemUM.ImageState { + val artwork = when (size) { + ArtworkSize.LARGE -> largeCache.value[cardId] + ArtworkSize.SMALL -> smallCache.value[cardId] + } + return artwork + ?.let { UserWalletItemUM.ImageState.Image(artwork) } + ?: UserWalletItemUM.ImageState.Loading + } + + private fun save(cardId: String, size: ArtworkSize, artwork: ArtworkUM) { + when (size) { + ArtworkSize.LARGE -> largeCache.update { it.plus(cardId to artwork) } + ArtworkSize.SMALL -> smallCache.update { it.plus(cardId to artwork) } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt index 7a3b9accba..1b4e9c358f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt @@ -14,16 +14,15 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.lce import com.tangem.domain.core.utils.toLce -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase import com.tangem.domain.tokens.error.TokenListError -import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.wallet.impl.R +import com.tangem.features.wallet.utils.UserWalletImageFetcher import com.tangem.features.wallet.utils.UserWalletsFetcher import com.tangem.operations.attestation.ArtworkSize import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -44,12 +43,11 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( @Assisted private val onWalletClick: (UserWalletId) -> Unit, @Assisted private val messageSender: UiMessageSender, @Assisted("onlyMultiCurrency") private val onlyMultiCurrency: Boolean, - @Assisted("authMode") private val authMode: Boolean, - private val getCardImageUseCase: GetCardImageUseCase, + @Assisted("isAuthMode") private val isAuthMode: Boolean, + private val userWalletImageFetcher: UserWalletImageFetcher, dispatchers: CoroutineDispatcherProvider, ) : UserWalletsFetcher { - private var loadedArtworks: HashMap = hashMapOf() private val walletsFlow = if (onlyMultiCurrency) getWalletsUseCase().map { it.filter { it.isMultiCurrency } } else getWalletsUseCase() @@ -57,7 +55,7 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( override val userWallets: Flow> = walletsFlow.transformLatest { wallets -> val uiModels = UserWalletItemUMConverter( onClick = onWalletClick, - authMode = authMode, + isAuthMode = isAuthMode, ).convertList(wallets) .toImmutableList() @@ -66,8 +64,8 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( combine( flow = getSelectedAppCurrencyUseCase().distinctUntilChanged(), flow2 = getBalanceHidingSettingsUseCase().distinctUntilChanged(), - flow3 = getWalletTotalBalanceUseCase(wallets.map(UserWallet::walletId)).distinctUntilChanged(), - flow4 = loadArtworks(wallets), + flow3 = getTotalBalanceFlow(wallets), + flow4 = userWalletImageFetcher.walletsImage(wallets, ArtworkSize.SMALL), ) { maybeAppCurrency, balanceHidingSettings, maybeBalances, artworks -> createUiModels( wallets = wallets, @@ -90,20 +88,16 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( } .flowOn(dispatchers.default) - private fun loadArtworks(wallets: List): Flow> { - return flow { - emit(hashMapOf()) // emits right away so the transform doesn't wait for the images' loading to finish - wallets.filterIsInstance().forEach { wallet -> - val artwork = getCardImageUseCase( - cardId = wallet.cardId, - manufacturerName = wallet.scanResponse.card.manufacturer.name, - firmwareVersion = wallet.scanResponse.card.firmwareVersion.toSdkFirmwareVersion(), - cardPublicKey = wallet.scanResponse.card.cardPublicKey, - size = ArtworkSize.SMALL, - ) - loadedArtworks[wallet.walletId] = artwork - emit(loadedArtworks) - } + private fun getTotalBalanceFlow( + wallets: List, + ): Flow>> { + val walletIds = wallets.map(UserWallet::walletId) + + return if (isAuthMode) { + // We should not load balances in auth mode + flowOf(Lce.Loading(walletIds.associateWith { TotalFiatBalance.Loading })) + } else { + getWalletTotalBalanceUseCase(walletIds).distinctUntilChanged() } } @@ -112,7 +106,7 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( maybeAppCurrency: Either, maybeBalances: Lce>, balanceHidingSettings: BalanceHidingSettings, - artworks: HashMap, + artworks: Map, ): Lce> = lce { val balances = withError( transform = { Error.UnableToGetBalances }, @@ -136,7 +130,7 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( balance = balance, isBalanceHidden = balanceHidingSettings.isBalanceHidden, artwork = artworks[userWallet.walletId], - authMode = authMode, + isAuthMode = isAuthMode, ) .convert(userWallet) } @@ -155,7 +149,7 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( override fun create( messageSender: UiMessageSender, @Assisted("onlyMultiCurrency") onlyMultiCurrency: Boolean, - @Assisted("authMode") authMode: Boolean, + @Assisted("isAuthMode") isAuthMode: Boolean, onWalletClick: (UserWalletId) -> Unit, ): DefaultUserWalletsFetcher } diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt index 29a42feb84..e4fde45099 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt @@ -795,6 +795,7 @@ class DefaultPromoDeeplinkHandlerTest { amounts = emptyMap(), pendingTransactions = emptyMap(), source = StatusSource.ACTUAL, + yieldSupplyStatuses = emptyMap(), ) return NetworkStatus(network = network, value = value) diff --git a/features/walletconnect/api/src/main/kotlin/com/tangem/features/walletconnect/components/WalletConnectFeatureToggles.kt b/features/walletconnect/api/src/main/kotlin/com/tangem/features/walletconnect/components/WalletConnectFeatureToggles.kt deleted file mode 100644 index 3b9e35b56a..0000000000 --- a/features/walletconnect/api/src/main/kotlin/com/tangem/features/walletconnect/components/WalletConnectFeatureToggles.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.features.walletconnect.components - -interface WalletConnectFeatureToggles { - val isRedesignedWalletConnectEnabled: Boolean -} \ No newline at end of file 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 7449212ae2..bf416fe8f2 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 @@ -23,6 +23,7 @@ import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.walletconnect.WcAnalyticEvents import com.tangem.domain.walletconnect.model.* +import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData import com.tangem.domain.walletconnect.usecase.pair.WcPairState import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase @@ -96,7 +97,8 @@ internal class WcPairModel @Inject constructor( appInfoUiState.transformerUpdate( WcConnectButtonProgressTransformer(showProgress = false), ) - pairState.result + pairState + .result .onLeft(::processError) .onRight(::processSuccessfullyConnected) router.pop() @@ -203,12 +205,12 @@ internal class WcPairModel @Inject constructor( stackNavigation.pushNew(WcAppInfoRoutes.Alert.Verified(appName)) } - private fun processSuccessfullyConnected(session: WcSession) { + private fun processSuccessfullyConnected(session: WcAppMetaData) { messageSender.send( SnackbarMessage( message = resourceReference( id = R.string.wc_connected_to, - formatArgs = wrappedList(session.sdkModel.appMetaData.name), + formatArgs = wrappedList(session.name), ), ), ) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt index 5524f954e3..341ab307f2 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt @@ -10,7 +10,6 @@ import com.tangem.domain.walletconnect.WcRequestService import com.tangem.domain.walletconnect.model.WcEthMethodName import com.tangem.domain.walletconnect.model.WcMethodName import com.tangem.domain.walletconnect.model.WcSolanaMethodName -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import javax.inject.Inject @@ -21,18 +20,15 @@ internal class WcRoutingModel @Inject constructor( private val pairService: WcPairService, private val cardSdkProvider: CardSdkProvider, override val dispatchers: CoroutineDispatcherProvider, - private val featureToggles: WalletConnectFeatureToggles, ) : Model() { - val innerRouter = WcRouter(SlotNavigation()) + val innerRouter = WcRouter(SlotNavigation()) private val isSlotEmpty = MutableStateFlow(true) private val permittedAppRoute = MutableStateFlow(false) init { - if (featureToggles.isRedesignedWalletConnectEnabled) { - setupQueue() - } + setupQueue() } fun onSlotEmpty() { 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 0096e3ef3c..3247db2b9e 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 @@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -17,6 +18,8 @@ import coil.compose.AsyncImage import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.conditional import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags +import com.tangem.core.ui.test.WalletConnectDetailsBottomSheetTestTags import com.tangem.features.walletconnect.connections.entity.VerifiedDAppState import com.tangem.features.walletconnect.impl.R @@ -43,7 +46,8 @@ internal fun WcAppInfoItem( AsyncImage( modifier = Modifier .size(TangemTheme.dimens.size48) - .clip(RoundedCornerShape(TangemTheme.dimens.radius8)), + .clip(RoundedCornerShape(TangemTheme.dimens.radius8)) + .testTag(WalletConnectBottomSheetTestTags.APP_ICON), model = iconUrl, contentDescription = title, error = painterResource(R.drawable.img_wc_dapp_icon_placeholder_48), @@ -58,7 +62,9 @@ internal fun WcAppInfoItem( verticalAlignment = Alignment.CenterVertically, ) { Text( - modifier = Modifier.weight(1f, fill = false), + modifier = Modifier + .weight(1f, fill = false) + .testTag(WalletConnectBottomSheetTestTags.APP_NAME), text = title, color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.h3, @@ -67,7 +73,9 @@ internal fun WcAppInfoItem( ) if (verifiedDAppState is VerifiedDAppState.Verified) { Icon( - modifier = Modifier.size(20.dp), + modifier = Modifier + .size(20.dp) + .testTag(WalletConnectBottomSheetTestTags.APPROVE_ICON), painter = painterResource(R.drawable.img_approvale2_20), contentDescription = null, tint = Color.Unspecified, @@ -78,6 +86,7 @@ internal fun WcAppInfoItem( text = subtitle, color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.body2, + modifier = Modifier.testTag(WalletConnectBottomSheetTestTags.APP_URL), ) } } @@ -86,11 +95,15 @@ internal fun WcAppInfoItem( @Composable internal fun WcNetworkInfoItem(@DrawableRes icon: Int, name: String, symbol: String, modifier: Modifier = Modifier) { Row( - modifier = modifier.padding(vertical = 14.dp, horizontal = 12.dp), + modifier = modifier + .padding(vertical = 14.dp, horizontal = 12.dp) + .testTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_ITEM), verticalAlignment = Alignment.CenterVertically, ) { Icon( - modifier = Modifier.size(24.dp), + modifier = Modifier + .size(24.dp) + .testTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_ICON), painter = painterResource(icon), contentDescription = null, tint = Color.Unspecified, @@ -98,7 +111,8 @@ internal fun WcNetworkInfoItem(@DrawableRes icon: Int, name: String, symbol: Str Text( modifier = Modifier .padding(start = 12.dp) - .weight(1f, fill = false), + .weight(1f, fill = false) + .testTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_NAME), text = name, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, @@ -106,7 +120,9 @@ internal fun WcNetworkInfoItem(@DrawableRes icon: Int, name: String, symbol: Str overflow = TextOverflow.Ellipsis, ) Text( - modifier = Modifier.padding(start = 4.dp), + modifier = Modifier + .padding(start = 4.dp) + .testTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_SYMBOL), text = symbol, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.tertiary, 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 ebd0adbd3a..0f5487ae09 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 @@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -42,6 +43,7 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags import com.tangem.features.walletconnect.connections.entity.* import com.tangem.features.walletconnect.impl.R import kotlinx.collections.immutable.ImmutableList @@ -62,6 +64,7 @@ internal fun WcAppInfoModalBottomSheet(state: WcAppInfoUM, onBack: () -> Unit, o title = resourceReference(R.string.wc_wallet_connect), endIconRes = R.drawable.ic_close_24, onEndClick = onDismiss, + modifier = Modifier.testTag(WalletConnectBottomSheetTestTags.TITLE), ) }, content = { @@ -157,7 +160,9 @@ private fun WcAppInfoFirstBlock(state: WcAppInfoUM.Content, modifier: Modifier = private fun ConnectionRequestBlock(expanded: Boolean, modifier: Modifier = Modifier) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { Icon( - modifier = Modifier.size(24.dp), + modifier = Modifier + .size(24.dp) + .testTag(WalletConnectBottomSheetTestTags.CONNECTION_REQUEST_ICON), painter = painterResource(R.drawable.ic_connect_new_24), contentDescription = null, tint = TangemTheme.colors.icon.accent, @@ -165,7 +170,8 @@ private fun ConnectionRequestBlock(expanded: Boolean, modifier: Modifier = Modif Text( modifier = Modifier .padding(start = TangemTheme.dimens.spacing4) - .weight(1f), + .weight(1f) + .testTag(WalletConnectBottomSheetTestTags.CONNECTION_REQUEST_TEXT), text = stringResourceSafe(R.string.wc_connection_request), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, @@ -173,7 +179,8 @@ private fun ConnectionRequestBlock(expanded: Boolean, modifier: Modifier = Modif Icon( modifier = Modifier .padding(start = TangemTheme.dimens.spacing12) - .size(width = 18.dp, height = 24.dp), + .size(width = 18.dp, height = 24.dp) + .testTag(WalletConnectBottomSheetTestTags.CONNECTION_REQUEST_CHEVRON), painter = painterResource(if (expanded) R.drawable.ic_chevron_up_24 else R.drawable.ic_chevron_24), contentDescription = null, tint = TangemTheme.colors.icon.informative, @@ -319,7 +326,9 @@ private fun WcAppInfoSecondBlock(state: WcAppInfoUM.Content, modifier: Modifier private fun WalletRowItem(walletName: String, showEndIcon: Boolean, modifier: Modifier = Modifier) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { Icon( - modifier = Modifier.size(24.dp), + modifier = Modifier + .size(24.dp) + .testTag(WalletConnectBottomSheetTestTags.WALLET_ICON), painter = painterResource(R.drawable.ic_wallet_new_24), contentDescription = null, tint = TangemTheme.colors.icon.accent, @@ -330,14 +339,18 @@ private fun WalletRowItem(walletName: String, showEndIcon: Boolean, modifier: Mo verticalAlignment = Alignment.CenterVertically, ) { Text( - modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing4) + .testTag(WalletConnectBottomSheetTestTags.WALLET_NAME_TITLE), text = stringResourceSafe(R.string.manage_tokens_network_selector_wallet), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, maxLines = 1, ) Text( - modifier = Modifier.padding(start = TangemTheme.dimens.spacing16), + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing16) + .testTag(WalletConnectBottomSheetTestTags.WALLET_NAME), text = walletName, textAlign = TextAlign.End, style = TangemTheme.typography.body1, @@ -366,7 +379,9 @@ private fun SelectNetworksBlock(networksInfo: WcNetworksInfo, modifier: Modifier verticalAlignment = Alignment.CenterVertically, ) { Icon( - modifier = Modifier.size(24.dp), + modifier = Modifier + .size(24.dp) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_ICON), painter = painterResource(R.drawable.ic_network_new_24), contentDescription = null, tint = TangemTheme.colors.icon.accent, @@ -374,7 +389,8 @@ private fun SelectNetworksBlock(networksInfo: WcNetworksInfo, modifier: Modifier Text( modifier = Modifier .padding(start = TangemTheme.dimens.spacing4) - .weight(1f), + .weight(1f) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_TITLE), text = stringResourceSafe(R.string.wc_common_networks), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, @@ -385,7 +401,9 @@ private fun SelectNetworksBlock(networksInfo: WcNetworksInfo, modifier: Modifier is WcNetworksInfo.NoneNetworksAdded -> Unit } Icon( - modifier = Modifier.size(width = 18.dp, height = 24.dp), + modifier = Modifier + .size(width = 18.dp, height = 24.dp) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_SELECTOR_ICON), painter = painterResource(id = R.drawable.ic_select_18_24), contentDescription = null, tint = TangemTheme.colors.icon.informative, @@ -417,7 +435,8 @@ private fun NetworkIcons(items: ImmutableList, modifier: Modi ) .padding(2.dp) .clip(CircleShape) - .size(20.dp), + .size(20.dp) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_ICONS), ) } if (remainingCount > 0) { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectedAppInfoBS.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectedAppInfoBS.kt index 4a97cf03a1..5fab3966ed 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectedAppInfoBS.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectedAppInfoBS.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -28,6 +29,7 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.WalletConnectDetailsBottomSheetTestTags import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday import com.tangem.core.ui.utils.toTimeFormat @@ -116,7 +118,8 @@ private fun WcConnectedAppInfoBSContent(state: WcConnectedAppInfoUM, modifier: M SecondaryButton( modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp), + .padding(vertical = 16.dp) + .testTag(WalletConnectDetailsBottomSheetTestTags.DISCONNECT_BUTTON), text = stringResourceSafe(R.string.common_disconnect), enabled = state.disconnectButtonConfig.enabled, showProgress = state.disconnectButtonConfig.showProgress, @@ -142,7 +145,9 @@ private fun AppInfoFirstBlock(state: WcConnectedAppInfoUM, modifier: Modifier = verticalAlignment = Alignment.CenterVertically, ) { Icon( - modifier = Modifier.size(24.dp), + modifier = Modifier + .size(24.dp) + .testTag(WalletConnectDetailsBottomSheetTestTags.WALLET_ICON), painter = painterResource(R.drawable.ic_wallet_new_24), contentDescription = null, tint = TangemTheme.colors.icon.accent, @@ -150,7 +155,8 @@ private fun AppInfoFirstBlock(state: WcConnectedAppInfoUM, modifier: Modifier = Text( modifier = Modifier .padding(start = TangemTheme.dimens.spacing4) - .weight(1f), + .weight(1f) + .testTag(WalletConnectDetailsBottomSheetTestTags.WALLET_TITLE), text = stringResourceSafe(R.string.manage_tokens_network_selector_wallet), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, @@ -158,7 +164,8 @@ private fun AppInfoFirstBlock(state: WcConnectedAppInfoUM, modifier: Modifier = Text( modifier = Modifier .padding(start = TangemTheme.dimens.spacing16) - .weight(1f), + .weight(1f) + .testTag(WalletConnectDetailsBottomSheetTestTags.WALLET_NAME), text = state.walletName, textAlign = TextAlign.End, style = TangemTheme.typography.body1, @@ -174,7 +181,8 @@ private fun NetworksBlock(networks: ImmutableList, m Text( modifier = Modifier .padding(top = 12.dp, bottom = 4.dp) - .padding(horizontal = 12.dp), + .padding(horizontal = 12.dp) + .testTag(WalletConnectDetailsBottomSheetTestTags.NETWORKS_TITLE), text = stringResourceSafe(R.string.wc_connected_networks), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.tertiary, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt index 25acf5033f..f079b379c0 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -35,6 +36,7 @@ import com.tangem.core.ui.components.snackbar.TangemSnackbarHost 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.WalletConnectScreenTestTags import com.tangem.features.walletconnect.connections.entity.* import com.tangem.features.walletconnect.connections.ui.preview.WcConnectionsPreviewData import com.tangem.features.walletconnect.impl.R @@ -170,7 +172,9 @@ private fun ConnectionItem(connection: WcConnectionsUM, modifier: Modifier = Mod Column(modifier = modifier) { key("${connection.userWalletId}_${connection.walletName}") { Text( - modifier = Modifier.padding(top = 12.dp, bottom = 4.dp, start = 12.dp, end = 12.dp), + modifier = Modifier + .padding(top = 12.dp, bottom = 4.dp, start = 12.dp, end = 12.dp) + .testTag(WalletConnectScreenTestTags.WALLET_NAME), text = connection.walletName, color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, @@ -199,7 +203,8 @@ private fun AppInfoItem(appInfo: WcConnectedAppInfo, modifier: Modifier = Modifi AsyncImage( modifier = Modifier .size(40.dp) - .clip(RoundedCornerShape(TangemTheme.dimens.radius8)), + .clip(RoundedCornerShape(TangemTheme.dimens.radius8)) + .testTag(WalletConnectScreenTestTags.APP_ICON), model = appInfo.iconUrl, error = painterResource(R.drawable.img_wc_dapp_icon_placeholder_48), fallback = painterResource(R.drawable.img_wc_dapp_icon_placeholder_48), @@ -214,7 +219,9 @@ private fun AppInfoItem(appInfo: WcConnectedAppInfo, modifier: Modifier = Modifi verticalAlignment = Alignment.CenterVertically, ) { Text( - modifier = Modifier.weight(1f, fill = false), + modifier = Modifier + .weight(1f, fill = false) + .testTag(WalletConnectScreenTestTags.APP_NAME), text = appInfo.name, color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.subtitle2, @@ -223,7 +230,9 @@ private fun AppInfoItem(appInfo: WcConnectedAppInfo, modifier: Modifier = Modifi ) if (appInfo.verifiedState is VerifiedDAppState.Verified) { Icon( - modifier = Modifier.size(20.dp), + modifier = Modifier + .size(20.dp) + .testTag(WalletConnectScreenTestTags.APPROVE_ICON), painter = painterResource(R.drawable.img_approvale2_20), contentDescription = null, tint = Color.Unspecified, @@ -234,6 +243,7 @@ private fun AppInfoItem(appInfo: WcConnectedAppInfo, modifier: Modifier = Modifi text = appInfo.subtitle, color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.caption2, + modifier = Modifier.testTag(WalletConnectScreenTestTags.APP_URL), ) } } @@ -259,6 +269,7 @@ private fun ConnectionsTopBar( TopAppBarButton( button = config.startButtonUM, tint = TangemTheme.colors.icon.primary1, + modifier = Modifier.testTag(WalletConnectScreenTestTags.MORE_BUTTON), ) }, title = { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcUserWalletsFetcher.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcUserWalletsFetcher.kt index 727e85c485..dc19a55e4e 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcUserWalletsFetcher.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcUserWalletsFetcher.kt @@ -29,7 +29,7 @@ internal class WcUserWalletsFetcher( private val userWalletsFetcher = userWalletsFetcherFactory.create( messageSender = messageSender, onlyMultiCurrency = true, - authMode = false, + isAuthMode = false, onWalletClick = { onWalletSelected(it) }, ) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/deeplink/DefaultWalletConnectDeepLinkHandler.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/deeplink/DefaultWalletConnectDeepLinkHandler.kt index 53c98210ad..5d82c115da 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/deeplink/DefaultWalletConnectDeepLinkHandler.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/deeplink/DefaultWalletConnectDeepLinkHandler.kt @@ -4,7 +4,6 @@ import android.net.Uri import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.model.WcPairRequest import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles import com.tangem.features.walletconnect.components.deeplink.WalletConnectDeepLinkHandler import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -15,7 +14,6 @@ import java.net.URLDecoder internal class DefaultWalletConnectDeepLinkHandler @AssistedInject constructor( @Assisted uri: Uri, getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, - walletConnectFeatureToggles: WalletConnectFeatureToggles, wcPairService: WcPairService, ) : WalletConnectDeepLinkHandler { @@ -30,15 +28,13 @@ internal class DefaultWalletConnectDeepLinkHandler @AssistedInject constructor( }, ifRight = { wallet -> val decodedWcUri = URLDecoder.decode(wcUri, DEFAULT_CHARSET_NAME) - if (walletConnectFeatureToggles.isRedesignedWalletConnectEnabled) { - wcPairService.pair( - request = WcPairRequest( - uri = decodedWcUri, - source = WcPairRequest.Source.DEEPLINK, - userWalletId = wallet.walletId, - ), - ) - } + wcPairService.pair( + request = WcPairRequest( + uri = decodedWcUri, + source = WcPairRequest.Source.DEEPLINK, + userWalletId = wallet.walletId, + ), + ) }, ) } catch (e: Exception) { 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 1ddad75e91..36c5a2b3c1 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 @@ -62,6 +62,8 @@ internal abstract class WcCommonTransactionComponentDelegate( is WcTransactionRoutes.Alert, is WcTransactionRoutes.CustomAllowance, is WcTransactionRoutes.SelectFee, + is WcTransactionRoutes.MultipleTransactions, + WcTransactionRoutes.TransactionProcess, -> stackNavigation?.pop() null -> 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 292804e584..5be85fef22 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,10 +4,13 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.walletconnect.WcAnalyticEvents import com.tangem.features.send.v2.api.FeeSelectorComponent -import com.tangem.features.send.v2.api.params.FeeSelectorParams +import com.tangem.features.send.v2.api.params.FeeSelectorParams.FeeDisplaySource +import com.tangem.features.send.v2.api.params.FeeSelectorParams.FeeSelectorDetailsParams 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.components.send.WcSendingProcessComponent +import com.tangem.features.walletconnect.transaction.components.send.WcSendMultipleTransactionsComponent 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 @@ -43,17 +46,32 @@ internal fun getWcCommonScreen( feeSelectorComponentFactory.create( context = appComponentContext, onDismiss = model::popBack, - params = FeeSelectorParams.FeeSelectorDetailsParams( + params = FeeSelectorDetailsParams( state = state.feeSelectorUM, onLoadFee = model::loadFee, feeCryptoCurrencyStatus = model.cryptoCurrencyStatus, cryptoCurrencyStatus = model.cryptoCurrencyStatus, callback = model, feeStateConfiguration = model.feeStateConfiguration, - feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet, + feeDisplaySource = FeeDisplaySource.BottomSheet, analyticsCategoryName = WcAnalyticEvents.WC_CATEGORY_NAME, ), ) } + is WcTransactionRoutes.MultipleTransactions -> { + val model = model as? WcSendTransactionModel ?: error("model must be WcSendTransactionModel") + WcSendMultipleTransactionsComponent( + appComponentContext = appComponentContext, + model = model, + onConfirm = config.onConfirm, + ) + } + WcTransactionRoutes.TransactionProcess -> { + val model = model as? WcSendTransactionModel ?: error("model must be WcSendTransactionModel") + WcSendingProcessComponent( + appComponentContext = appComponentContext, + model = model, + ) + } } } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendMultipleTransactionsComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendMultipleTransactionsComponent.kt new file mode 100644 index 0000000000..0146849910 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendMultipleTransactionsComponent.kt @@ -0,0 +1,33 @@ +package com.tangem.features.walletconnect.transaction.components.send + +import androidx.compose.runtime.Composable +import com.tangem.core.decompose.context.AppComponentContext +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.features.walletconnect.transaction.model.WcSendTransactionModel +import com.tangem.features.walletconnect.transaction.ui.send.WcSendMultipleTransactionsModalBottomSheet + +internal class WcSendMultipleTransactionsComponent( + private val appComponentContext: AppComponentContext, + private val model: WcSendTransactionModel, + private val onConfirm: () -> Unit, +) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { + + override fun dismiss() { + model.dismiss() + } + + @Composable + override fun BottomSheet() { + WcSendMultipleTransactionsModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = { model.popBack() }, + content = TangemBottomSheetConfigContent.Empty, + ), + onConfirm = onConfirm, + onBack = { model.popBack() }, + ) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendingProcessComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendingProcessComponent.kt new file mode 100644 index 0000000000..5723380bbb --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendingProcessComponent.kt @@ -0,0 +1,30 @@ +package com.tangem.features.walletconnect.transaction.components.send + +import androidx.compose.runtime.Composable +import com.tangem.core.decompose.context.AppComponentContext +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.features.walletconnect.transaction.model.WcSendTransactionModel +import com.tangem.features.walletconnect.transaction.ui.send.WcSendingProcessModalBottomSheet + +internal class WcSendingProcessComponent( + private val appComponentContext: AppComponentContext, + private val model: WcSendTransactionModel, +) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { + + override fun dismiss() { + model.dismiss() + } + + @Composable + override fun BottomSheet() { + WcSendingProcessModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + ) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTransactionUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTransactionUMConverter.kt index 28368845c9..2aaa77a777 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTransactionUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTransactionUMConverter.kt @@ -1,5 +1,6 @@ package com.tangem.features.walletconnect.transaction.converter +import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.domain.walletconnect.usecase.method.WcMessageSignUseCase import com.tangem.domain.walletconnect.usecase.method.WcMethodContext import com.tangem.domain.walletconnect.usecase.method.WcSignState @@ -32,6 +33,7 @@ internal class WcSignTransactionUMConverter @Inject constructor( networkInfo = networkInfoUMConverter.convert(value.context.network), isLoading = value.signState.domainStep == WcSignStep.Signing, address = WcAddressConverter.convert(value.context.derivationState), + walletInteractionIcon = walletInterationIcon(value.context.session.wallet), ), transactionRequestInfo = WcTransactionRequestInfoUM( requestBlockUMConverter.convert( diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTypedDataUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTypedDataUMConverter.kt index faef1ed164..d16c5250da 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTypedDataUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTypedDataUMConverter.kt @@ -1,5 +1,6 @@ package com.tangem.features.walletconnect.transaction.converter +import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.domain.walletconnect.usecase.method.WcMessageSignUseCase import com.tangem.domain.walletconnect.usecase.method.WcMethodContext import com.tangem.domain.walletconnect.usecase.method.WcSignState @@ -32,6 +33,7 @@ internal class WcSignTypedDataUMConverter @Inject constructor( networkInfo = networkInfoUMConverter.convert(value.context.network), address = WcAddressConverter.convert(value.context.derivationState), isLoading = value.signState.domainStep == WcSignStep.Signing, + walletInteractionIcon = walletInterationIcon(value.context.session.wallet), ), transactionRequestInfo = WcTransactionRequestInfoUM( blocks = buildList { 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 e97fc79509..2eb5493da3 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 @@ -32,4 +32,5 @@ internal data class WcSendTransactionItemUM( val sendEnabled: Boolean, val feeErrorNotification: NotificationUM.Info?, val isLoading: Boolean = false, + val walletInteractionIcon: Int? = null, ) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/sign/WcSignTransactionUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/sign/WcSignTransactionUM.kt index 54c08cf874..bdc508086b 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/sign/WcSignTransactionUM.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/sign/WcSignTransactionUM.kt @@ -18,5 +18,6 @@ internal data class WcSignTransactionItemUM( val walletName: String?, val networkInfo: WcNetworkInfoUM, val address: String?, + val walletInteractionIcon: Int?, 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 8b5afff900..99b80940ee 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 @@ -141,7 +141,13 @@ internal class WcSendTransactionModel @Inject constructor( val isApprovalMethod = isSecurityCheckContent && securityCheck.content is BlockAidTransactionCheck.Result.Approval wcApproval = useCase as? WcApproval - sign = { useCase.sign() } + sign = { + if (isMultipleSignRequired(useCase)) { + openMultipleTransaction(useCase) + } else { + useCase.sign() + } + } buildUiState(securityCheck, useCase, signState, isApprovalMethod) if (feeReloadState.value) { triggerFeeReload() @@ -162,6 +168,17 @@ internal class WcSendTransactionModel @Inject constructor( } } + private fun openMultipleTransaction(useCase: WcSignUseCase<*>) { + stackNavigation.pushNew( + WcTransactionRoutes.MultipleTransactions( + onConfirm = { + useCase.sign() + stackNavigation.pushNew(WcTransactionRoutes.TransactionProcess) + }, + ), + ) + } + /** * Handles callback with updated [feeSelectorUM] from click FeeSelectorComponent. * We need to trigger navigation to dismiss fee selector component @@ -272,6 +289,14 @@ internal class WcSendTransactionModel @Inject constructor( stackNavigation.pop() } + private fun isMultipleSignRequired(useCase: WcSignUseCase<*>): Boolean { + return if (useCase is SignRequirements) { + useCase.isMultipleSignRequired() + } else { + false + } + } + fun showTransactionRequest() { analytics.send( WcAnalyticEvents.TransactionDetailsOpened( 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 843e0e291d..cc1d9513d7 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 @@ -34,6 +34,7 @@ internal sealed class WcTransactionRoutes : TangemBottomSheetConfigContent, Rout val iconType: MessageBottomSheetUMV2.Icon.Type, val iconBgType: MessageBottomSheetUMV2.Icon.BackgroundType, ) : Type() + data class UnknownError( val errorMessage: String?, val onDismiss: () -> Unit, @@ -41,4 +42,12 @@ internal sealed class WcTransactionRoutes : TangemBottomSheetConfigContent, Rout ) : Type() } } + + @Serializable + data class MultipleTransactions( + val onConfirm: () -> Unit, + ) : WcTransactionRoutes() + + @Serializable + data object TransactionProcess : WcTransactionRoutes() } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcTransactionRequestButtons.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcTransactionRequestButtons.kt index 61f1cf95e7..f60920591d 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcTransactionRequestButtons.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcTransactionRequestButtons.kt @@ -1,5 +1,6 @@ package com.tangem.features.walletconnect.transaction.ui.common +import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -16,11 +17,13 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.features.walletconnect.impl.R +@Suppress("LongParameterList") @Composable internal fun WcTransactionRequestButtons( activeButtonText: TextReference, isLoading: Boolean, validationResult: ValidationResult?, + @DrawableRes walletInteractionIcon: Int?, onDismiss: () -> Unit, onClickActiveButton: () -> Unit, modifier: Modifier = Modifier, @@ -52,7 +55,7 @@ internal fun WcTransactionRequestButtons( .weight(1f), text = activeButtonText.resolveReference(), onClick = onClickActiveButton, - iconResId = R.drawable.ic_tangem_24, + iconResId = walletInteractionIcon, showProgress = isLoading, enabled = enabled, ) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendMultipleTransactionsModalBottomSheet.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendMultipleTransactionsModalBottomSheet.kt new file mode 100644 index 0000000000..85a56ac9fb --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendMultipleTransactionsModalBottomSheet.kt @@ -0,0 +1,105 @@ +package com.tangem.features.walletconnect.transaction.ui.send + +import android.content.res.Configuration +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.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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +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.* +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.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +internal fun WcSendMultipleTransactionsModalBottomSheet( + config: TangemBottomSheetConfig, + onConfirm: () -> Unit, + onBack: () -> Unit, +) { + TangemModalBottomSheet( + config = config, + content = { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SpacerH24() + Icon( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(percent = 100)) + .background(TangemTheme.colors.icon.informative.copy(alpha = 0.1f)) + .padding(12.dp), + painter = rememberVectorPainter( + ImageVector.vectorResource(com.tangem.core.ui.R.drawable.ic_alert_24), + ), + tint = TangemTheme.colors.icon.attention, + contentDescription = null, + ) + SpacerH24() + Text( + text = stringResourceSafe(R.string.wallet_connect_multiple_transactions), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + SpacerH8() + Text( + text = stringResourceSafe(R.string.wallet_connect_multiple_transactions_description), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + SpacerH(48.dp) + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_cancel), + onClick = onBack, + ) + SpacerH8() + PrimaryButtonIconEnd( + modifier = Modifier.fillMaxWidth(), + iconResId = R.drawable.ic_tangem_24, + text = stringResourceSafe(R.string.common_send), + onClick = onConfirm, + ) + } + }, + ) +} + +@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 WcSendTransactionBottomSheetPreview() { + TangemThemePreview { + WcSendMultipleTransactionsModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + onConfirm = {}, + onBack = {}, + ) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt index b647ef5abd..a1d574e005 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 @@ -138,6 +138,7 @@ internal fun WcSendTransactionModalBottomSheet( activeButtonText = resourceReference(R.string.common_send), isLoading = state.isLoading, enabled = state.sendEnabled, + walletInteractionIcon = state.walletInteractionIcon, validationResult = state.transactionValidationResult, ) }, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendingProcessModalBottomSheet.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendingProcessModalBottomSheet.kt new file mode 100644 index 0000000000..b3be4ca60d --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendingProcessModalBottomSheet.kt @@ -0,0 +1,79 @@ +package com.tangem.features.walletconnect.transaction.ui.send + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH8 +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.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +internal fun WcSendingProcessModalBottomSheet(config: TangemBottomSheetConfig) { + TangemModalBottomSheet( + config = config, + onBack = { + // empty to disable back handling + }, + dismissOnClickOutside = false, + content = { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SpacerH(64.dp) + CircularProgressIndicator( + modifier = Modifier + .size(TangemTheme.dimens.size32), + color = TangemTheme.colors.text.accent, + strokeWidth = TangemTheme.dimens.size2, + ) + SpacerH(32.dp) + Text( + text = stringResourceSafe(R.string.wallet_connect_sending_multiple_tx), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + SpacerH8() + Text( + text = stringResourceSafe(R.string.wallet_connect_sending_multiple_explanation), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + SpacerH(56.dp) + } + }, + ) +} + +@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 WcSendTransactionBottomSheetPreview() { + TangemThemePreview { + WcSendingProcessModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + ) + } +} \ No newline at end of file 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 5b0b50fe2e..a662c1f8b7 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 @@ -95,6 +95,7 @@ internal fun WcSignTransactionModalBottomSheetContent( onClickActiveButton = state.onSign, activeButtonText = resourceReference(R.string.common_sign), isLoading = state.isLoading, + walletInteractionIcon = state.walletInteractionIcon, validationResult = null, ) }, @@ -184,6 +185,7 @@ private class WcSignTransactionStateProvider : CollectionPreviewParameterProvide walletName = "Tangem 2.0", networkInfo = WcNetworkInfoUM(name = "Ethereum", iconRes = R.drawable.img_eth_22), address = null, + walletInteractionIcon = R.drawable.ic_tangem_24, ), WcSignTransactionItemUM( onDismiss = {}, @@ -197,6 +199,7 @@ private class WcSignTransactionStateProvider : CollectionPreviewParameterProvide walletName = null, networkInfo = WcNetworkInfoUM(name = "Ethereum", iconRes = R.drawable.img_eth_22), address = "0xdac17f958d2ee523a2206206994597c13d831ec7", + walletInteractionIcon = R.drawable.ic_tangem_24, ), ), ) \ 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 index 3baa86b20e..4b7bdf5496 100644 --- 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 @@ -55,7 +55,7 @@ internal class WelcomeModel @Inject constructor( private val walletsFetcher = userWalletsFetcherFactory.create( messageSender = uiMessageSender, onlyMultiCurrency = false, - authMode = true, + isAuthMode = true, onWalletClick = { walletId -> modelScope.launch { val userWallets = userWalletsListRepository.userWalletsSync() @@ -208,7 +208,7 @@ internal class WelcomeModel @Inject constructor( } val unlockMethod = when (userWallet) { - is UserWallet.Cold -> UserWalletsListRepository.UnlockMethod.Scan + is UserWallet.Cold -> UserWalletsListRepository.UnlockMethod.Scan() is UserWallet.Hot -> { uiState.value = WelcomeUM.Empty UserWalletsListRepository.UnlockMethod.AccessCode 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 index 5b80e192ee..9da4e13724 100644 --- 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 @@ -22,8 +22,8 @@ internal fun WelcomePlain(modifier: Modifier = Modifier) { contentAlignment = Alignment.Center, ) { Icon( - modifier = Modifier.size(84.dp), - imageVector = ImageVector.vectorResource(R.drawable.ic_tangem_24), + modifier = Modifier.size(82.dp + 11.dp), + imageVector = ImageVector.vectorResource(R.drawable.splash_logo), tint = TangemTheme.colors.icon.primary1, contentDescription = null, ) 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 index 7e9e671696..d47a0f2311 100644 --- 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 @@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import com.tangem.common.ui.userwallet.UserWalletItem @@ -43,10 +44,9 @@ internal fun AnimatedContentScope.WelcomeSelectWallet(state: WelcomeUM.SelectWal .statusBarsPadding(), ) { TopBar(state) - TitleText() - SpacerH12() var actualWallets by remember { mutableStateOf>(persistentListOf()) } + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } Box(modifier = Modifier.weight(1f)) { LazyColumn( @@ -58,19 +58,26 @@ internal fun AnimatedContentScope.WelcomeSelectWallet(state: WelcomeUM.SelectWal ) + fadeIn(tween(delayMillis = 300)), exit = fadeOut(), ) - .fillMaxHeight() - .padding(horizontal = 16.dp), + .fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + item { + TitleText(modifier = Modifier.padding(bottom = 4.dp)) + } itemsIndexed(actualWallets) { index, walletState -> UserWalletItem( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), state = walletState, blockColors = TangemBlockCardColors.copy( containerColor = TangemTheme.colors.field.primary, ), ) } + item { + SpacerH(128.dp + bottomBarHeight) + } } BottomFade(modifier = Modifier.align(Alignment.BottomCenter)) @@ -115,7 +122,6 @@ private fun AnimatedContentScope.TopBar(state: WelcomeUM.SelectWallet, modifier: ) .padding( top = 16.dp, - bottom = 16.dp, start = 16.dp, ) .fillMaxWidth(), diff --git a/features/yield-supply/api/.gitignore b/features/yield-supply/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/yield-supply/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/yield-supply/api/build.gradle.kts b/features/yield-supply/api/build.gradle.kts new file mode 100644 index 0000000000..7fbd6e9fd1 --- /dev/null +++ b/features/yield-supply/api/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.yield.supply.api" +} + +dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.appCurrency.models) + + /** Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyDepositedWarningComponent.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyDepositedWarningComponent.kt new file mode 100644 index 0000000000..de39187872 --- /dev/null +++ b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyDepositedWarningComponent.kt @@ -0,0 +1,23 @@ +package com.tangem.features.yield.supply.api + +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.currency.CryptoCurrency + +interface YieldSupplyDepositedWarningComponent : ComposableBottomSheetComponent { + + data class Params( + val cryptoCurrency: CryptoCurrency, + val modelCallback: ModelCallback, + val onDismiss: () -> Unit, + ) + + interface ModelCallback { + fun onYieldSupplyWarningAcknowledged() + } + + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): YieldSupplyDepositedWarningComponent + } +} \ No newline at end of file diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt new file mode 100644 index 0000000000..93edb89872 --- /dev/null +++ b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.features.yield.supply.api + +interface YieldSupplyFeatureToggles { + + val isYieldSupplyFeatureEnabled: Boolean +} \ No newline at end of file diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyPromoComponent.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyPromoComponent.kt new file mode 100644 index 0000000000..0516efb037 --- /dev/null +++ b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyPromoComponent.kt @@ -0,0 +1,16 @@ +package com.tangem.features.yield.supply.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId + +interface YieldSupplyPromoComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val currency: CryptoCurrency, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/yield-supply/impl/.gitignore b/features/yield-supply/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/yield-supply/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/yield-supply/impl/build.gradle.kts b/features/yield-supply/impl/build.gradle.kts new file mode 100644 index 0000000000..0944ea2a33 --- /dev/null +++ b/features/yield-supply/impl/build.gradle.kts @@ -0,0 +1,64 @@ +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.yield.supply.impl" +} + +dependencies { + + /** Feature */ + implementation(projects.features.yieldSupply.api) + implementation(projects.features.sendV2.api) + + /** Core */ + implementation(projects.core.configToggles) + implementation(projects.core.decompose) + implementation(projects.core.ui) + implementation(projects.core.navigation) + + /** Common */ + implementation(projects.common.ui) + implementation(projects.common.routing) + + /** SDK */ + implementation(tangemDeps.blockchain) { + exclude(module = "joda-time") + } + + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.appCurrency) + implementation(projects.domain.wallets.models) + implementation(projects.domain.wallets) + implementation(projects.domain.tokens.models) + implementation(projects.domain.tokens) + implementation(projects.domain.transaction.models) + implementation(projects.domain.transaction) + implementation(projects.domain.yieldSupply) + + /** 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) + + /** Other */ + implementation(deps.decompose) + implementation(deps.decompose.ext.compose) + implementation(deps.timber) + implementation(deps.kotlin.immutable.collections) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt new file mode 100644 index 0000000000..db476c617b --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.features.yield.supply.impl + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles + +internal class DefaultYieldSupplyFeatureToggles( + private val featureToggles: FeatureTogglesManager, +) : YieldSupplyFeatureToggles { + override val isYieldSupplyFeatureEnabled: Boolean + get() = featureToggles.isFeatureEnabled("YIELD_SUPPLY_FEATURE_ENABLED") +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/entity/YieldSupplyFeeUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/entity/YieldSupplyFeeUM.kt new file mode 100644 index 0000000000..9c30cb4f8e --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/entity/YieldSupplyFeeUM.kt @@ -0,0 +1,26 @@ +package com.tangem.features.yield.supply.impl.common.entity + +import androidx.compose.runtime.Immutable +import com.tangem.blockchain.common.TransactionData +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class YieldSupplyFeeUM { + data object Loading : YieldSupplyFeeUM() + data object Error : YieldSupplyFeeUM() + data class Content( + val transactionDataList: ImmutableList, + val feeValue: TextReference, + ) : YieldSupplyFeeUM() +} + +internal data class YieldSupplyActionUM( + val title: TextReference, + val subtitle: TextReference, + val footer: TextReference, + val currencyIconState: CurrencyIconState, + val yieldSupplyFeeUM: YieldSupplyFeeUM, + val isPrimaryButtonEnabled: Boolean, +) \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/ui/YieldSupplyActionContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/ui/YieldSupplyActionContent.kt new file mode 100644 index 0000000000..98746a3d45 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/ui/YieldSupplyActionContent.kt @@ -0,0 +1,147 @@ +package com.tangem.features.yield.supply.impl.common.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.containers.FooterContainer +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM +import com.tangem.utils.StringsSigns +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun YieldSupplyActionContent( + yieldSupplyActionUM: YieldSupplyActionUM, + modifier: Modifier = Modifier, + iconContent: @Composable ColumnScope.() -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.padding(bottom = 16.dp, start = 16.dp, end = 16.dp), + ) { + iconContent() + SpacerH24() + Text( + text = yieldSupplyActionUM.title.resolveReference(), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + SpacerH8() + Text( + text = yieldSupplyActionUM.subtitle.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp), + ) + SpacerH24() + FooterContainer( + footer = yieldSupplyActionUM.footer, + paddingValues = PaddingValues( + start = 12.dp, + end = 12.dp, + top = 8.dp, + ), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(TangemTheme.colors.background.action) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = stringResourceSafe(R.string.common_network_fee_title), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + SpacerWMax() + when (val fee = yieldSupplyActionUM.yieldSupplyFeeUM) { + is YieldSupplyFeeUM.Content -> Text( + text = fee.feeValue.resolveReference(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.tertiary, + ) + YieldSupplyFeeUM.Error -> Text( + text = stringResourceSafe(R.string.common_fee_error), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.attention, + ) + YieldSupplyFeeUM.Loading -> { + TextShimmer( + style = TangemTheme.typography.body1, + text = stringResourceSafe(R.string.common_fee_error), + ) + } + } + } + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 344) +@Preview(showBackground = true, widthDp = 344, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun YieldSupplyActionContent_Preview( + @PreviewParameter(YieldSupplyActionContentPreviewProvider::class) params: YieldSupplyActionUM, +) { + TangemThemePreview { + YieldSupplyActionContent( + yieldSupplyActionUM = params, + modifier = Modifier.background(TangemTheme.colors.background.tertiary), + ) { + CurrencyIcon( + state = CurrencyIconState.Loading, + shouldDisplayNetwork = false, + iconSize = 48.dp, + modifier = Modifier.size(48.dp), + ) + } + } +} + +private class YieldSupplyActionContentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + YieldSupplyActionUM( + title = resourceReference(R.string.yield_module_start_earning), + subtitle = resourceReference( + R.string.yield_module_start_earning_sheet_description, + wrappedList("USDT"), + ), + footer = combinedReference( + resourceReference(R.string.yield_module_start_earning_sheet_next_deposits), + stringReference(StringsSigns.WHITE_SPACE), + resourceReference(R.string.yield_module_start_earning_sheet_fee_policy), + ), + currencyIconState = CurrencyIconState.Loading, + yieldSupplyFeeUM = YieldSupplyFeeUM.Content( + transactionDataList = persistentListOf(), + feeValue = stringReference("0.00020 ETH • \$0.99"), + ), + isPrimaryButtonEnabled = false, + ), + ) +} +// endregion \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt new file mode 100644 index 0000000000..45b8806bd6 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt @@ -0,0 +1,21 @@ +package com.tangem.features.yield.supply.impl.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.yield.supply.impl.DefaultYieldSupplyFeatureToggles +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles +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 YieldSupplyFeatureModule { + + @Singleton + @Provides + fun provideYieldFeatureToggles(featureTogglesManager: FeatureTogglesManager): YieldSupplyFeatureToggles { + return DefaultYieldSupplyFeatureToggles(featureTogglesManager) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/DefaultYieldSupplyPromoComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/DefaultYieldSupplyPromoComponent.kt new file mode 100644 index 0000000000..7e9414c4a1 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/DefaultYieldSupplyPromoComponent.kt @@ -0,0 +1,56 @@ +package com.tangem.features.yield.supply.impl.promo + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.getEmptyComposableBottomSheetComponent +import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent +import com.tangem.features.yield.supply.impl.promo.model.YieldSupplyPromoModel +import com.tangem.features.yield.supply.impl.promo.ui.YieldSupplyPromoContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultYieldSupplyPromoComponent @AssistedInject constructor( + @Assisted private val appComponentContext: AppComponentContext, + @Assisted private val params: YieldSupplyPromoComponent.Params, +) : YieldSupplyPromoComponent, AppComponentContext by appComponentContext { + + private val model: YieldSupplyPromoModel = getOrCreateModel(params = params) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = null, + handleBackButton = false, + childFactory = { _, _ -> bottomSheetChild() }, + ) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState + val bottomSheet by bottomSheetSlot.subscribeAsState() + + YieldSupplyPromoContent( + yieldSupplyPromoUM = state, + clickIntents = model, + modifier = modifier, + ) + + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild(): ComposableBottomSheetComponent = getEmptyComposableBottomSheetComponent() + + @AssistedFactory + interface Factory : YieldSupplyPromoComponent.Factory { + override fun create( + context: AppComponentContext, + params: YieldSupplyPromoComponent.Params, + ): DefaultYieldSupplyPromoComponent + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/YieldSupplyPromoConfig.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/YieldSupplyPromoConfig.kt new file mode 100644 index 0000000000..7a87ea9946 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/YieldSupplyPromoConfig.kt @@ -0,0 +1,6 @@ +package com.tangem.features.yield.supply.impl.promo + +internal enum class YieldSupplyPromoConfig { + Apy, + Action, +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/di/YieldSupplyPromoBindsModule.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/di/YieldSupplyPromoBindsModule.kt new file mode 100644 index 0000000000..95e0a79cc6 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/di/YieldSupplyPromoBindsModule.kt @@ -0,0 +1,34 @@ +package com.tangem.features.yield.supply.impl.promo.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.yield.supply.impl.promo.DefaultYieldSupplyPromoComponent +import com.tangem.features.yield.supply.impl.promo.model.YieldSupplyPromoModel +import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface YieldSupplyPromoBindsModule { + @Binds + @Singleton + fun provideYieldSupplyPromoComponentFactory( + impl: DefaultYieldSupplyPromoComponent.Factory, + ): YieldSupplyPromoComponent.Factory +} + +@Module +@InstallIn(ModelComponent::class) +internal interface YieldSupplyPromoModelModule { + + @Binds + @IntoMap + @ClassKey(YieldSupplyPromoModel::class) + fun provideYieldSupplyPromoModel(impl: YieldSupplyPromoModel): Model +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/entity/YieldSupplyPromoUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/entity/YieldSupplyPromoUM.kt new file mode 100644 index 0000000000..51eefdad41 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/entity/YieldSupplyPromoUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.yield.supply.impl.promo.entity + +import com.tangem.core.ui.extensions.TextReference + +data class YieldSupplyPromoUM( + val tosLink: String, + val policyLink: String, + val title: TextReference, +) \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoClickIntents.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoClickIntents.kt new file mode 100644 index 0000000000..0bf70fa048 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoClickIntents.kt @@ -0,0 +1,14 @@ +package com.tangem.features.yield.supply.impl.promo.model + +internal interface YieldSupplyPromoClickIntents { + + fun onBackClick() + + fun onApyInfoClick() + + fun onHowItWorksClick() + + fun onStartEarningClick() + + fun onUrlClick(url: String) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt new file mode 100644 index 0000000000..9dbc5ecdd9 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt @@ -0,0 +1,51 @@ +package com.tangem.features.yield.supply.impl.promo.model + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.common.routing.AppRouter +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.promo.YieldSupplyPromoConfig +import com.tangem.features.yield.supply.impl.promo.entity.YieldSupplyPromoUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject + +@ModelScoped +internal class YieldSupplyPromoModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val urlOpener: UrlOpener, + private val appRouter: AppRouter, +) : Model(), YieldSupplyPromoClickIntents { + + val uiState: YieldSupplyPromoUM = YieldSupplyPromoUM( + tosLink = "https://tangem.com/terms-of-service/", // TODO replace with real link + policyLink = "https://tangem.com/privacy-policy/", // TODO replace with real link + title = resourceReference(R.string.yield_module_promo_screen_title, wrappedList("5.3")), + ) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + + override fun onBackClick() { + appRouter.pop() + } + + override fun onApyInfoClick() { + bottomSheetNavigation.activate(YieldSupplyPromoConfig.Apy) + } + + override fun onUrlClick(url: String) { + urlOpener.openUrl(url) + } + + override fun onHowItWorksClick() { + urlOpener.openUrl("https://tangem.com/") // TODO replace with real link + } + + override fun onStartEarningClick() { + bottomSheetNavigation.activate(YieldSupplyPromoConfig.Action) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt new file mode 100644 index 0000000000..5839f6fb28 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt @@ -0,0 +1,239 @@ +package com.tangem.features.yield.supply.impl.promo.ui + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.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.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.label.Label +import com.tangem.core.ui.components.label.entity.LabelStyle +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.promo.entity.YieldSupplyPromoUM +import com.tangem.features.yield.supply.impl.promo.model.YieldSupplyPromoClickIntents +import com.tangem.utils.StringsSigns + +@Suppress("MagicNumber") +@Composable +internal fun YieldSupplyPromoContent( + yieldSupplyPromoUM: YieldSupplyPromoUM, + clickIntents: YieldSupplyPromoClickIntents, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .background(color = TangemTheme.colors.background.tertiary) + .fillMaxWidth() + .imePadding() + .systemBarsPadding() + .padding(horizontal = 16.dp), + ) { + YieldStatusAppBar( + onBackClick = clickIntents::onBackClick, + onHowItWorksClick = clickIntents::onHowItWorksClick, + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 20.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_analytics_up_24), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + modifier = Modifier + .background(TangemTheme.colors.icon.accent.copy(0.1f), CircleShape) + .padding(12.dp) + .size(32.dp), + ) + SpacerH(20.dp) + Text( + text = yieldSupplyPromoUM.title.resolveReference(), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + SpacerH8() + Label( + state = LabelUM( + text = resourceReference(R.string.yield_module_promo_screen_variable_rate_info), + style = LabelStyle.REGULAR, + icon = R.drawable.ic_information_24, + onIconClick = clickIntents::onApyInfoClick, + ), + ) + SpacerH32() + PromoItems() + } + SpacerHMax() + YieldSupplyTosText( + tosLink = yieldSupplyPromoUM.tosLink, + policyLink = yieldSupplyPromoUM.policyLink, + onClick = clickIntents::onUrlClick, + ) + PrimaryButton( + text = stringResourceSafe(R.string.yield_module_start_earning), + onClick = clickIntents::onStartEarningClick, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH8() + } +} + +@Composable +private fun YieldStatusAppBar(onBackClick: () -> Unit, onHowItWorksClick: () -> Unit) { + Row( + modifier = Modifier.padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_back_24), + contentDescription = null, + tint = TangemTheme.colors.icon.primary1, + modifier = Modifier.clickable(onClick = onBackClick), + ) + SpacerWMax() + Text( + text = stringResourceSafe(R.string.yield_module_promo_screen_how_it_works_button_title), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.clickable(onClick = onHowItWorksClick), + ) + } +} + +@Composable +private fun PromoItems() { + PromoItem( + icon = R.drawable.ic_flash_new_24, + title = resourceReference(R.string.yield_module_promo_screen_cash_out_title), + subtitle = resourceReference(R.string.yield_module_promo_screen_cash_out_subtitle), + ) + SpacerH24() + PromoItem( + icon = R.drawable.ic_repeat_24, + title = resourceReference(R.string.yield_module_promo_screen_auto_balance_title), + subtitle = resourceReference(R.string.yield_module_promo_screen_auto_balance_subtitle), + ) + SpacerH24() + PromoItem( + icon = R.drawable.ic_security_check_24, + title = resourceReference(R.string.yield_module_promo_screen_self_custodial_title), + subtitle = resourceReference(R.string.yield_module_promo_screen_self_custodial_subtitle), + ) +} + +@Composable +private fun PromoItem(@DrawableRes icon: Int, title: TextReference, subtitle: TextReference) { + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(icon), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = title.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = subtitle.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } +} + +@Composable +private fun YieldSupplyTosText(tosLink: String, policyLink: String, onClick: (String) -> Unit) { + val tosTitle = stringResourceSafe(R.string.common_terms_of_use) + val policyTitle = stringResourceSafe(R.string.common_privacy_policy) + val fullString = stringResourceSafe(id = R.string.yield_module_promo_screen_terms_disclaimer, tosTitle, policyTitle) + val tosIndex = fullString.indexOf(tosTitle) + val policyIndex = fullString.indexOf(policyTitle) + + Text( + text = buildAnnotatedString { + append(StringsSigns.POINT_SIGN) + appendSpace() + append(fullString.substring(0, tosIndex)) + withLink( + link = LinkAnnotation.Clickable( + tag = "TOS_TAG", + linkInteractionListener = { onClick(tosLink) }, + ), + block = { + appendColored( + text = fullString.substring(tosIndex, tosIndex + tosTitle.length), + color = TangemTheme.colors.text.accent, + ) + }, + ) + append(fullString.substring(tosIndex + tosTitle.length, policyIndex)) + withLink( + link = LinkAnnotation.Clickable( + tag = "POLICY_TAG", + linkInteractionListener = { onClick(policyLink) }, + ), + block = { + appendColored( + text = fullString.substring(policyIndex, policyIndex + policyTitle.length), + color = TangemTheme.colors.text.accent, + ) + }, + ) + }, + textAlign = TextAlign.Center, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360, heightDp = 724) +@Preview(showBackground = true, widthDp = 360, heightDp = 724, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun YieldSupplyPromoContent_Preview() { + TangemThemePreview { + YieldSupplyPromoContent( + yieldSupplyPromoUM = YieldSupplyPromoUM( + tosLink = "https://tangem.com/terms-of-service/", + policyLink = "https://tangem.com/privacy-policy/", + title = resourceReference(R.string.yield_module_promo_screen_title, wrappedList("5.3")), + ), + clickIntents = object : YieldSupplyPromoClickIntents { + override fun onBackClick() {} + override fun onApyInfoClick() {} + override fun onHowItWorksClick() {} + override fun onStartEarningClick() {} + override fun onUrlClick(url: String) {} + }, + ) + } +} + +// endregion \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveComponent.kt new file mode 100644 index 0000000000..2b3bd18f57 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveComponent.kt @@ -0,0 +1,63 @@ +package com.tangem.features.yield.supply.impl.subcomponents.active + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.yield.supply.impl.subcomponents.active.model.YieldSupplyActiveModel +import com.tangem.features.yield.supply.impl.subcomponents.active.ui.YieldSupplyActiveContent +import com.tangem.features.yield.supply.impl.subcomponents.active.ui.YieldSupplyActiveTitle +import com.tangem.features.yield.supply.impl.R +import kotlinx.coroutines.flow.StateFlow + +internal class YieldSupplyActiveComponent( + appComponentContext: AppComponentContext, + private val params: Params, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + + private val model: YieldSupplyActiveModel = getOrCreateModel(params = params) + + @Composable + override fun Title() { + YieldSupplyActiveTitle(onCloseClick = params.callback::onBackClick) + } + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + YieldSupplyActiveContent(state = state, modifier = Modifier) + } + + @Composable + override fun Footer() { + SecondaryButton( + text = stringResourceSafe(R.string.yield_module_stop_earning), + onClick = params.callback::onStopEarning, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + + data class Params( + val userWallet: UserWallet, + val cryptoCurrencyStatusFlow: StateFlow, + val callback: ModelCallback, + ) + + interface ModelCallback { + fun onBackClick() + fun onStopEarning() + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveEntryComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveEntryComponent.kt new file mode 100644 index 0000000000..258da13fe2 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveEntryComponent.kt @@ -0,0 +1,103 @@ +package com.tangem.features.yield.supply.impl.subcomponents.active + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.decompose.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.yield.supply.impl.subcomponents.active.model.YieldSupplyActiveEntryModel +import com.tangem.features.yield.supply.impl.subcomponents.active.model.YieldSupplyActiveRoute +import com.tangem.features.yield.supply.impl.subcomponents.active.ui.YieldSupplyActiveEntryBottomSheet +import com.tangem.features.yield.supply.impl.subcomponents.stopearning.YieldSupplyStopEarningComponent +import kotlinx.coroutines.flow.StateFlow + +internal class YieldSupplyActiveEntryComponent( + private val appComponentContext: AppComponentContext, + private val params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val model: YieldSupplyActiveEntryModel = getOrCreateModel(params = params, router = innerRouter) + + private val innerStack = childStack( + key = "yieldSupplyActiveStack", + source = stackNavigation, + serializer = null, + initialConfiguration = YieldSupplyActiveRoute.Info, + handleBackButton = true, + childFactory = { configuration, factoryContext -> + createChild( + configuration, + childByContext( + componentContext = factoryContext, + router = innerRouter, + ), + ) + }, + ) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val stackState by innerStack.subscribeAsState() + + YieldSupplyActiveEntryBottomSheet( + stackState = stackState, + onDismiss = ::dismiss, + ) + } + + private fun createChild( + route: YieldSupplyActiveRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent = when (route) { + YieldSupplyActiveRoute.Info -> YieldSupplyActiveComponent( + appComponentContext = factoryContext, + params = YieldSupplyActiveComponent.Params( + userWallet = params.userWallet, + cryptoCurrencyStatusFlow = params.cryptoCurrencyStatusFlow, + callback = model, + ), + ) + YieldSupplyActiveRoute.Action -> YieldSupplyStopEarningComponent( + appComponentContext = factoryContext, + params = YieldSupplyStopEarningComponent.Params( + userWallet = params.userWallet, + cryptoCurrencyStatusFlow = params.cryptoCurrencyStatusFlow, + callback = model, + ), + ) + } + + private fun onChildBack() { + if (innerStack.value.backStack.isEmpty()) { + dismiss() + } else { + stackNavigation.pop() + } + } + + data class Params( + val userWallet: UserWallet, + val cryptoCurrencyStatusFlow: StateFlow, + val onDismiss: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/di/YieldSupplyActiveModule.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/di/YieldSupplyActiveModule.kt new file mode 100644 index 0000000000..4e062d0d10 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/di/YieldSupplyActiveModule.kt @@ -0,0 +1,26 @@ +package com.tangem.features.yield.supply.impl.subcomponents.active.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.yield.supply.impl.subcomponents.active.model.YieldSupplyActiveEntryModel +import com.tangem.features.yield.supply.impl.subcomponents.active.model.YieldSupplyActiveModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface YieldSupplyActiveModule { + + @Binds + @IntoMap + @ClassKey(YieldSupplyActiveModel::class) + fun provideYieldSupplyActiveModel(impl: YieldSupplyActiveModel): Model + + @Binds + @IntoMap + @ClassKey(YieldSupplyActiveEntryModel::class) + fun provideYieldSupplyActiveEntryModel(impl: YieldSupplyActiveEntryModel): Model +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/entity/YieldSupplyActiveContentUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/entity/YieldSupplyActiveContentUM.kt new file mode 100644 index 0000000000..fe2157d2ef --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/entity/YieldSupplyActiveContentUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.yield.supply.impl.subcomponents.active.entity + +import com.tangem.core.ui.extensions.TextReference + +internal data class YieldSupplyActiveContentUM( + val totalEarnings: TextReference, + val availableBalance: TextReference, + val providerTitle: TextReference, + val subtitle: TextReference, +) \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveEntryModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveEntryModel.kt new file mode 100644 index 0000000000..57878b486a --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveEntryModel.kt @@ -0,0 +1,33 @@ +package com.tangem.features.yield.supply.impl.subcomponents.active.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.core.decompose.navigation.Router +import com.tangem.features.yield.supply.impl.subcomponents.active.YieldSupplyActiveComponent +import com.tangem.features.yield.supply.impl.subcomponents.active.YieldSupplyActiveEntryComponent +import com.tangem.features.yield.supply.impl.subcomponents.stopearning.YieldSupplyStopEarningComponent +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject + +@ModelScoped +internal class YieldSupplyActiveEntryModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, + private val router: Router, +) : Model(), YieldSupplyActiveComponent.ModelCallback, YieldSupplyStopEarningComponent.ModelCallback { + + private val params = paramsContainer.require() + + override fun onStopEarning() { + router.push(YieldSupplyActiveRoute.Action) + } + + override fun onBackClick() { + router.pop() + } + + override fun onTransactionSent() { + params.onDismiss() + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveModel.kt new file mode 100644 index 0000000000..698ba4b032 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveModel.kt @@ -0,0 +1,50 @@ +package com.tangem.features.yield.supply.impl.subcomponents.active.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.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.subcomponents.active.YieldSupplyActiveComponent +import com.tangem.features.yield.supply.impl.subcomponents.active.entity.YieldSupplyActiveContentUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ModelScoped +internal class YieldSupplyActiveModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params: YieldSupplyActiveComponent.Params = paramsContainer.require() + + private val cryptoCurrencyStatusFlow = params.cryptoCurrencyStatusFlow + private val cryptoCurrency = cryptoCurrencyStatusFlow.value.currency + + val uiState: StateFlow + field = MutableStateFlow( + YieldSupplyActiveContentUM( + totalEarnings = stringReference("0"), + availableBalance = stringReference( + cryptoCurrencyStatusFlow.value.value.amount.format { + crypto(cryptoCurrency = cryptoCurrencyStatusFlow.value.currency) + }, + ), + providerTitle = resourceReference(R.string.yield_module_provider), + subtitle = combinedReference( + resourceReference( + id = R.string.yield_module_earn_sheet_provider_description, + formatArgs = wrappedList(cryptoCurrency.symbol, cryptoCurrency.symbol), + ), + resourceReference(R.string.common_read_more), + ), + ), + ) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveRoute.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveRoute.kt new file mode 100644 index 0000000000..a143cc777f --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveRoute.kt @@ -0,0 +1,8 @@ +package com.tangem.features.yield.supply.impl.subcomponents.active.model + +import com.tangem.core.decompose.navigation.Route + +internal sealed class YieldSupplyActiveRoute : Route { + data object Info : YieldSupplyActiveRoute() + data object Action : YieldSupplyActiveRoute() +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt new file mode 100644 index 0000000000..d0bb88752b --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt @@ -0,0 +1,164 @@ +package com.tangem.features.yield.supply.impl.subcomponents.active.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.ResizableText +import com.tangem.core.ui.components.SpacerH4 +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.subcomponents.active.entity.YieldSupplyActiveContentUM + +@Composable +internal fun YieldSupplyActiveContent(state: YieldSupplyActiveContentUM, modifier: Modifier = Modifier) { + Column( + verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = modifier.padding( + vertical = 8.dp, + horizontal = 16.dp, + ), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.background.action) + .fillMaxWidth() + .padding(12.dp), + ) { + Text( + text = stringResourceSafe(R.string.yield_module_earn_sheet_total_earnings_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + ResizableText( + text = state.totalEarnings.resolveReference(), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + } + YieldSupplyActiveMyFunds(state = state) + } +} + +@Composable +private fun YieldSupplyActiveMyFunds(state: YieldSupplyActiveContentUM) { + Column( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.background.action) + .fillMaxWidth() + .padding(12.dp), + ) { + Text( + text = stringResourceSafe(R.string.yield_module_earn_sheet_my_funds_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerH4() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_aave_36), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text( + text = state.providerTitle.resolveReference(), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + } + SpacerH8() + Text( + text = state.subtitle.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerH8() + HorizontalDivider( + thickness = 0.5.dp, + color = TangemTheme.colors.stroke.primary, + ) + InfoRow( + title = resourceReference(R.string.yield_module_earn_sheet_transfers_title), + info = resourceReference(R.string.yield_module_transfer_mode_automatic), + ) + HorizontalDivider( + thickness = 0.5.dp, + color = TangemTheme.colors.stroke.primary, + ) + InfoRow( + title = resourceReference(R.string.yield_module_earn_sheet_available_title), + info = state.availableBalance, + ) + } +} + +@Composable +private fun InfoRow(title: TextReference, info: TextReference) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp), + ) { + Text( + text = title.resolveReference(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + SpacerWMax() + Text( + text = info.resolveReference(), + style = TangemTheme.typography.body1, + 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 YieldSupplyActiveBottomSheet_Preview( + @PreviewParameter(YieldSupplyActiveBottomSheetPreviewProvider::class) params: YieldSupplyActiveContentUM, +) { + TangemThemePreview { + YieldSupplyActiveContent(params) + } +} + +private class YieldSupplyActiveBottomSheetPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + YieldSupplyActiveContentUM( + totalEarnings = stringReference("0.006994219 USDT"), + availableBalance = stringReference("3,210.006994 aUSDT"), + providerTitle = stringReference("Aave"), + subtitle = resourceReference( + R.string.yield_module_earn_sheet_provider_description, + wrappedList("USDT", "USDT"), + ), + ), + ) +} +// endregion \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveEntryBottomSheet.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveEntryBottomSheet.kt new file mode 100644 index 0000000000..124b736fa4 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveEntryBottomSheet.kt @@ -0,0 +1,48 @@ +package com.tangem.features.yield.supply.impl.subcomponents.active.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.yield.supply.impl.subcomponents.active.model.YieldSupplyActiveRoute + +@Composable +internal fun YieldSupplyActiveEntryBottomSheet( + stackState: ChildStack, + onDismiss: () -> Unit, +) { + TangemModalBottomSheetWithFooter( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors.background.tertiary, + title = { state -> + AnimatedContent( + stackState.active.instance, + ) { currentState -> + currentState.Title() + } + }, + footer = { state -> + AnimatedContent( + stackState.active.instance, + ) { currentState -> + currentState.Footer() + } + }, + content = { state -> + AnimatedContent( + stackState.active.instance, + ) { currentState -> + currentState.Content(modifier = Modifier) + } + }, + ) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveTitle.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveTitle.kt new file mode 100644 index 0000000000..300e051ba1 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveTitle.kt @@ -0,0 +1,51 @@ +package com.tangem.features.yield.supply.impl.subcomponents.active.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.buttons.small.TangemIconButton +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.yield.supply.impl.R + +@Composable +internal fun YieldSupplyActiveTitle(onCloseClick: () -> Unit) { + Box(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.align(Alignment.Center)) { + Text( + text = stringResourceSafe(R.string.yield_module_earn_sheet_title), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(TangemTheme.colors.icon.accent, CircleShape), + ) + Text( + text = stringResourceSafe(R.string.yield_module_status_active), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier, + ) + } + } + TangemIconButton( + iconRes = R.drawable.ic_close_24, + onClick = onCloseClick, + modifier = Modifier + .padding(16.dp) + .align(Alignment.CenterEnd), + ) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/YieldSupplyStartEarningComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/YieldSupplyStartEarningComponent.kt new file mode 100644 index 0000000000..99e5075b5f --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/YieldSupplyStartEarningComponent.kt @@ -0,0 +1,107 @@ +package com.tangem.features.yield.supply.impl.subcomponents.startearning + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +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.PrimaryButtonIconEnd +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.common.ui.YieldSupplyActionContent +import com.tangem.features.yield.supply.impl.subcomponents.startearning.model.YieldSupplyStartEarningModel + +internal class YieldSupplyStartEarningComponent( + private val appComponentContext: AppComponentContext, + private val params: Params, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + + private val model: YieldSupplyStartEarningModel = getOrCreateModel(params = params) + + @Composable + override fun Title() { + TangemModalBottomSheetTitle( + endIconRes = R.drawable.ic_close_24, + onEndClick = params.callback::onBackClick, + ) + } + + @Suppress("MagicNumber") + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + YieldSupplyActionContent( + yieldSupplyActionUM = state, + modifier = modifier, + ) { + Box( + modifier = Modifier + .width(80.dp) + .padding(vertical = 1.dp), + ) { + CurrencyIcon( + state = state.currencyIconState, + shouldDisplayNetwork = false, + iconSize = 48.dp, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(48.dp)) + .align(Alignment.CenterStart), + ) + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_aave_36), + contentDescription = null, + modifier = Modifier + .background(TangemTheme.colors.background.tertiary, RoundedCornerShape(51.dp)) + .padding(3.dp) + .size(48.dp) + .align(Alignment.CenterEnd), + ) + } + } + } + + @Composable + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + + PrimaryButtonIconEnd( + text = stringResourceSafe(R.string.yield_module_start_earning), + onClick = model::onClick, + enabled = state.isPrimaryButtonEnabled, + iconResId = R.drawable.ic_tangem_24, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + + data class Params( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + val callback: ModelCallback, + ) + + interface ModelCallback { + fun onBackClick() + fun onFeePolicyClick() + fun onTransactionSent() + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/YieldSupplyStartEarningEntryComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/YieldSupplyStartEarningEntryComponent.kt new file mode 100644 index 0000000000..d13e9293d2 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/YieldSupplyStartEarningEntryComponent.kt @@ -0,0 +1,94 @@ +package com.tangem.features.yield.supply.impl.subcomponents.startearning + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.decompose.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.decompose.getEmptyComposableModularContentComponent +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.yield.supply.impl.subcomponents.startearning.model.YieldSupplyStartEarningEntryModel +import com.tangem.features.yield.supply.impl.subcomponents.startearning.ui.YieldSupplyStartEarningBottomSheet + +internal class YieldSupplyStartEarningEntryComponent( + private val appComponentContext: AppComponentContext, + private val params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val model: YieldSupplyStartEarningEntryModel = getOrCreateModel(params = params, router = innerRouter) + + private val innerStack = childStack( + key = "startEarningStack", + source = stackNavigation, + serializer = null, + initialConfiguration = YieldSupplyStartEarningRoute.Action, + handleBackButton = true, + childFactory = { configuration, factoryContext -> + createChild( + configuration, + childByContext( + componentContext = factoryContext, + router = innerRouter, + ), + ) + }, + ) + + override fun dismiss() { + params.onDismiss(false) + } + + @Composable + override fun BottomSheet() { + val stackState by innerStack.subscribeAsState() + + YieldSupplyStartEarningBottomSheet( + stackState = stackState, + onDismiss = ::dismiss, + ) + } + + private fun createChild( + route: YieldSupplyStartEarningRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent = when (route) { + YieldSupplyStartEarningRoute.FeePolicy -> getEmptyComposableModularContentComponent() // todo fee policy + YieldSupplyStartEarningRoute.Action -> YieldSupplyStartEarningComponent( + appComponentContext = factoryContext, + params = YieldSupplyStartEarningComponent.Params( + userWalletId = params.userWalletId, + cryptoCurrency = params.cryptoCurrency, + callback = model, + ), + ) + } + + private fun onChildBack() { + if (innerStack.value.backStack.isEmpty()) { + dismiss() + } else { + stackNavigation.pop() + } + } + + data class Params( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + val onDismiss: (Boolean) -> Unit, + ) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/YieldSupplyStartEarningRoute.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/YieldSupplyStartEarningRoute.kt new file mode 100644 index 0000000000..9a8c832586 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/YieldSupplyStartEarningRoute.kt @@ -0,0 +1,8 @@ +package com.tangem.features.yield.supply.impl.subcomponents.startearning + +import com.tangem.core.decompose.navigation.Route + +internal sealed class YieldSupplyStartEarningRoute : Route { + data object Action : YieldSupplyStartEarningRoute() + data object FeePolicy : YieldSupplyStartEarningRoute() +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/di/YieldSupplyStartEarningModule.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/di/YieldSupplyStartEarningModule.kt new file mode 100644 index 0000000000..a26ae6c3a0 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/di/YieldSupplyStartEarningModule.kt @@ -0,0 +1,26 @@ +package com.tangem.features.yield.supply.impl.subcomponents.startearning.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.yield.supply.impl.subcomponents.startearning.model.YieldSupplyStartEarningEntryModel +import com.tangem.features.yield.supply.impl.subcomponents.startearning.model.YieldSupplyStartEarningModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface YieldSupplyStartEarningModule { + + @Binds + @IntoMap + @ClassKey(YieldSupplyStartEarningModel::class) + fun provideYieldSupplyStartEarningModel(impl: YieldSupplyStartEarningModel): Model + + @Binds + @IntoMap + @ClassKey(YieldSupplyStartEarningEntryModel::class) + fun provideYieldSupplyStartEarningEntryModel(impl: YieldSupplyStartEarningEntryModel): Model +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/entity/YieldSupplyStartEarningContentUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/entity/YieldSupplyStartEarningContentUM.kt new file mode 100644 index 0000000000..0354f3e6e1 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/entity/YieldSupplyStartEarningContentUM.kt @@ -0,0 +1,20 @@ +package com.tangem.features.yield.supply.impl.subcomponents.startearning.entity + +import androidx.compose.runtime.Immutable +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal sealed class YieldSupplyStartEarningContentUM : TangemBottomSheetConfigContent { + + data class Main( + val currencyIconState: CurrencyIconState, + val fee: Fee?, + ) : YieldSupplyStartEarningContentUM() + + data class FeePolicy( + val title: TextReference, + ) : YieldSupplyStartEarningContentUM() +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningEntryModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningEntryModel.kt new file mode 100644 index 0000000000..c48e31cac8 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningEntryModel.kt @@ -0,0 +1,33 @@ +package com.tangem.features.yield.supply.impl.subcomponents.startearning.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.core.decompose.navigation.Router +import com.tangem.features.yield.supply.impl.subcomponents.startearning.YieldSupplyStartEarningComponent +import com.tangem.features.yield.supply.impl.subcomponents.startearning.YieldSupplyStartEarningEntryComponent +import com.tangem.features.yield.supply.impl.subcomponents.startearning.YieldSupplyStartEarningRoute +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject + +@ModelScoped +internal class YieldSupplyStartEarningEntryModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + paramsContainer: ParamsContainer, +) : Model(), YieldSupplyStartEarningComponent.ModelCallback { + + private val params = paramsContainer.require() + + override fun onBackClick() { + router.pop() + } + + override fun onFeePolicyClick() { + router.push(YieldSupplyStartEarningRoute.FeePolicy) + } + + override fun onTransactionSent() { + params.onDismiss(true) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt new file mode 100644 index 0000000000..dd5fee8a5e --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -0,0 +1,222 @@ +package com.tangem.features.yield.supply.impl.subcomponents.startearning.model + +import arrow.core.getOrElse +import com.tangem.blockchain.common.TransactionSender +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyStartEarningUseCase +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM +import com.tangem.features.yield.supply.impl.subcomponents.startearning.YieldSupplyStartEarningComponent +import com.tangem.utils.StringsSigns.DOT +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import java.math.BigDecimal +import javax.inject.Inject +import kotlin.properties.Delegates + +@Suppress("LongParameterList") +@ModelScoped +internal class YieldSupplyStartEarningModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, + private val sendTransactionUseCase: SendTransactionUseCase, + private val yieldSupplyStartEarningUseCase: YieldSupplyStartEarningUseCase, + private val yieldSupplyEstimateEnterFeeUseCase: YieldSupplyEstimateEnterFeeUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, +) : Model() { + + private val params: YieldSupplyStartEarningComponent.Params = paramsContainer.require() + + private val cryptoCurrency = params.cryptoCurrency + private var userWallet: UserWallet by Delegates.notNull() + + private val cryptoCurrencyStatusFlow: StateFlow + field = MutableStateFlow( + CryptoCurrencyStatus( + currency = cryptoCurrency, + value = CryptoCurrencyStatus.Loading, + ), + ) + private val feeCryptoCurrencyStatusFlow: StateFlow + field = MutableStateFlow( + CryptoCurrencyStatus( + currency = cryptoCurrency, + value = CryptoCurrencyStatus.Loading, + ), + ) + + val uiState: StateFlow + field: MutableStateFlow = MutableStateFlow( + YieldSupplyActionUM( + title = resourceReference(R.string.yield_module_start_earning), + subtitle = resourceReference( + R.string.yield_module_start_earning_sheet_description, + wrappedList(cryptoCurrency.symbol), + ), + footer = combinedReference( + resourceReference(R.string.yield_module_start_earning_sheet_next_deposits), + resourceReference(R.string.yield_module_start_earning_sheet_fee_policy), + ), + currencyIconState = CryptoCurrencyToIconStateConverter().convert(params.cryptoCurrency), + yieldSupplyFeeUM = YieldSupplyFeeUM.Loading, + isPrimaryButtonEnabled = false, + ), + ) + + private val cryptoCurrencyStatus + get() = cryptoCurrencyStatusFlow.value + private var appCurrency = AppCurrency.Default + + init { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + subscribeOnCurrencyStatusUpdates() + } + } + + private suspend fun onLoadFee() { + if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) return + + val transactionListData = yieldSupplyStartEarningUseCase( + userWalletId = userWallet.walletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull() ?: return + + val updatedTransactionList = yieldSupplyEstimateEnterFeeUseCase.invoke( + userWallet = userWallet, + cryptoCurrency = cryptoCurrency, + transactionDataList = transactionListData, + ).getOrNull() ?: return + + val feeSum = updatedTransactionList.sumOf { + it.fee?.amount?.value ?: BigDecimal.ZERO + } + + val crypto = feeSum.format { crypto(feeCryptoCurrencyStatusFlow.value.currency) } + val fiatFeeValue = cryptoCurrencyStatus.value.fiatRate?.let { rate -> + feeSum.multiply(rate) + } + + val fiat = fiatFeeValue.format { fiat(appCurrency.code, appCurrency.symbol) } + + uiState.update { + if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) { + it.copy(yieldSupplyFeeUM = YieldSupplyFeeUM.Loading) + } else { + it.copy( + isPrimaryButtonEnabled = true, // todo yield supply check for notifications + yieldSupplyFeeUM = YieldSupplyFeeUM.Content( + transactionDataList = updatedTransactionList.toPersistentList(), + feeValue = combinedReference( + stringReference(crypto), + stringReference(" $DOT "), + stringReference(fiat), + ), + ), + ) + } + } + } + + fun onClick() { + val yieldSupplyFeeUM = uiState.value.yieldSupplyFeeUM as? YieldSupplyFeeUM.Content ?: return + + uiState.update { it.copy(isPrimaryButtonEnabled = false) } + modelScope.launch(dispatchers.default) { + sendTransactionUseCase.invoke( + txsData = yieldSupplyFeeUM.transactionDataList, + userWallet = userWallet, + network = cryptoCurrency.network, + sendMode = TransactionSender.MultipleTransactionSendMode.DEFAULT, + ).fold( + ifLeft = { + Timber.e(it.toString()) + uiState.update { it.copy(isPrimaryButtonEnabled = true) } + }, + ifRight = { + params.callback.onTransactionSent() + }, + ) + } + } + + private fun subscribeOnCurrencyStatusUpdates() { + modelScope.launch { + getUserWalletUseCase(params.userWalletId).fold( + ifRight = { wallet -> + userWallet = wallet + getCurrenciesStatusUpdates() + }, + ifLeft = { + Timber.w(it.toString()) + // showAlertError() todo yield supply error alert + return@launch + }, + ) + } + } + + private fun getCurrenciesStatusUpdates() { + getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( + userWalletId = params.userWalletId, + currencyId = cryptoCurrency.id, + isSingleWalletWithTokens = false, + ).onEach { maybeCryptoCurrency -> + maybeCryptoCurrency.fold( + ifRight = { cryptoCurrencyStatus -> + onDataLoaded( + currencyStatus = cryptoCurrencyStatus, + feeCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = params.userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull() ?: cryptoCurrencyStatus, + ) + }, + ifLeft = { + // todo yield supply error + // sendConfirmAlertFactory.getGenericErrorState( + // onFailedTxEmailClick = { + // onFailedTxEmailClick(it.toString()) + // }, + // popBack = router::pop, + // ) + }, + ) + }.launchIn(modelScope) + } + + private fun onDataLoaded(currencyStatus: CryptoCurrencyStatus, feeCurrencyStatus: CryptoCurrencyStatus) { + cryptoCurrencyStatusFlow.update { currencyStatus } + feeCryptoCurrencyStatusFlow.update { feeCurrencyStatus } + + modelScope.launch { + onLoadFee() + } + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/ui/YieldSupplyStartEarningBottomSheet.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/ui/YieldSupplyStartEarningBottomSheet.kt new file mode 100644 index 0000000000..aba821a580 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/ui/YieldSupplyStartEarningBottomSheet.kt @@ -0,0 +1,48 @@ +package com.tangem.features.yield.supply.impl.subcomponents.startearning.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.yield.supply.impl.subcomponents.startearning.YieldSupplyStartEarningRoute + +@Composable +internal fun YieldSupplyStartEarningBottomSheet( + stackState: ChildStack, + onDismiss: () -> Unit, +) { + TangemModalBottomSheetWithFooter( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors.background.tertiary, + title = { + AnimatedContent( + stackState.active.instance, + ) { currentState -> + currentState.Title() + } + }, + footer = { + AnimatedContent( + stackState.active.instance, + ) { currentState -> + currentState.Footer() + } + }, + content = { + AnimatedContent( + stackState.active.instance, + ) { currentState -> + currentState.Content(modifier = Modifier) + } + }, + ) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/YieldSupplyStopEarningComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/YieldSupplyStopEarningComponent.kt new file mode 100644 index 0000000000..86ae2929ca --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/YieldSupplyStopEarningComponent.kt @@ -0,0 +1,96 @@ +package com.tangem.features.yield.supply.impl.subcomponents.stopearning + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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 androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.common.ui.YieldSupplyActionContent +import com.tangem.features.yield.supply.impl.subcomponents.stopearning.model.YieldSupplyStopEarningModel +import kotlinx.coroutines.flow.StateFlow + +internal class YieldSupplyStopEarningComponent( + private val appComponentContext: AppComponentContext, + private val params: Params, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + + private val model: YieldSupplyStopEarningModel = getOrCreateModel(params = params) + + @Composable + override fun Title() { + TangemModalBottomSheetTitle( + startIconRes = R.drawable.ic_back_24, + onStartClick = params.callback::onBackClick, + ) + } + + @Suppress("MagicNumber") + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + YieldSupplyActionContent( + yieldSupplyActionUM = state, + modifier = modifier, + ) { + Box( + modifier = Modifier + .size(56.dp) + .background(TangemTheme.colors.icon.attention.copy(0.1f), CircleShape), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_alert_triangle_20), + contentDescription = null, + tint = TangemTheme.colors.icon.attention, + modifier = Modifier + .padding(12.dp) + .size(32.dp), + ) + } + } + } + + @Composable + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + + PrimaryButtonIconEnd( + text = stringResourceSafe(R.string.common_confirm), + onClick = model::onClick, + iconResId = R.drawable.ic_tangem_24, + enabled = state.isPrimaryButtonEnabled, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + + data class Params( + val userWallet: UserWallet, + val cryptoCurrencyStatusFlow: StateFlow, + val callback: ModelCallback, + ) + + interface ModelCallback { + fun onBackClick() + fun onTransactionSent() + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/di/YieldSupplyStopEarningModule.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/di/YieldSupplyStopEarningModule.kt new file mode 100644 index 0000000000..2b8e83f294 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/di/YieldSupplyStopEarningModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.yield.supply.impl.subcomponents.stopearning.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.yield.supply.impl.subcomponents.stopearning.model.YieldSupplyStopEarningModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface YieldSupplyStopEarningModule { + + @Binds + @IntoMap + @ClassKey(YieldSupplyStopEarningModel::class) + fun provideYieldSupplyStopEarningModel(impl: YieldSupplyStopEarningModel): Model +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt new file mode 100644 index 0000000000..8aa0b80853 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt @@ -0,0 +1,160 @@ +package com.tangem.features.yield.supply.impl.subcomponents.stopearning.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.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyStopEarningUseCase +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM +import com.tangem.features.yield.supply.impl.subcomponents.stopearning.YieldSupplyStopEarningComponent +import com.tangem.utils.StringsSigns.DOT +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@Suppress("LongParameterList") +@ModelScoped +internal class YieldSupplyStopEarningModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, + private val getFeeUseCase: GetFeeUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, + private val sendTransactionUseCase: SendTransactionUseCase, + private val yieldSupplyStopEarningUseCase: YieldSupplyStopEarningUseCase, +) : Model() { + + private val params: YieldSupplyStopEarningComponent.Params = paramsContainer.require() + + private val cryptoCurrencyStatus + get() = params.cryptoCurrencyStatusFlow.value + private val cryptoCurrency = cryptoCurrencyStatus.currency + private var userWallet = params.userWallet + + private val feeCryptoCurrencyStatusFlow: StateFlow + field = MutableStateFlow( + CryptoCurrencyStatus( + cryptoCurrencyStatus.currency, + value = CryptoCurrencyStatus.Loading, + ), + ) + + private var appCurrency = AppCurrency.Default + + val uiState: StateFlow + field: MutableStateFlow = MutableStateFlow( + YieldSupplyActionUM( + title = resourceReference(R.string.yield_module_stop_earning), + subtitle = resourceReference( + id = R.string.yield_module_stop_earning_sheet_description, + formatArgs = wrappedList(cryptoCurrency.symbol), + ), + footer = combinedReference( + resourceReference(R.string.yield_module_stop_earning_sheet_fee_note), + resourceReference(R.string.common_read_more), + ), + currencyIconState = CryptoCurrencyToIconStateConverter().convert(cryptoCurrency), + yieldSupplyFeeUM = YieldSupplyFeeUM.Loading, + isPrimaryButtonEnabled = false, + ), + ) + + init { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + subscribeOnCurrencyStatusUpdates() + } + } + + fun onClick() { + val yieldSupplyFeeUM = uiState.value.yieldSupplyFeeUM as? YieldSupplyFeeUM.Content ?: return + uiState.update { it.copy(isPrimaryButtonEnabled = false) } + + modelScope.launch(dispatchers.default) { + sendTransactionUseCase( + txData = yieldSupplyFeeUM.transactionDataList.first(), + userWallet = userWallet, + network = cryptoCurrency.network, + ).fold( + ifLeft = { + Timber.e(it.toString()) + }, + ifRight = { + params.callback.onTransactionSent() + }, + ) + } + } + + private fun subscribeOnCurrencyStatusUpdates() { + modelScope.launch { + feeCryptoCurrencyStatusFlow.update { + getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWallet.walletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull() ?: cryptoCurrencyStatus + } + onLoadFee() + } + } + + private suspend fun onLoadFee() { + val exitTransitionData = yieldSupplyStopEarningUseCase( + userWalletId = userWallet.walletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + fee = null, + ).getOrNull() ?: return + + val fee = getFeeUseCase( + transactionData = exitTransitionData, + userWallet = userWallet, + network = cryptoCurrency.network, + ).getOrNull() ?: return + + val crypto = fee.normal.amount.value.format { crypto(feeCryptoCurrencyStatusFlow.value.currency) } + val fiatFeeValue = cryptoCurrencyStatus.value.fiatRate?.let { rate -> + fee.normal.amount.value?.multiply(rate) + } + + val fiat = fiatFeeValue.format { fiat(appCurrency.code, appCurrency.symbol) } + + uiState.update { + if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) { + it.copy(yieldSupplyFeeUM = YieldSupplyFeeUM.Loading) + } else { + it.copy( + isPrimaryButtonEnabled = true, + yieldSupplyFeeUM = YieldSupplyFeeUM.Content( + transactionDataList = persistentListOf(exitTransitionData.copy(fee = fee.normal)), + feeValue = combinedReference( + stringReference(crypto), + stringReference(" $DOT "), + stringReference(fiat), + ), + ), + ) + } + } + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/DefaultYieldSupplyDepositedWarningComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/DefaultYieldSupplyDepositedWarningComponent.kt new file mode 100644 index 0000000000..64aea7a635 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/DefaultYieldSupplyDepositedWarningComponent.kt @@ -0,0 +1,39 @@ +package com.tangem.features.yield.supply.impl.warning + +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.yield.supply.api.YieldSupplyDepositedWarningComponent +import com.tangem.features.yield.supply.impl.warning.model.YieldSupplyDepositedWarningModel +import com.tangem.features.yield.supply.impl.warning.ui.YieldSupplyDepositedWarningContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultYieldSupplyDepositedWarningComponent @AssistedInject constructor( + @Assisted private val appComponentContext: AppComponentContext, + @Assisted private val params: YieldSupplyDepositedWarningComponent.Params, +) : AppComponentContext by appComponentContext, YieldSupplyDepositedWarningComponent { + + private val model: YieldSupplyDepositedWarningModel = getOrCreateModel(params = params) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.state.collectAsStateWithLifecycle() + YieldSupplyDepositedWarningContent(warningUM = state, onDismiss = params.onDismiss) + } + + @AssistedFactory + interface Factory : YieldSupplyDepositedWarningComponent.Factory { + override fun create( + context: AppComponentContext, + params: YieldSupplyDepositedWarningComponent.Params, + ): DefaultYieldSupplyDepositedWarningComponent + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/di/YieldSupplyDepositedWarningModule.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/di/YieldSupplyDepositedWarningModule.kt new file mode 100644 index 0000000000..a066bb08d7 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/di/YieldSupplyDepositedWarningModule.kt @@ -0,0 +1,34 @@ +package com.tangem.features.yield.supply.impl.warning.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent +import com.tangem.features.yield.supply.impl.warning.DefaultYieldSupplyDepositedWarningComponent +import com.tangem.features.yield.supply.impl.warning.model.YieldSupplyDepositedWarningModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface YieldSupplyDepositedWarningModule { + @Binds + @Singleton + fun provideYieldSupplyWarningComponentFactory( + impl: DefaultYieldSupplyDepositedWarningComponent.Factory, + ): YieldSupplyDepositedWarningComponent.Factory +} + +@Module +@InstallIn(ModelComponent::class) +internal interface YieldSupplyDepositedWarningModelModule { + + @Binds + @IntoMap + @ClassKey(YieldSupplyDepositedWarningModel::class) + fun provideYieldSupplyWarningModel(impl: YieldSupplyDepositedWarningModel): Model +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/model/YieldSupplyDepositedWarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/model/YieldSupplyDepositedWarningModel.kt new file mode 100644 index 0000000000..038224d072 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/model/YieldSupplyDepositedWarningModel.kt @@ -0,0 +1,35 @@ +package com.tangem.features.yield.supply.impl.warning.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.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent +import com.tangem.features.yield.supply.impl.warning.ui.YieldSupplyDepositedWarningUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@Stable +@ModelScoped +internal class YieldSupplyDepositedWarningModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = + paramsContainer.require() + + private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) + + internal val state: StateFlow + field = MutableStateFlow( + YieldSupplyDepositedWarningUM( + iconState = iconStateConverter.convert(params.cryptoCurrency), + onWarningAcknowledged = params.modelCallback::onYieldSupplyWarningAcknowledged, + network = params.cryptoCurrency.name, + ), + ) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/ui/YieldSupplyDepositedWarningContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/ui/YieldSupplyDepositedWarningContent.kt new file mode 100644 index 0000000000..9f4529e86f --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/ui/YieldSupplyDepositedWarningContent.kt @@ -0,0 +1,159 @@ +package com.tangem.features.yield.supply.impl.warning.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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 com.tangem.core.ui.R as CoreUiR +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.SpacerH8 +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.yield.supply.impl.R + +@Composable +internal fun YieldSupplyDepositedWarningContent(warningUM: YieldSupplyDepositedWarningUM, onDismiss: () -> Unit) { + TangemModalBottomSheetWithFooter( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors.background.primary, + onBack = null, + title = { + TangemModalBottomSheetTitle( + endIconRes = CoreUiR.drawable.ic_close_24, + onEndClick = onDismiss, + ) + }, + content = { + Content(warningUM) + }, + footer = { + SecondaryButton( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + text = stringResourceSafe(CoreUiR.string.balance_hidden_got_it_button), + onClick = onDismiss, + ) + }, + ) +} + +@Composable +private fun Content(warningUM: YieldSupplyDepositedWarningUM) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = TangemTheme.colors.background.primary) + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(height = 56.dp, width = 80.dp), + contentAlignment = Alignment.Center, + ) { + CurrencyIcon( + modifier = Modifier + .align(Alignment.CenterStart) + .size(48.dp), + state = warningUM.iconState, + shouldDisplayNetwork = true, + iconSize = 48.dp, + ) + Box( + modifier = Modifier + .size(54.dp) + .align(Alignment.CenterEnd) + .background(TangemTheme.colors.background.primary, CircleShape), + ) + Image( + painter = painterResource(id = CoreUiR.drawable.img_aave_22), + contentDescription = null, + modifier = Modifier + .padding(3.dp) + .align(Alignment.CenterEnd) + .size(48.dp), + ) + } + + SpacerH24() + + Text( + textAlign = TextAlign.Center, + text = stringResourceSafe(R.string.yield_module_explore_sheet_title, warningUM.network), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + + SpacerH8() + + Text( + textAlign = TextAlign.Center, + text = stringResourceSafe(R.string.yield_module_balance_info_sheet_subtitle), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + + SpacerH(8.dp) + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PreviewYieldSupplyDepositedWarningContent( + @PreviewParameter(YieldSupplyWarningContentProvider::class) warningUM: YieldSupplyDepositedWarningUM, +) { + TangemThemePreview { + YieldSupplyDepositedWarningContent(warningUM = warningUM, onDismiss = {}) + } +} + +private class YieldSupplyWarningContentProvider : PreviewParameterProvider { + private val iconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = null, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + showCustomBadge = false, + ) + + override val values: Sequence + get() = sequenceOf( + YieldSupplyDepositedWarningUM( + iconState = iconState, + onWarningAcknowledged = {}, + network = "Ethereum", + ), + ) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/ui/YieldSupplyDepositedWarningUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/ui/YieldSupplyDepositedWarningUM.kt new file mode 100644 index 0000000000..b675c74ac6 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/ui/YieldSupplyDepositedWarningUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.yield.supply.impl.warning.ui + +import com.tangem.core.ui.components.currency.icon.CurrencyIconState + +internal data class YieldSupplyDepositedWarningUM( + val network: String, + val iconState: CurrencyIconState, + val onWarningAcknowledged: () -> Unit, +) \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 972feade5e..5d0d2ea17d 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -11,6 +11,12 @@ ksp = "2.1.10-1.0.30" firebasePerf = "1.4.2" # endregion Classpath +# region AppGallery +agconnect = "1.9.1.304" +huaweiServices = "6.9.0.301" +huaweiPush = "6.11.0.300" +# endregion AppGallery + # region AndroidX androidxActivityCompose = "1.8.0" androidxAppCompat = "1.5.1" @@ -40,6 +46,7 @@ compose-lifecycle-runtime = "2.7.0" # endregion Compose # region Other libraries +appsflyer = "6.17.3" amplitude = "2.36.1" armadillo = "0.9.0" coil = "2.1.0" @@ -57,7 +64,6 @@ hilt-work = "1.0.0" hilt-compilerx = "1.0.0" jodatime = "2.12.1" kotlin-immutable-collections = "0.3.5" -kotsonGsonExt = "2.5.0" lottie = "3.4.0" lottie-compose = "6.6.0" moshi = "1.15.1" @@ -130,6 +136,7 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } room = { id = "androidx.room", version.ref = "room" } kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +agconnect = { id = "com.huawei.agconnect", version.ref = "agconnect" } [libraries] # region Classpath @@ -190,6 +197,15 @@ firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" } firebase-perf = { module = "com.google.firebase:firebase-perf" } # endregion Firebase +# region AppGallery +agconnect-agcp = { module = "com.huawei.agconnect:agcp", version.ref = "agconnect" } +agconnect-core = { module = "com.huawei.agconnect:agconnect-core", version.ref = "agconnect" } +agconnect-crash = { module = "com.huawei.agconnect:agconnect-crash", version.ref = "agconnect" } +huawei-base = { module = "com.huawei.hms:base", version.ref = "huaweiServices" } +huawei-analytics = { module = "com.huawei.hms:hianalytics", version.ref = "huaweiServices" } +huawei-push = { module = "com.huawei.hms:push", version.ref = "huaweiPush" } +# endregion AppGallery + # region Detekt detekt-compose = { module = "ru.kode:detekt-rules-compose", version.ref = "detektComposeRules" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } @@ -218,6 +234,7 @@ test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "orch # endregion Test # region Other +appsflyer = { module = "com.appsflyer:af-android-sdk", version.ref = "appsflyer" } amplitude = { module = "com.amplitude:android-sdk", version.ref = "amplitude" } armadillo = { module = "at.favre.lib:armadillo", version.ref = "armadillo" } coil = { module = "io.coil-kt:coil", version.ref = "coil" } @@ -236,7 +253,6 @@ hilt-core = { module = "com.google.dagger:hilt-core", version.ref = "hilt" } hilt-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-compilerx = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-compilerx" } jodatime = { module = "joda-time:joda-time", version.ref = "jodatime" } -kotsonGson = { module = "com.github.salomonbrys.kotson:kotson", version.ref = "kotsonGsonExt" } lottie = { module = "com.airbnb.android:lottie", version.ref = "lottie" } lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie-compose" } material = { module = "com.google.android.material:material", version.ref = "googleMaterialComponent" } diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt index 8a8f3e30d3..0e7eda2f34 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt @@ -7,6 +7,7 @@ import com.tangem.blockchain.common.WalletManagerFactory import com.tangem.blockchain.common.datastorage.BlockchainDataStorage import com.tangem.blockchain.common.logging.BlockchainSDKLogger import com.tangem.blockchainsdk.providers.BlockchainProviderTypes +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import timber.log.Timber import javax.inject.Inject @@ -23,6 +24,7 @@ internal class WalletManagerFactoryCreator @Inject constructor( private val accountCreator: AccountCreator, private val blockchainDataStorage: BlockchainDataStorage, private val blockchainSDKLogger: BlockchainSDKLogger, + private val featureTogglesManager: FeatureTogglesManager, ) { fun create(config: BlockchainSdkConfig, blockchainProviderTypes: BlockchainProviderTypes): WalletManagerFactory { @@ -32,7 +34,9 @@ internal class WalletManagerFactoryCreator @Inject constructor( config = config, blockchainProviderTypes = blockchainProviderTypes, accountCreator = accountCreator, - featureToggles = BlockchainFeatureToggles(), + featureToggles = BlockchainFeatureToggles( + isYieldSupplyEnabled = featureTogglesManager.isFeatureEnabled("YIELD_SUPPLY_FEATURE_ENABLED"), + ), blockchainDataStorage = blockchainDataStorage, loggers = listOf(blockchainSDKLogger), ) diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt index cce9a8c942..9abd481655 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt @@ -17,6 +17,7 @@ import com.tangem.blockchainsdk.providers.BlockchainProvidersTypesManager import com.tangem.blockchainsdk.providers.DevBlockchainProvidersTypesManager import com.tangem.blockchainsdk.providers.ProdBlockchainProvidersTypesManager import com.tangem.blockchainsdk.providers.dev.BlockchainProvidersResponseSerializer +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage @@ -90,11 +91,13 @@ internal object BlockchainSDKFactoryModule { tangemTechApi: TangemTechApi, appPreferencesStore: AppPreferencesStore, blockchainSDKLogger: BlockchainSDKLogger, + featureTogglesManager: FeatureTogglesManager, ): WalletManagerFactoryCreator { return WalletManagerFactoryCreator( accountCreator = DefaultAccountCreator(tangemTechApi), blockchainDataStorage = DefaultBlockchainDataStorage(appPreferencesStore), blockchainSDKLogger = blockchainSDKLogger, + featureTogglesManager = featureTogglesManager, ) } } \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/models/UpdateWalletManagerResult.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/models/UpdateWalletManagerResult.kt index 200f9c2d8b..7064211d88 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/models/UpdateWalletManagerResult.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/models/UpdateWalletManagerResult.kt @@ -2,6 +2,7 @@ package com.tangem.blockchainsdk.models import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import java.math.BigDecimal /** Result of updating wallet manager */ @@ -65,18 +66,38 @@ sealed class UpdateWalletManagerResult { */ data class Coin(override val value: BigDecimal) : CryptoCurrencyAmount - /** - * Token - * - * @property currencyRawId crypto currency id - * @property contractAddress token contract address - * @property value amount value - */ - data class Token( - override val value: BigDecimal, - val currencyRawId: CryptoCurrency.RawID?, - val contractAddress: String, - ) : CryptoCurrencyAmount + sealed interface Token : CryptoCurrencyAmount { + val currencyRawId: CryptoCurrency.RawID? + val contractAddress: String + + /** + * Basic Token + * + * @property currencyRawId crypto currency id + * @property contractAddress token contract address + * @property value amount value + */ + data class BasicToken( + override val value: BigDecimal, + override val currencyRawId: CryptoCurrency.RawID?, + override val contractAddress: String, + ) : Token + + /** + * Yield Supply Token + * + * @property currencyRawId crypto currency id + * @property contractAddress token contract address + * @property value amount value + * @property yieldSupplyStatus status of the yield token + */ + data class YieldSupplyToken( + override val value: BigDecimal, + override val currencyRawId: CryptoCurrency.RawID?, + override val contractAddress: String, + val yieldSupplyStatus: YieldSupplyStatus, + ) : Token + } } /** Crypto currency transaction */ diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/ExcludedBlockchains.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/ExcludedBlockchains.kt index 6e2cb4877d..e2d3d72ea2 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/ExcludedBlockchains.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/ExcludedBlockchains.kt @@ -28,10 +28,6 @@ class ExcludedBlockchains @Inject internal constructor( excludedBlockchainsManager = object : ExcludedBlockchainsManager { override val excludedBlockchainsIds: Set = emptySet() - - override suspend fun init() { - /* no-op */ - } }, ) diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt index e6534bcb53..203d2fddbd 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt @@ -9,4 +9,5 @@ fun Network.toBlockchain(): Blockchain = id.toBlockchain() /** Converts [Network.ID] to [Blockchain] */ fun Network.ID.toBlockchain(): Blockchain = rawId.toBlockchain() +/** Converts [Network.RawID] to [Blockchain] */ fun Network.RawID.toBlockchain(): Blockchain = Blockchain.fromId(id = value) \ No newline at end of file diff --git a/libs/crypto/build.gradle.kts b/libs/crypto/build.gradle.kts index 0278a0501c..2adf84b11c 100644 --- a/libs/crypto/build.gradle.kts +++ b/libs/crypto/build.gradle.kts @@ -10,17 +10,30 @@ android { namespace = "com.tangem.lib.crypto" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { - /** Coroutines */ - implementation(deps.kotlin.coroutines) - - /** SDK */ - implementation(tangemDeps.blockchain) - - /** Core */ + // region Project implementation(projects.core.utils) - - /** Libs */ implementation(projects.libs.blockchainSdk) + // endregion + + // region Tangem SDKs + implementation(tangemDeps.card.core) + implementation(tangemDeps.blockchain) + // endregion + + // region Other deps + implementation(deps.kotlin.coroutines) + implementation(deps.timber) + // endregion + + // region Test libraries + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.truth) + // endregion } \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt index 3f12cb37cb..aec26f5567 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt @@ -77,10 +77,16 @@ object BlockchainUtils { return blockchain == Blockchain.TON || blockchain == Blockchain.TONTestnet } - fun isSupportedNetworkId(blockchainId: String, excludedBlockchains: ExcludedBlockchains): Boolean { + fun isSupportedNetworkId( + blockchainId: String, + excludedBlockchains: ExcludedBlockchains, + hotExcludedBlockchains: Set, + hasOnlyHotWallets: Boolean = false, + ): Boolean { val blockchain = Blockchain.fromNetworkId(blockchainId) - return blockchain != null && blockchain !in excludedBlockchains + return blockchain != null && blockchain !in excludedBlockchains && + (hasOnlyHotWallets.not() || blockchain !in hotExcludedBlockchains) } fun isArbitrum(blockchainId: String): Boolean { diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt new file mode 100644 index 0000000000..6008e6ea58 --- /dev/null +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt @@ -0,0 +1,46 @@ +package com.tangem.lib.crypto.derivation + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.isUTXO +import com.tangem.crypto.hdWallet.DerivationPath + +/** + * Utility class to recognize the account node in a derivation path based on the blockchain type. + * Derivation path schema: [ m / purpose' / coin_type' / account' / change / address_index ]. + * + * @param blockchain the blockchain for which the account node is to be recognized + * +[REDACTED_AUTHOR] + */ +class AccountNodeRecognizer(blockchain: Blockchain) { + + /** Index of the account node in the derivation path */ + val accountNodeIndex: Int = if (blockchain.isUTXO) { + UTXO_BLOCKCHAIN_NODE_INDEX + } else { + NON_UTXO_BLOCKCHAIN_NODE_INDEX + } + + /** Recognizes the account node value from the given derivation path string [derivationPathValue] */ + fun recognize(derivationPathValue: String): Long? { + return runCatching { + recognize(derivationPath = DerivationPath(rawPath = derivationPathValue)) + } + .getOrNull() + } + + /** Recognizes the account node value from the given [derivationPath] */ + fun recognize(derivationPath: DerivationPath): Long? { + return runCatching { + val accountNode = derivationPath.nodes.getOrNull(accountNodeIndex) + + accountNode?.getIndex(includeHardened = false) + } + .getOrNull() + } + + private companion object { + const val UTXO_BLOCKCHAIN_NODE_INDEX = 2 + const val NON_UTXO_BLOCKCHAIN_NODE_INDEX = 4 + } +} \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/MutableDerivationPath.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/MutableDerivationPath.kt new file mode 100644 index 0000000000..92ec3a5e41 --- /dev/null +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/MutableDerivationPath.kt @@ -0,0 +1,44 @@ +package com.tangem.lib.crypto.derivation + +import com.tangem.blockchain.common.Blockchain +import com.tangem.crypto.hdWallet.DerivationNode +import com.tangem.crypto.hdWallet.DerivationPath +import timber.log.Timber + +/** Extension function to convert a [DerivationPath] into a [MutableDerivationPath] */ +fun DerivationPath.toMutable(): MutableDerivationPath = MutableDerivationPath(value = this) + +/** + * A mutable representation of a derivation path, allowing modifications to specific nodes + * + * @property value the initial derivationPath to be used + */ +class MutableDerivationPath internal constructor(val value: DerivationPath) { + + /** + * Replaces the account node in the derivation path with a new value + * + * @param value the new index to set for the account node + * @param blockchain the blockchain used to determine the account node index + */ + fun replaceAccountNode(value: Long, blockchain: Blockchain): MutableDerivationPath { + val mutableNodes = this@MutableDerivationPath.value.nodes.toMutableList() + + val accountNodeIndex = AccountNodeRecognizer(blockchain).accountNodeIndex + val accountNode = mutableNodes.getOrNull(accountNodeIndex) + + if (accountNode != null) { + mutableNodes[accountNodeIndex] = when (accountNode) { + is DerivationNode.Hardened -> DerivationNode.Hardened(value) + is DerivationNode.NonHardened -> DerivationNode.NonHardened(value) + } + } else { + Timber.e("Account node not found in the derivation path: ${this@MutableDerivationPath.value}") + } + + return DerivationPath(path = mutableNodes).toMutable() + } + + /** Applies the changes and returns it as an immutable [DerivationPath] */ + fun apply(): DerivationPath = value +} \ No newline at end of file diff --git a/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/AccountNodeRecognizerTest.kt b/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/AccountNodeRecognizerTest.kt new file mode 100644 index 0000000000..4295568a38 --- /dev/null +++ b/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/AccountNodeRecognizerTest.kt @@ -0,0 +1,116 @@ +package com.tangem.lib.crypto.derivation + +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.crypto.hdWallet.DerivationPath +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +internal class AccountNodeRecognizerTest { + + private val utxoBlockchain = Blockchain.Bitcoin + private val nonUtxoBlockchain = Blockchain.Ethereum + + @Nested + inner class RecognizeAsDerivationPath { + + @Test + fun `returns account node value for UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(utxoBlockchain) + val derivationPath = DerivationPath(rawPath = "m/44'/0'/1'/0/0") + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + val expected = 1 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns account node value for non-UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(nonUtxoBlockchain) + val derivationPath = DerivationPath(rawPath = "m/44'/0'/0'/0/0") + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + val expected = 0 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns null if derivation path is shorter than expected`() { + // Arrange + val recognizer = AccountNodeRecognizer(nonUtxoBlockchain) + val derivationPath = DerivationPath(rawPath = "m/44'/0'") + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + Truth.assertThat(actual).isNull() + } + } + + @Nested + inner class RecognizeAsString { + + @Test + fun `returns account node value for UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(utxoBlockchain) + val derivationPath = "m/44'/0'/1'/0/0" + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + val expected = 1 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns account node value for non-UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(nonUtxoBlockchain) + val derivationPath = "m/44'/0'/0'/0/0" + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + val expected = 0 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns null if derivation path is shorter than expected`() { + // Arrange + val recognizer = AccountNodeRecognizer(nonUtxoBlockchain) + val derivationPath = "m/44'/0'" + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun `returns null if derivation path string is invalid`() { + // Arrange + val recognizer = AccountNodeRecognizer(utxoBlockchain) + val derivationPathValue = "invalid/path" + + // Act + val actual = recognizer.recognize(derivationPathValue) + + // Assert + Truth.assertThat(actual).isNull() + } + } +} \ No newline at end of file diff --git a/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/MutableDerivationPathTest.kt b/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/MutableDerivationPathTest.kt new file mode 100644 index 0000000000..3a364785be --- /dev/null +++ b/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/MutableDerivationPathTest.kt @@ -0,0 +1,92 @@ +package com.tangem.lib.crypto.derivation + +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.crypto.hdWallet.DerivationPath +import org.junit.jupiter.api.Test + +internal class MutableDerivationPathTest { + + private val utxoBlockchain = Blockchain.Bitcoin + private val nonUtxoBlockchain = Blockchain.Ethereum + + @Test + fun `replaces account node with hardened value`() { + // Arrange + val derivationPath = DerivationPath(rawPath = "m/44'/0'/0'/0/0") + val mutablePath = derivationPath.toMutable() + + // Act + val actual = mutablePath + .replaceAccountNode(value = 1, blockchain = utxoBlockchain) + .apply() + + // Assert + val expected = DerivationPath(rawPath = "m/44'/0'/1'/0/0") + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `replaces account node with non hardened value`() { + // Arrange + val derivationPath = DerivationPath(rawPath = "m/44'/0'/0/0/0") + val mutablePath = derivationPath.toMutable() + + // Act + val actual = mutablePath + .replaceAccountNode(value = 1, blockchain = utxoBlockchain) + .apply() + + // Assert + val expected = DerivationPath(rawPath = "m/44'/0'/1/0/0") + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `does nothing if account node not found`() { + // Arrange + val derivationPath = DerivationPath(rawPath = "m/44'/0'") + val mutablePath = derivationPath.toMutable() + + // Act + val actual = mutablePath + .replaceAccountNode(value = 1, blockchain = utxoBlockchain) + .apply() + + // Assert + val expected = DerivationPath(rawPath = "m/44'/0'") + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `replaces account node with hardened value for non-utxo blockchain`() { + // Arrange + val derivationPath = DerivationPath(rawPath = "m/44'/60'/0'/0/0") + val mutablePath = derivationPath.toMutable() + + // Act + val actual = mutablePath + .replaceAccountNode(value = 2, blockchain = nonUtxoBlockchain) + .apply() + + // Assert + val expected = DerivationPath(rawPath = "m/44'/60'/0'/0/2") + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `does nothing if account node not found for non-utxo blockchain`() { + // Arrange + val derivationPath = DerivationPath(rawPath = "m/44'/60'") + val mutablePath = derivationPath.toMutable() + + // Act + val actual = mutablePath + .replaceAccountNode(value = 2, blockchain = nonUtxoBlockchain) + .apply() + + // Assert + val expected = DerivationPath(rawPath = "m/44'/60'") + Truth.assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file 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 6f03ce1a3a..88e9d25480 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 @@ -75,6 +75,7 @@ private fun AndroidBuildType.configureBuildVariant(appExtension: AppExtension, b } BuildType.Debug -> { isDebuggable = true + signingConfig = appExtension.signingConfigs.getByName(BuildType.Debug.id) } BuildType.Internal, BuildType.External diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt index c19607fae8..7011f2d423 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt @@ -29,6 +29,7 @@ internal fun BaseExtension.configureCompose(project: Project) { contains(Regex(pattern = ":app\$")) || // TODO: [REDACTED_JIRA] contains(Regex(pattern = ":features:markets:api\$")) || // provides Composable function contains(Regex(pattern = ":features:manage-tokens:api\$")) || // provides Composable function + contains(Regex(pattern = ":features:txhistory:api\$")) || // provides Composable function contains(Regex(pattern = ":impl\$")) } diff --git a/settings.gradle.kts b/settings.gradle.kts index d785e00c38..365023aad8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ pluginManagement { gradlePluginPortal() google() mavenCentral() + maven { url = uri("https://developer.huawei.com/repo/") } } includeBuild("plugins/configuration") @@ -35,6 +36,7 @@ dependencyResolutionManagement { } } mavenCentral() + maven { url = uri("https://developer.huawei.com/repo/") } mavenLocal { content { includeGroupAndSubgroups("com.tangem.tangem-sdk-kotlin") @@ -261,8 +263,8 @@ include(":features:hot-wallet:api") include(":features:hot-wallet:impl") include(":features:kyc:api") -//TODO disable for release because of the permissions -// include(":features:kyc:impl") +include(":features:kyc:impl") +include(":features:kyc:mock") include(":features:tangempay:main:api") include(":features:tangempay:main:impl") @@ -270,6 +272,9 @@ include(":features:tangempay:main:impl") include(":features:tangempay:details:api") include(":features:tangempay:details:impl") +include(":features:tangempay:onboarding:api") +include(":features:tangempay:onboarding:impl") + include(":features:create-wallet-selection:api") include(":features:create-wallet-selection:impl") @@ -281,6 +286,9 @@ include(":features:account:impl") include(":features:token-recieve:api") include(":features:token-recieve:impl") + +include(":features:yield-supply:api") +include(":features:yield-supply:impl") // endregion Feature modules // region Domain modules @@ -336,13 +344,13 @@ include(":domain:blockaid") 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") include(":domain:wallet-manager") include(":domain:wallet-manager:models") +include(":domain:yield-supply") // endregion Domain modules // region Data modules @@ -376,4 +384,6 @@ include(":data:blockaid") include(":data:swap") include(":data:express") include(":data:wallet-manager") -// endregion Data modules \ No newline at end of file +include(":data:yield-supply") +// endregion Data modules +include(":features:tangempay:onboarding") \ No newline at end of file diff --git a/tangem-android-tools b/tangem-android-tools index 794a8187e6..c25ebad5af 160000 --- a/tangem-android-tools +++ b/tangem-android-tools @@ -1 +1 @@ -Subproject commit 794a8187e6d248ca3c21661df199a34ffeb0037a +Subproject commit c25ebad5af5776af0c06e52a2a2eb12b886f6cfd