From 2718d3965a507efbd9f5038e84d69eb8b14d65c3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 6 Mar 2026 12:17:34 +0300 Subject: [PATCH 01/60] Updated on 2026-08-14 --- .../modal/TangemModalBottomSheet.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) 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 8731e72e07..f6275d6cf5 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 @@ -1,6 +1,7 @@ package com.tangem.core.ui.components.bottomsheets.modal import android.content.res.Configuration +import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -110,13 +111,17 @@ inline fun DefaultModalBottomSheet( sheetState = sheetState, onBack = onBack, bsContent = { - BsContent( - config = config, - containerColor = containerColor, - scrollableContent = scrollableContent, - title = title, - content = content, - ) + CompositionLocalProvider( + LocalOverscrollFactory provides null, + ) { + BsContent( + config = config, + containerColor = containerColor, + scrollableContent = scrollableContent, + title = title, + content = content, + ) + } }, ) } From 3a754e96a68baa10782ed0e1d28ea5590a89e0f8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 4 Mar 2026 14:45:34 +0300 Subject: [PATCH 02/60] Updated on 2026-08-14 --- .../src/main/assets/configs/feature_toggles_config.json | 4 ++++ .../features/wallet/featuretoggles/WalletFeatureToggles.kt | 2 ++ .../wallet/featuretoggles/DefaultWalletFeatureToggles.kt | 3 +++ 3 files changed, 9 insertions(+) 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 73b02b2af8..30e91a42ae 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 @@ -55,5 +55,9 @@ { "name": "MULTI_ADDRESS_UTXO_ENABLED", "version": "undefined" + }, + { + "name": "MAIN_SCREEN_QR_SCANNING_ENABLED", + "version": "undefined" } ] diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt index c2b4b02ced..1bb167f64c 100644 --- a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt @@ -8,4 +8,6 @@ package com.tangem.features.wallet.featuretoggles interface WalletFeatureToggles { val isWalletReorderFeatureEnabled: Boolean + + val isMainScreenQrScanningEnabled: Boolean } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt index ea02a07cac..2da336722c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt @@ -10,4 +10,7 @@ internal class DefaultWalletFeatureToggles @Inject constructor( override val isWalletReorderFeatureEnabled: Boolean get() = featureToggles.isFeatureEnabled("WALLET_REORDER_FEATURE_ENABLED") + + override val isMainScreenQrScanningEnabled: Boolean + get() = featureToggles.isFeatureEnabled("MAIN_SCREEN_QR_SCANNING_ENABLED") } \ No newline at end of file From 34609aeb8c528befd0a1e5130713d47bd1eb9eaf Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 4 Mar 2026 14:47:17 +0300 Subject: [PATCH 03/60] Updated on 2026-08-14 --- .../tangem/core/ui/ds/topbar/TangemTopBar.kt | 62 ++++++++++++------- .../wallet/child/wallet/model/WalletModel.kt | 3 + .../intents/WalletContentClickIntents.kt | 8 ++- .../common/WalletPreviewDataLegacy.kt | 2 +- .../router/DefaultWalletRouter.kt | 4 ++ .../presentation/router/InnerWalletRouter.kt | 3 + .../wallet/state/WalletStateController.kt | 2 +- .../wallet/state/model/WalletTopBarConfig.kt | 12 +++- .../InitializeWalletsTransformer.kt | 18 +++++- .../ui/components/common/WalletTopBar.kt | 47 +++++++++++--- 10 files changed, 123 insertions(+), 38 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt index c9dca9b4fc..383047dd49 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt @@ -26,6 +26,8 @@ import com.tangem.core.ui.R import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf /** * A top bar composable that displays a title and optional start and end icons. @@ -34,8 +36,8 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign * @param title The title text to be displayed in the center of the top bar. * @param modifier Modifier to be applied to the top bar. * @param subtitle Optional subtitle text to be displayed below the title. - * @param startActionUM Optional action data for the start action icon. - * @param endActionUM Optional action data for the end action icon. + * @param startAction Optional action data for the start action icon. + * @param endActions List of end action icons to display on the right side. * @param titleIconRes Optional drawable resource ID for the icon to be displayed next to the title. * [REDACTED_AUTHOR] @@ -45,8 +47,8 @@ fun TangemTopBar( modifier: Modifier = Modifier, title: TextReference? = null, subtitle: TextReference? = null, - startActionUM: TangemTopBarActionUM? = null, - endActionUM: TangemTopBarActionUM? = null, + startAction: TangemTopBarActionUM? = null, + endActions: ImmutableList = persistentListOf(), @DrawableRes titleIconRes: Int? = null, ) { TangemTopBar( @@ -54,13 +56,19 @@ fun TangemTopBar( subtitle = subtitle, titleIconRes = titleIconRes, modifier = modifier, - startContent = if (startActionUM != null) { - { TangemTopBarActionContent(startActionUM) } + startContent = if (startAction != null) { + { TangemTopBarActionContent(startAction) } } else { null }, - endContent = if (endActionUM != null) { - { TangemTopBarActionContent(endActionUM) } + endContent = if (endActions.isNotEmpty()) { + { + Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) { + endActions.forEach { action -> + TangemTopBarActionContent(action) + } + } + } } else { null }, @@ -215,7 +223,17 @@ private fun TangemTopBar_Preview(@PreviewParameter(PreviewProvider::class) param titleIconRes = params.titleIconRes, modifier = Modifier.background(TangemTheme.colors2.surface.level1), startContent = params.startActionUM?.let { { TangemTopBarActionContent(it) } }, - endContent = params.endActionUM?.let { { TangemTopBarActionContent(it) } }, + endContent = if (params.endActions.isNotEmpty()) { + { + Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) { + params.endActions.forEach { action -> + TangemTopBarActionContent(action) + } + } + } + } else { + null + }, ) } } @@ -225,7 +243,7 @@ private class TangemTopBarPreviewData( val subtitle: TextReference? = null, val titleIconRes: Int? = null, val startActionUM: TangemTopBarActionUM? = null, - val endActionUM: TangemTopBarActionUM? = null, + val endActions: ImmutableList = persistentListOf(), ) private class PreviewProvider : PreviewParameterProvider { @@ -238,11 +256,11 @@ private class PreviewProvider : PreviewParameterProvider) + + /** Open QR scanner screen */ + fun openQrScanner() } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt index b1ddbd67b7..89bff8d53b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt @@ -130,7 +130,7 @@ internal class WalletStateController @Inject constructor( private fun getInitialState(): WalletScreenState { return WalletScreenState( - topBarConfig = WalletTopBarConfig(onDetailsClick = {}), + topBarConfig = WalletTopBarConfig(), selectedWalletIndex = NOT_INITIALIZED_WALLET_INDEX, wallets = persistentListOf(), wallets2 = persistentListOf(), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTopBarConfig.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTopBarConfig.kt index 0bf162e9c7..717a80fe24 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTopBarConfig.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTopBarConfig.kt @@ -1,8 +1,16 @@ package com.tangem.feature.wallet.presentation.wallet.state.model +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + /** * Wallet screen top bar config * - * @property onDetailsClick lambda be invoked when details button is clicked + * @property endActions list of top bar end action buttons (e.g. QR scan, More) */ -internal data class WalletTopBarConfig(val onDetailsClick: () -> Unit) \ No newline at end of file +@Immutable +internal data class WalletTopBarConfig( + val endActions: ImmutableList = persistentListOf(), +) \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt index eb010c9a39..a79d895999 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt @@ -1,7 +1,9 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers +import com.tangem.core.ui.R as CoreUiR import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isLocked @@ -25,6 +27,7 @@ internal class InitializeWalletsTransformer( private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, + private val isMainScreenQrScanningEnabled: Boolean = false, ) : WalletScreenStateTransformer { private val walletLoadingStateFactory by lazy { @@ -60,7 +63,20 @@ internal class InitializeWalletsTransformer( private fun createTopBarConfig(): WalletTopBarConfig { return WalletTopBarConfig( - onDetailsClick = clickIntents::onDetailsClick, + endActions = listOfNotNull( + if (isMainScreenQrScanningEnabled) { + TangemTopBarActionUM( + iconRes = CoreUiR.drawable.ic_qrcode_scaner_24, + onClick = clickIntents::onScanQrClick, + ) + } else { + null + }, + TangemTopBarActionUM( + iconRes = CoreUiR.drawable.ic_more_default_24, + onClick = clickIntents::onDetailsClick, + ), + ).toPersistentList(), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt index 7782f92342..e2eb07e356 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt @@ -25,6 +25,7 @@ import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.common.WalletPreviewDataLegacy import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTopBarConfig import dev.chrisbanes.haze.HazeProgressive +import kotlinx.collections.immutable.persistentListOf private const val VISIBILITY_THRESHOLD = 0.5f @@ -54,16 +55,11 @@ internal fun WalletTopBar( TangemTopBar( title = wrappedBalance, - startActionUM = TangemTopBarActionUM( + startAction = TangemTopBarActionUM( iconRes = R.drawable.ic_tangem_24, isActionable = false, ), - endActionUM = TangemTopBarActionUM( - iconRes = R.drawable.ic_more_default_24, - isActionable = true, - onClick = topBarConfig.onDetailsClick, - ghostModeProgress = behavior.state.collapsedFraction, - ), + endActions = topBarConfig.endActions, modifier = Modifier .statusBarsPadding() .testTag(MainScreenTestTags.TOP_BAR), @@ -85,8 +81,15 @@ internal fun WalletTopBar(config: WalletTopBarConfig) { Icon(painter = painterResource(id = R.drawable.img_tangem_logo_90_24), contentDescription = null) }, actions = { - IconButton(onClick = config.onDetailsClick, modifier = Modifier.testTag(MainScreenTestTags.MORE_BUTTON)) { - Icon(painter = painterResource(id = R.drawable.ic_more_vertical_24), contentDescription = null) + config.endActions.forEach { action -> + if (action.onClick != null) { + IconButton(onClick = action.onClick!!) { + Icon( + painter = painterResource(id = action.iconRes), + contentDescription = null, + ) + } + } } }, colors = TopAppBarDefaults.topAppBarColors( @@ -115,7 +118,31 @@ private fun Preview_WalletTopBar() { private fun WalletTopBar_Preview() { TangemThemePreviewRedesign { WalletTopBar( - topBarConfig = WalletTopBarConfig(onDetailsClick = {}), + topBarConfig = WalletTopBarConfig(), + walletBalance = stringReference("$ 8923,05"), + behavior = rememberTangemExitUntilCollapsedScrollBehavior(), + ) + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun WalletTopBar_WithQrButton_Preview() { + TangemThemePreviewRedesign { + WalletTopBar( + topBarConfig = WalletTopBarConfig( + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = com.tangem.core.ui.R.drawable.ic_qrcode_scaner_24, + onClick = {}, + ), + TangemTopBarActionUM( + iconRes = R.drawable.ic_more_default_24, + onClick = {}, + ), + ), + ), walletBalance = stringReference("$ 8923,05"), behavior = rememberTangemExitUntilCollapsedScrollBehavior(), ) From e2721b21e11b75c52b9c21023f245f7a164e7cba Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 4 Mar 2026 14:47:39 +0300 Subject: [PATCH 04/60] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 1 + .../com/tangem/common/routing/AppRoute.kt | 3 +++ core/res/src/main/res/values-de/strings.xml | 11 +++++++++-- core/res/src/main/res/values-ja/strings.xml | 4 ++++ core/res/src/main/res/values-ru/strings.xml | 6 +++--- core/res/src/main/res/values/strings.xml | 2 ++ .../tangem/core/ui/ds/topbar/TangemTopBar.kt | 4 ++-- .../core/ui/ds/topbar/TangemTopBarActionUM.kt | 2 ++ .../main/res/drawable/ic_qrcode_scaner_24.xml | 18 ++++++++++++++++++ .../domain/qrscanning/models/SourceType.kt | 1 + .../InitializeQrScanningStateTransformer.kt | 5 +++++ .../model/intents/WalletContentClickIntents.kt | 1 + .../ui/components/common/WalletTopBar.kt | 4 ++-- 13 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_qrcode_scaner_24.xml 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 4131a1c99f..6078133aea 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 @@ -349,6 +349,7 @@ internal class ChildFactory @Inject constructor( val source = when (route.source) { is AppRoute.QrScanning.Source.Send -> SourceType.SEND is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT + is AppRoute.QrScanning.Source.MainScreen -> SourceType.MAIN_SCREEN } createComponentChild( context = context, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 07b256a7eb..8b1bc37667 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 @@ -167,11 +167,14 @@ sealed class AppRoute(val path: String) : Route { get() = when (this) { is Send -> "/$networkName" WalletConnect -> "" + MainScreen -> "" } data class Send(val networkName: String) : Source() data object WalletConnect : Source() + + data object MainScreen : Source() } } diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 4cc165f633..402ebaa74f 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -399,6 +399,7 @@ Staking beenden Aufgrund der Beschränkungen von %1$s können nur %2$d UTXOs in eine einzige Transaktion passen. Das bedeutet, dass du nur %3$s oder weniger senden kannst. Du musst den Betrag reduzieren. Wert kopiert + Meine Wallet Woche mit Ja @@ -469,9 +470,12 @@ Sende Geld nur mit Beste Gelegenheiten Filter löschen + Die Liste ist vorübergehend leer, da sie gerade aktualisiert wird. Schauen Sie in Kürze wieder rein. Alle Netzwerke Alle Arten Filtern nach + Meine Netzwerke + Netzwerke Meist verwendet Keine Ergebnisse Verdienen @@ -826,6 +830,7 @@ Volumen Rufe dies auf oder tippe auf die Suchleiste, um Token direkt vom Markt hinzuzufügen Token hinzufügen + Token hinzufügen Steiger die Leistung Deiner Assets und ermögliche Dir gleichzeitig den sofortigen Zugriff. %s Yield-Modus aktivieren Du musst auf die folgende Version aktualisieren: %1$s um eine mobile Wallet zu erstellen @@ -845,6 +850,7 @@ Verwandte Token Verwandte Nachrichten Auf dem Laufenden bleiben + Trend-Score NFC ist auf deinem Gerät nicht verfügbar Über NFT NFT-Vermögenswert @@ -963,12 +969,12 @@ Biometrische Daten Lese mehr über die Seed-Phrase - + . Schreibe diese %d-Wörter in der unten angegebenen Reihenfolge auf und bewahre sie an einem sicheren und geheimen Ort auf. Deine Seed-Phrase - + . %d Wörter Um deine Wallets zu importieren, gib bitte deine Seed-Phrase in das folgende Feld ein @@ -1431,6 +1437,7 @@ Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Sie die Verwendung Ihres Tokens für den Swap genehmigen. Tausche mehr Token zu besseren Kursen direkt in deiner Brieftasche. Neuer Swap-Anbieter verfügbar! + Suchen Sie etwas anderes? Versuchen Sie es mit der Suche oder erkunden Sie eine andere Kryptowährung! Suchen Sie nach einem beliebigen Token, auch wenn es noch nicht in Ihrer Liste ist. Nutzen Sie die Suche, um zu finden, was Sie benötigen. Vertraue auf den rund um die Uhr verfügbaren Support bei allen Problemen diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 6b87995090..e1c2294119 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -839,6 +839,7 @@ 関連トークン 関連ニュース 最新情報を入手 + トレンドスコア お使いのデバイスではNFCが使用できません NFTについて NFTアセット @@ -1488,6 +1489,7 @@ メイン画面からKYCを非表示にする 資金を追加 入金オプション + Googleウォレットに追加 カード番号 PINを変更する カードは支払いの準備が整いました。 @@ -1876,6 +1878,8 @@ サービスは一時的に利用できません スワップするトークンの量は %s を超えないでください スワップ金額は %s 以上である必要があります + このペアではスワップを利用できません。別のトークンを選択して、もう一度お試しください。 + スワップ非対応のペアです スワップの金額を変更してください このカードは、サンプル品または偽造品である可能性があります 真正性チェックに失敗しました diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 350f3c2c43..32e497df40 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -733,13 +733,13 @@ Удалить Например Bitcoin Ваш портфель был обновлен - Выбранный токен не доступен в кошельке на данный момент. Но не переживайте, вы можете выразить свой интерес проголосовав за его добавление. + Выбранный токен недоступен в кошельке на данный момент. Но не переживайте, вы можете выразить свой интерес проголосовав за его добавление. Голосовать Кошелёк не поддерживает более одной сети О монете Чтобы купить, обменять или получить данный токен, вам нужно добавить его к себе в портфель Этот актив в настоящее время не поддерживается в кошельке - Этот токен не доступен для данного кошелька + Этот токен недоступен для данного кошелька Добавить APY %s Мой портфель @@ -1499,7 +1499,7 @@ Обмен… Вы получите Выберите токен - не доступен + недоступен Будем рады вашей обратной связи Tangem Pay в режиме beta Карта заморожена diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index b03f0df753..7e5f655ad3 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -699,6 +699,7 @@ Mana level To begin tracking your crypto assets and transactions, add tokens Manage tokens + Scan QR code to send funds or connect to an app To access all the networks you need to scan the card Scan your card or ring Enjoy %1$s service fees on swaps via Changelly from February %2$s-%3$s @@ -1509,6 +1510,7 @@ Hide KYC from main screen Add funds Top-up options + Add to Google Wallet Card Number Change PIN The card is fully ready for payments. diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt index 383047dd49..ecb96f61cf 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt @@ -63,7 +63,7 @@ fun TangemTopBar( }, endContent = if (endActions.isNotEmpty()) { { - Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) { + Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x5)) { endActions.forEach { action -> TangemTopBarActionContent(action) } @@ -225,7 +225,7 @@ private fun TangemTopBar_Preview(@PreviewParameter(PreviewProvider::class) param startContent = params.startActionUM?.let { { TangemTopBarActionContent(it) } }, endContent = if (params.endActions.isNotEmpty()) { { - Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) { + Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x5)) { params.endActions.forEach { action -> TangemTopBarActionContent(action) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBarActionUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBarActionUM.kt index 6857bffdbd..eb096a2d8f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBarActionUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBarActionUM.kt @@ -2,6 +2,7 @@ package com.tangem.core.ui.ds.topbar import androidx.annotation.DrawableRes import androidx.annotation.FloatRange +import androidx.compose.runtime.Immutable /** * User model for top bar action @@ -11,6 +12,7 @@ import androidx.annotation.FloatRange * @property onClick lambda be invoked when action component is clicked. If null, action will not be clickable * @property ghostModeProgress progress of ghost mode animation, from 0f to 1f. */ +@Immutable data class TangemTopBarActionUM( @param:DrawableRes val iconRes: Int, val isActionable: Boolean = true, diff --git a/core/ui/src/main/res/drawable/ic_qrcode_scaner_24.xml b/core/ui/src/main/res/drawable/ic_qrcode_scaner_24.xml new file mode 100644 index 0000000000..2e11d5915c --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_qrcode_scaner_24.xml @@ -0,0 +1,18 @@ + + + + diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/SourceType.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/SourceType.kt index 87be83fd0c..3f4e122ff6 100644 --- a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/SourceType.kt +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/SourceType.kt @@ -3,4 +3,5 @@ package com.tangem.domain.qrscanning.models enum class SourceType { WALLET_CONNECT, SEND, + MAIN_SCREEN, } \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt index 06faacb811..ca3528a5e9 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt @@ -22,6 +22,7 @@ internal class InitializeQrScanningStateTransformer( val message = when (source) { SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) } SourceType.WALLET_CONNECT -> resourceReference(R.string.wc_qr_scan_hint) + SourceType.MAIN_SCREEN -> resourceReference(R.string.main_qr_scan_hint) } return QrScanningState( @@ -44,6 +45,10 @@ internal class InitializeQrScanningStateTransformer( title = resourceReference(R.string.wc_new_connection), startIcon = R.drawable.ic_close_24, ) + SourceType.MAIN_SCREEN -> TopBarConfig( + title = null, + startIcon = R.drawable.ic_close_24, + ) } } 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 3264587566..5c65edb11b 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 @@ -43,6 +43,7 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +@Suppress("TooManyFunctions") internal interface WalletContentClickIntents { fun onDetailsClick() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt index e2eb07e356..7dd4faf316 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt @@ -82,8 +82,8 @@ internal fun WalletTopBar(config: WalletTopBarConfig) { }, actions = { config.endActions.forEach { action -> - if (action.onClick != null) { - IconButton(onClick = action.onClick!!) { + action.onClick?.let { onClick -> + IconButton(onClick = onClick) { Icon( painter = painterResource(id = action.iconRes), contentDescription = null, From a67ca6279f713eb79ebd4bae5e02a7b279bc2752 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 6 Mar 2026 10:59:48 +0400 Subject: [PATCH 05/60] Updated on 2026-08-14 --- .../com/tangem/tap/ApplicationEntryPoint.kt | 3 - .../java/com/tangem/tap/TangemApplication.kt | 5 - .../tangem/tap/di/domain/NFTDomainModule.kt | 3 - .../tap/proxy/redux/DaggerGraphState.kt | 2 - ...aultMultiWalletCryptoCurrenciesProducer.kt | 71 ---- ...MultiWalletCryptoCurrenciesProducerTest.kt | 378 ------------------ .../data/common/currency/UserTokensSaver.kt | 26 -- .../tangem/data/common/di/DataCommonModule.kt | 3 - .../common/currency/UserTokensSaverTest.kt | 83 ---- .../domain/nft/GetNFTNetworksUseCase.kt | 26 +- .../nft/common/DefaultNFTComponent.kt | 7 +- .../tangem/features/nft/common/NFTRoute.kt | 8 +- .../nft/receive/NFTReceiveComponent.kt | 4 +- .../nft/receive/model/NFTReceiveModel.kt | 19 +- 14 files changed, 24 insertions(+), 614 deletions(-) delete mode 100644 data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducer.kt delete mode 100644 data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducerTest.kt diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index ba01875acf..580686d592 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -23,7 +23,6 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.local.config.issuers.IssuersConfigStorage import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.apptheme.repository.AppThemeModeRepository @@ -138,8 +137,6 @@ interface ApplicationEntryPoint { fun getApiConfigsManager(): ApiConfigsManager - fun getUserTokensResponseStore(): UserTokensResponseStore - fun getUserWalletsListRepository(): UserWalletsListRepository fun getTangemHotSdk(): TangemHotSdk diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 8cf20b1b8a..8977b105fc 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -39,7 +39,6 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.local.config.issuers.IssuersConfigStorage import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.utils.NetworkLogsSaveInterceptor import com.tangem.datasource.utils.WireMockRedirectInterceptor import com.tangem.domain.appcurrency.repository.AppCurrencyRepository @@ -218,9 +217,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val apiConfigsManager: ApiConfigsManager get() = entryPoint.getApiConfigsManager() - private val userTokensResponseStore: UserTokensResponseStore - get() = entryPoint.getUserTokensResponseStore() - private val userWalletsListRepository get() = entryPoint.getUserWalletsListRepository() @@ -381,7 +377,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. settingsManager = settingsManager, uiMessageSender = uiMessageSender, coldUserWalletBuilderFactory = coldUserWalletBuilderFactory, - userTokensResponseStore = userTokensResponseStore, userWalletsListRepository = userWalletsListRepository, tangemHotSdk = tangemHotSdk, trackingContextProxy = trackingContextProxy, 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 4cbe877c01..1c40e0e8c3 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 @@ -8,7 +8,6 @@ import com.tangem.domain.nft.utils.NFTCleaner 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.repository.CurrenciesRepository import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -63,9 +62,7 @@ internal object NFTDomainModule { fun providesGetNFTAvailableNetworksUseCase( nftRepository: NFTRepository, singleAccountListSupplier: SingleAccountListSupplier, - currenciesRepository: CurrenciesRepository, ): GetNFTNetworksUseCase = GetNFTNetworksUseCase( - currenciesRepository = currenciesRepository, nftRepository = nftRepository, singleAccountListSupplier = singleAccountListSupplier, ) 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 d86379f650..861d425898 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 @@ -13,7 +13,6 @@ import com.tangem.data.card.TransactionSignerFactory import com.tangem.datasource.connection.NetworkConnectionManager import com.tangem.datasource.local.config.issuers.IssuersConfigStorage import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.apptheme.repository.AppThemeModeRepository import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository @@ -71,7 +70,6 @@ data class DaggerGraphState( val uiMessageSender: UiMessageSender? = null, val cardArworksProvider: CardArtworksProvider? = null, val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory? = null, - val userTokensResponseStore: UserTokensResponseStore? = null, val userWalletsListRepository: UserWalletsListRepository? = null, val tangemHotSdk: TangemHotSdk? = null, val trackingContextProxy: TrackingContextProxy? = null, diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducer.kt deleted file mode 100644 index 16fa1c9e44..0000000000 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducer.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.tangem.data.account.producer - -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.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.common.wallets.getSyncStrict -import com.tangem.domain.core.flow.FlowProducerTools -import com.tangem.domain.models.account.DerivationIndex -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.isMultiCurrency -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.* - -/** - * Default implementation of [MultiWalletCryptoCurrenciesProducer] - * - * @property params params - * @property flowProducerTools tools for producing flows - * @property userWalletsListRepository repository for getting user wallets - * @property userTokensResponseStore store of `UserTokensResponse` - * @property responseCryptoCurrenciesFactory factory for creating [CryptoCurrency] from `UserTokensResponse` - * @property dispatchers dispatchers - * -[REDACTED_AUTHOR] - */ -internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constructor( - @Assisted val params: MultiWalletCryptoCurrenciesProducer.Params, - override val flowProducerTools: FlowProducerTools, - private val userWalletsListRepository: UserWalletsListRepository, - private val userTokensResponseStore: UserTokensResponseStore, - private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, - private val dispatchers: CoroutineDispatcherProvider, -) : MultiWalletCryptoCurrenciesProducer { - - override val fallback: Option> = emptySet().some() - - override fun produce(): Flow> { - val userWallet = userWalletsListRepository.getSyncStrict(id = params.userWalletId) - - if (!userWallet.isMultiCurrency) { - error("${this::class.simpleName ?: this::class.toString()} supports only multi-currency wallet") - } - - return userTokensResponseStore.get(userWalletId = params.userWalletId) - .distinctUntilChanged() - .map { response -> - if (response == null) return@map emptySet() - - responseCryptoCurrenciesFactory.createCurrencies( - response = response, - userWallet = userWallet, - accountIndex = DerivationIndex.Main, - ).toSet() - } - .onEmpty { emit(emptySet()) } - .flowOn(dispatchers.default) - } - - @AssistedFactory - interface Factory : MultiWalletCryptoCurrenciesProducer.Factory { - override fun create( - params: MultiWalletCryptoCurrenciesProducer.Params, - ): DefaultMultiWalletCryptoCurrenciesProducer - } -} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducerTest.kt deleted file mode 100644 index d1df425b53..0000000000 --- a/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducerTest.kt +++ /dev/null @@ -1,378 +0,0 @@ -package com.tangem.data.account.producer - -import com.google.common.truth.Truth -import com.tangem.blockchain.common.Blockchain -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.data.common.currency.ResponseCryptoCurrenciesFactory -import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.datasource.local.token.UserTokensResponseStore -import com.tangem.domain.card.configs.GenericCardConfig -import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.core.flow.FlowProducerTools -import com.tangem.domain.models.account.DerivationIndex -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.isMultiCurrency -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer -import com.tangem.test.core.getEmittedValues -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.* -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance - -/** -[REDACTED_AUTHOR] - */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -internal class DefaultMultiWalletCryptoCurrenciesProducerTest { - - private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() - - private val params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWallet.walletId) - private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxUnitFun = true) - private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true) - private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk() - private val flowProducerTools: FlowProducerTools = mockk() - - private val producer = DefaultMultiWalletCryptoCurrenciesProducer( - params = params, - flowProducerTools = flowProducerTools, - userWalletsListRepository = userWalletsListRepository, - userTokensResponseStore = userTokensResponseStore, - responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, - dispatchers = TestingCoroutineDispatcherProvider(), - ) - - @BeforeEach - fun resetMocks() { - clearMocks(userWalletsListRepository, userTokensResponseStore, responseCryptoCurrenciesFactory) - } - - @Test - fun `flow is mapped for user wallet id from params`() = runTest { - // Arrange - val userTokensResponseFlow = flowOf(null) - - val userWalletsFlow = MutableStateFlow(listOf(userWallet)) - - every { userWalletsListRepository.userWallets } returns userWalletsFlow - every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow - - // Act - val actual = producer.produce().let(::getEmittedValues) - - // Assert - val expected = emptySet() - - Truth.assertThat(actual.size).isEqualTo(1) - Truth.assertThat(actual.first()).isEqualTo(expected) - - verifyOrder { - userWalletsListRepository.userWallets - userTokensResponseStore.get(params.userWalletId) - } - - verify(inverse = true) { - responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any()) - } - } - - @Test - fun `flow will updated if UserTokensResponse is updated`() = runTest { - // Arrange - val userTokensResponseFlow = MutableSharedFlow(replay = 2) - - val userTokensResponse = UserTokensResponse( - group = UserTokensResponse.GroupType.TOKEN, - sort = UserTokensResponse.SortType.MARKETCAP, - tokens = emptyList(), - ) - val cryptoCurrencies = emptySet() - - val updatedUserTokensResponse = UserTokensResponse( - group = UserTokensResponse.GroupType.TOKEN, - sort = UserTokensResponse.SortType.MARKETCAP, - tokens = listOf( - UserTokensResponse.Token( - id = null, - networkId = "bitcoin", - derivationPath = null, - name = "Bitcoin", - symbol = "BTC", - decimals = 8, - contractAddress = null, - addresses = listOf(), - ), - ), - ) - val updatedCryptoCurrencies = setOf( - cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin), - ) - - val userWalletsFlow = MutableStateFlow(listOf(userWallet)) - - every { userWalletsListRepository.userWallets } returns userWalletsFlow - every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow - - every { - responseCryptoCurrenciesFactory.createCurrencies( - response = userTokensResponse, - userWallet = userWallet, - accountIndex = DerivationIndex.Main, - ) - } returns cryptoCurrencies.toList() - - every { - responseCryptoCurrenciesFactory.createCurrencies( - response = updatedUserTokensResponse, - userWallet = userWallet, - accountIndex = DerivationIndex.Main, - ) - } returns updatedCryptoCurrencies.toList() - - val producerFlow = producer.produce() - - // Act 1 (first emit) - userTokensResponseFlow.emit(userTokensResponse) - - val actual1 = getEmittedValues(flow = producerFlow) - - // Assert - val expected1 = cryptoCurrencies - - Truth.assertThat(actual1.size).isEqualTo(1) - Truth.assertThat(actual1.first()).isEqualTo(expected1) - - verifyOrder { - userWalletsListRepository.userWallets - userTokensResponseStore.get(params.userWalletId) - responseCryptoCurrenciesFactory.createCurrencies( - response = userTokensResponse, - userWallet = userWallet, - accountIndex = DerivationIndex.Main, - ) - } - - // Act 2 (second emit) - userTokensResponseFlow.emit(updatedUserTokensResponse) - - val actual2 = getEmittedValues(flow = producerFlow) - - // Assert - val expected2 = listOf(cryptoCurrencies, updatedCryptoCurrencies) - - Truth.assertThat(actual2.size).isEqualTo(2) - Truth.assertThat(actual2).isEqualTo(expected2) - - verifyOrder { - responseCryptoCurrenciesFactory.createCurrencies( - response = updatedUserTokensResponse, - userWallet = userWallet, - accountIndex = DerivationIndex.Main, - ) - } - } - - @Test - fun `flow is filtered the same status`() = runTest { - // Arrange - val userTokensResponseFlow = MutableSharedFlow(replay = 2) - - val userTokensResponse = UserTokensResponse( - group = UserTokensResponse.GroupType.TOKEN, - sort = UserTokensResponse.SortType.MARKETCAP, - tokens = emptyList(), - ) - - val cryptoCurrencies = emptySet() - - val userWalletsFlow = MutableStateFlow(listOf(userWallet)) - - every { userWalletsListRepository.userWallets } returns userWalletsFlow - every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow - - every { - responseCryptoCurrenciesFactory.createCurrencies( - response = userTokensResponse, - userWallet = userWallet, - accountIndex = DerivationIndex.Main, - ) - } returns cryptoCurrencies.toList() - - val producerFlow = producer.produce() - - // Act 1 (first emit) - userTokensResponseFlow.emit(userTokensResponse) - - val actual1 = getEmittedValues(flow = producerFlow) - - // Assert - val expected1 = cryptoCurrencies - - Truth.assertThat(actual1.size).isEqualTo(1) - Truth.assertThat(actual1.first()).isEqualTo(expected1) - - verifyOrder { - userWalletsListRepository.userWallets - userTokensResponseStore.get(params.userWalletId) - responseCryptoCurrenciesFactory.createCurrencies( - response = userTokensResponse, - userWallet = userWallet, - accountIndex = DerivationIndex.Main, - ) - } - - // Act 2 (second emit) - userTokensResponseFlow.emit(userTokensResponse) - - val actual2 = getEmittedValues(flow = producerFlow) - - // Assert - val expected2 = expected1 - Truth.assertThat(actual2.size).isEqualTo(1) - Truth.assertThat(actual2.first()).isEqualTo(expected2) - } - - @Disabled - @Test - fun `flow throws exception`() = runTest { - // Arrange - val exception = IllegalStateException() - - val userTokensResponse = UserTokensResponse( - group = UserTokensResponse.GroupType.TOKEN, - sort = UserTokensResponse.SortType.MARKETCAP, - tokens = emptyList(), - ) - - val cryptoCurrencies = emptySet() - - val innerFlow = MutableStateFlow(value = false) - val userTokensResponseFlow = flow { - if (innerFlow.value) { - emit(userTokensResponse) - } else { - throw exception - } - } - .buffer(capacity = 5) - - val userWalletsFlow = MutableStateFlow(listOf(userWallet)) - - every { userWalletsListRepository.userWallets } returns userWalletsFlow - every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow - - every { - responseCryptoCurrenciesFactory.createCurrencies( - response = userTokensResponse, - userWallet = userWallet, - accountIndex = DerivationIndex.Main, - ) - } returns cryptoCurrencies.toList() - - val producerFlow = producer.produceWithFallback() - - // Act 1 (fallback) - val actual1 = getEmittedValues(flow = producerFlow) - - // Assert - val expected1 = producer.fallback.getOrNull() - Truth.assertThat(actual1.size).isEqualTo(1) - Truth.assertThat(actual1.first()).isEqualTo(expected1) - - verifyOrder { - userWalletsListRepository.userWallets - userTokensResponseStore.get(params.userWalletId) - } - - // Act 2 (emit) - innerFlow.emit(value = true) - val actual2 = getEmittedValues(flow = producerFlow) - - // Assert - val expected2 = cryptoCurrencies - Truth.assertThat(actual2.size).isEqualTo(1) - Truth.assertThat(actual2.first()).isEqualTo(expected2) - - verifyOrder { - responseCryptoCurrenciesFactory.createCurrencies( - response = userTokensResponse, - userWallet = userWallet, - accountIndex = DerivationIndex.Main, - ) - } - } - - @Test - fun `flow is empty if store returns empty flow`() = runTest { - // Arrange - val userWalletsFlow = MutableStateFlow(listOf(userWallet)) - - every { userWalletsListRepository.userWallets } returns userWalletsFlow - every { userTokensResponseStore.get(params.userWalletId) } returns emptyFlow() - - // Act - val actual = producer.produce().let(::getEmittedValues) - - // Assert - val expected = producer.fallback.getOrNull() - Truth.assertThat(actual.size).isEqualTo(1) - Truth.assertThat(actual.first()).isEqualTo(expected) - - verifyOrder { - userWalletsListRepository.userWallets - userTokensResponseStore.get(params.userWalletId) - } - - verify(inverse = true) { - responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any()) - } - } - - @Test - fun `produce throws exception if UserWallet isn't multi-currency wallet`() = runTest { - // Arrange - val mockUserWallet = mockk { - every { walletId } returns userWallet.walletId - every { isMultiCurrency } returns false - } - - val userWalletsFlow = MutableStateFlow(listOf(mockUserWallet)) - - every { userWalletsListRepository.userWallets } returns userWalletsFlow - - // Act - val actual = runCatching { producer.produce() }.exceptionOrNull() - - // Assert - val expected = IllegalStateException( - "${DefaultMultiWalletCryptoCurrenciesProducer::class.simpleName} supports only multi-currency wallet", - ) - - Truth.assertThat(actual).isInstanceOf(expected::class.java) - Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) - - verifyOrder { userWalletsListRepository.userWallets } - - verify(inverse = true) { - userTokensResponseStore.get(any()) - responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any()) - } - } - - private companion object { - - val scanResponse = MockScanResponseFactory.create( - cardConfig = GenericCardConfig(2), - derivedKeys = emptyMap(), - ) - - val userWallet = MockUserWalletFactory.create(scanResponse = scanResponse) - } -} \ No newline at end of file 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 a1ab6f180b..5796a28601 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 @@ -1,14 +1,12 @@ package com.tangem.data.common.currency import com.tangem.data.common.api.safeApiCall -import com.tangem.data.common.tokens.UserTokensBackwardCompatibility import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.isNetworkError 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.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.getSyncOrNull import com.tangem.domain.models.wallet.UserWallet @@ -23,31 +21,11 @@ import timber.log.Timber class UserTokensSaver( private val tangemTechApi: TangemTechApi, private val userWalletsListRepository: UserWalletsListRepository, - private val userTokensResponseStore: UserTokensResponseStore, private val dispatchers: CoroutineDispatcherProvider, private val addressesEnricher: UserTokensResponseAddressesEnricher, private val walletServerBinder: WalletServerBinder, private val pushTokensRetryerPool: RetryerPool, ) { - private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility() - - suspend fun storeAndPush(userWalletId: UserWalletId, response: UserTokensResponse) { - 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, @@ -111,10 +89,6 @@ class UserTokensSaver( ) } - private fun UserTokensResponse.applyCompatibility(): UserTokensResponse { - return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this) - } - private suspend fun UserTokensResponse.enrichIf( userWalletId: UserWalletId, condition: Boolean, 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 2d0f7de535..d30a846e5f 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 @@ -12,7 +12,6 @@ import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.walletmanager.WalletManagersFacade @@ -67,7 +66,6 @@ internal object DataCommonModule { fun provideUserTokensSaver( tangemTechApi: TangemTechApi, userWalletsListRepository: UserWalletsListRepository, - userTokensResponseStore: UserTokensResponseStore, dispatchers: CoroutineDispatcherProvider, addressesEnricher: UserTokensResponseAddressesEnricher, walletServerBinder: WalletServerBinder, @@ -75,7 +73,6 @@ internal object DataCommonModule { return UserTokensSaver( tangemTechApi = tangemTechApi, userWalletsListRepository = userWalletsListRepository, - userTokensResponseStore = userTokensResponseStore, dispatchers = dispatchers, addressesEnricher = addressesEnricher, pushTokensRetryerPool = RetryerPool( 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 4cc3f5aa9f..2a6546928a 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 @@ -6,7 +6,6 @@ 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.api.tangemTech.models.WalletType -import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -23,14 +22,12 @@ class UserTokensSaverTest { private val tangemTechApi: TangemTechApi = mockk() private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxUnitFun = true) - private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxed = true) private val enricher: UserTokensResponseAddressesEnricher = mockk() private val walletServerBinder: WalletServerBinder = mockk() private val userTokensSaver: UserTokensSaver = UserTokensSaver( tangemTechApi = tangemTechApi, userWalletsListRepository = userWalletsListRepository, - userTokensResponseStore = userTokensResponseStore, dispatchers = TestingCoroutineDispatcherProvider(), addressesEnricher = enricher, walletServerBinder = walletServerBinder, @@ -42,46 +39,11 @@ class UserTokensSaverTest { clearMocks( tangemTechApi, userWalletsListRepository, - userTokensResponseStore, enricher, walletServerBinder, ) } - @Test - fun `GIVEN user wallet id and response WHEN store THEN should store enriched response`() = runTest { - // GIVEN - val userWalletId = UserWalletId("1234567890abcdef") - val response = UserTokensResponse( - version = 0, - group = UserTokensResponse.GroupType.NETWORK, - sort = UserTokensResponse.SortType.BALANCE, - tokens = emptyList(), - ) - - val enrichedResponse = UserTokensResponse( - version = 0, - group = UserTokensResponse.GroupType.NETWORK, - sort = UserTokensResponse.SortType.MANUAL, - tokens = emptyList(), - ) - - coEvery { enricher(userWalletId, response) } returns enrichedResponse - - // WHEN - userTokensSaver.store(userWalletId, response) - - // THEN - coVerifyOrder { - enricher(userWalletId, response) - userTokensResponseStore.store(userWalletId, enrichedResponse) - } - - coVerify(inverse = true) { - tangemTechApi.saveTokens(any(), any()) - } - } - @Test fun `GIVEN user wallet id and response WHEN push AND api call fails THEN should log error and call onFailSend`() = runTest { @@ -133,49 +95,4 @@ class UserTokensSaverTest { assert(onFailSendCalled) { "onFailSend callback should be called when API call fails" } } - - @Test - fun `GIVEN user wallet id and response WHEN storeAndPush THEN should store and push enriched response`() = runTest { - // GIVEN - val userWalletId = UserWalletId("1234567890abcdef") - val userWallet = mockk { - every { this@mockk.walletId } returns userWalletId - every { this@mockk.name } returns "Wallet" - } - - val response = UserTokensResponse( - version = 0, - group = UserTokensResponse.GroupType.NETWORK, - sort = UserTokensResponse.SortType.BALANCE, - tokens = emptyList(), - walletName = userWallet.name, - walletType = WalletType.COLD, - ) - val enrichedResponse = UserTokensResponse( - version = 0, - group = UserTokensResponse.GroupType.NETWORK, - sort = UserTokensResponse.SortType.BALANCE, - tokens = emptyList(), - walletName = userWallet.name, - walletType = WalletType.COLD, - ) - - val userWalletsFlow = MutableStateFlow(listOf(userWallet)) - - every { userWalletsListRepository.userWallets } returns userWalletsFlow - coEvery { enricher(userWalletId, response) } returns enrichedResponse - coEvery { - tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse) - } returns ApiResponse.Success(Unit) - - // WHEN - userTokensSaver.storeAndPush(userWalletId, response) - - // THEN - coVerifyOrder { - enricher(userWalletId, response) - userWalletsListRepository.userWallets - tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse) - } - } } \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworksUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworksUseCase.kt index 3332f38965..cc4485938a 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworksUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworksUseCase.kt @@ -1,40 +1,30 @@ package com.tangem.domain.nft import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.models.NFTNetworks import com.tangem.domain.nft.repository.NFTRepository -import com.tangem.domain.tokens.repository.CurrenciesRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapNotNull class GetNFTNetworksUseCase( - private val currenciesRepository: CurrenciesRepository, private val singleAccountListSupplier: SingleAccountListSupplier, private val nftRepository: NFTRepository, ) { @OptIn(ExperimentalCoroutinesApi::class) - operator fun invoke(portfolioId: PortfolioId): Flow = when (portfolioId) { - is PortfolioId.Account -> { - singleAccountListSupplier(portfolioId.userWalletId) - .mapNotNull { accountList -> - val account = accountList.accounts.find { it.accountId == portfolioId.accountId } + operator fun invoke(accountId: AccountId): Flow { + return singleAccountListSupplier(accountId.userWalletId) + .mapNotNull { accountList -> + val account = accountList.accounts.find { it.accountId == accountId } - (account as? Account.CryptoPortfolio)?.cryptoCurrencies?.toList() - } - .mapLatest { it.toNFTNetworks(portfolioId.userWalletId) } - } - is PortfolioId.Wallet -> { - currenciesRepository - .getWalletCurrenciesUpdates(portfolioId.userWalletId) - .map { cryptoCurrencies -> cryptoCurrencies.toNFTNetworks(portfolioId.userWalletId) } - } + (account as? Account.CryptoPortfolio)?.cryptoCurrencies?.toList() + } + .mapLatest { it.toNFTNetworks(accountId.userWalletId) } } private suspend fun List.toNFTNetworks(userWalletId: UserWalletId): NFTNetworks { diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt index 0cbb637c1c..25d54e0d9f 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt @@ -20,7 +20,6 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.models.PortfolioId import com.tangem.features.account.PortfolioFetcher import com.tangem.features.account.PortfolioSelectorComponent import com.tangem.features.account.PortfolioSelectorController @@ -160,13 +159,13 @@ internal class DefaultNFTComponent @AssistedInject constructor( if (portfolioData.isSingleChoice) { val mainAccountId = portfolioData.balances.values.first() .accountsBalance.mainAccount.account.accountId - innerRouter.push(NFTRoute.Receive(portfolioId = PortfolioId(mainAccountId))) + innerRouter.push(NFTRoute.Receive(accountId = mainAccountId)) } else { bottomSheetNavigation.activate(Unit) val selectedAccountId = portfolioSelectorController.selectedAccount .filterNotNull().first() bottomSheetNavigation.dismiss() - innerRouter.push(NFTRoute.Receive(portfolioId = PortfolioId(selectedAccountId))) + innerRouter.push(NFTRoute.Receive(accountId = selectedAccountId)) } }.saveIn(onReceiveClickJob) @@ -176,7 +175,7 @@ internal class DefaultNFTComponent @AssistedInject constructor( ): ComposableContentComponent = NFTReceiveComponent( context = factoryContext, params = NFTReceiveComponent.Params( - portfolioId = route.portfolioId, + accountId = route.accountId, onBackClick = ::onChildBack, ), tokenReceiveComponentFactory = tokenReceiveComponentFactory, diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt index aa810f1b81..9333c371f4 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt @@ -1,10 +1,10 @@ package com.tangem.features.nft.common import com.tangem.core.decompose.navigation.Route -import com.tangem.domain.models.PortfolioId +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.nft.models.NFTCollection -import com.tangem.domain.models.wallet.UserWalletId import kotlinx.serialization.Serializable internal sealed class NFTRoute : Route { @@ -15,9 +15,7 @@ internal sealed class NFTRoute : Route { ) : NFTRoute() @Serializable - data class Receive( - val portfolioId: PortfolioId, - ) : NFTRoute() + data class Receive(val accountId: AccountId) : NFTRoute() @Serializable data class Details( diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/NFTReceiveComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/NFTReceiveComponent.kt index 560eee70b5..d4f36ccb62 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/NFTReceiveComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/NFTReceiveComponent.kt @@ -13,8 +13,8 @@ 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.ComposableContentComponent -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.account.AccountId import com.tangem.features.nft.receive.model.NFTReceiveModel import com.tangem.features.nft.receive.ui.NFTReceive import com.tangem.features.tokenreceive.TokenReceiveComponent @@ -57,7 +57,7 @@ internal class NFTReceiveComponent @AssistedInject constructor( ) data class Params( - val portfolioId: PortfolioId, + val accountId: AccountId, val onBackClick: () -> Unit, ) } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt index 6a32daa0a5..f96d50af08 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt @@ -18,7 +18,6 @@ import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.account.producer.SingleAccountProducer import com.tangem.domain.account.supplier.SingleAccountSupplier import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.network.Network @@ -88,14 +87,12 @@ internal class NFTReceiveModel @Inject constructor( } private fun loadPortfolioName() = modelScope.launch(dispatchers.default) { - val appBarSubtitle = when (val portfolioId = params.portfolioId) { - is PortfolioId.Wallet -> loadWalletName(portfolioId.userWalletId) - is PortfolioId.Account -> if (isAccountsModeEnabledUseCase.invokeSync()) { - loadAccountName(portfolioId.accountId) - } else { - loadWalletName(portfolioId.userWalletId) - } + val appBarSubtitle = if (isAccountsModeEnabledUseCase.invokeSync()) { + loadAccountName(params.accountId) + } else { + loadWalletName(params.accountId.userWalletId) } + _state.update { it.copy(appBarSubtitle = appBarSubtitle) } } @@ -121,7 +118,7 @@ internal class NFTReceiveModel @Inject constructor( private fun subscribeToNFTAvailableNetworks() { combine( - flow = getNFTNetworksUseCase(params.portfolioId), + flow = getNFTNetworksUseCase(params.accountId), flow2 = searchManager.query.distinctUntilChanged(), ) { networks, query -> filterNFTAvailableNetworksUseCase(networks, query) @@ -173,7 +170,7 @@ internal class NFTReceiveModel @Inject constructor( analyticsEventHandler.send(NFTAnalyticsEvent.Receive.BlockchainChosen(network.name)) val networkStatus = getNFTNetworkStatusUseCase.invoke( - userWalletId = params.portfolioId.userWalletId, + userWalletId = params.accountId.userWalletId, network = network, ) ?: return@launch @@ -198,7 +195,7 @@ internal class NFTReceiveModel @Inject constructor( private suspend fun configureReceiveAddresses(addresses: NetworkAddress, network: Network): TokenReceiveConfig { val cryptoCurrency = getNFTCurrencyUseCase.invoke(network) return receiveAddressesFactory.createForNft( - userWalletId = params.portfolioId.userWalletId, + userWalletId = params.accountId.userWalletId, addresses = addresses, network = network, nft = cryptoCurrency, From 47db65fdbebc211aca92c7f5e8f9ac2aa09844cc Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 6 Mar 2026 16:56:01 +0500 Subject: [PATCH 06/60] Updated on 2026-08-14 --- .../send/v2/send/DefaultSendComponent.kt | 27 +++++++--- .../features/send/v2/send/model/SendModel.kt | 51 +++++++++++++++++-- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt index 83fb037866..1cc1da5dce 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt @@ -1,8 +1,13 @@ package com.tangem.features.send.v2.send import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.extensions.compose.subscribeAsState @@ -21,8 +26,8 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.account.derivationIndex -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.send.v2.api.FeeSelectorBlockComponent import com.tangem.features.send.v2.api.SendComponent import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents @@ -193,12 +198,9 @@ internal class DefaultSendComponent @AssistedInject constructor( } private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableContentComponent { - val cryptoCurrencyStatus = model.cryptoCurrencyStatusFlow.value - val feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatusFlow.value - - return if (cryptoCurrencyStatus.value != CryptoCurrencyStatus.Loading && - feeCryptoCurrencyStatus.value != CryptoCurrencyStatus.Loading - ) { + return if (model.isAvailableForSend) { + val cryptoCurrencyStatus = model.cryptoCurrencyStatusFlow.value + val feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatusFlow.value SendConfirmComponent( appComponentContext = factoryContext, params = SendConfirmComponent.Params( @@ -280,6 +282,17 @@ internal class DefaultSendComponent @AssistedInject constructor( class StubComponent : ComposableContentComponent { @Composable override fun Content(modifier: Modifier) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + color = TangemTheme.colors.icon.primary1, + strokeWidth = TangemTheme.dimens.size2, + ) + } } } 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 180c7115a1..16449c2fc5 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 @@ -138,6 +138,22 @@ internal class SendModel @Inject constructor( ), ) + val isAvailableForSend: Boolean + get() { + val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value + val feeCryptoCurrencyStatus = feeCryptoCurrencyStatusFlow.value + + return cryptoCurrencyStatus.isAvailableForSend() && feeCryptoCurrencyStatus.isAvailableForSend() + } + + val isUnavailableForSend: Boolean + get() { + val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value + val feeCryptoCurrencyStatus = feeCryptoCurrencyStatusFlow.value + + return cryptoCurrencyStatus.isUnavailableForSend() || feeCryptoCurrencyStatus.isUnavailableForSend() + } + val accountFlow: StateFlow field = MutableStateFlow(null) val isAccountModeFlow: StateFlow @@ -153,6 +169,9 @@ internal class SendModel @Inject constructor( subscribeOnBalanceHidden() subscribeOnQRScannerResult() subscribeOnCurrencyStatusUpdates() + if (params.amount != null) { + subscribeOnStatusForDeeplinkDestination() + } initAppCurrency() initPredefinedValues() } @@ -363,10 +382,6 @@ internal class SendModel @Inject constructor( userWalletId = params.userWalletId, cryptoCurrencyStatus = cryptoCurrencyStatus, ).getOrNull() ?: cryptoCurrencyStatus - - if (params.amount != null) { - router.replaceAll(Confirm) - } }.flowOn(dispatchers.default) .launchIn(modelScope) }, @@ -379,6 +394,34 @@ internal class SendModel @Inject constructor( } } + private fun subscribeOnStatusForDeeplinkDestination() { + combine( + cryptoCurrencyStatusFlow, + feeCryptoCurrencyStatusFlow, + ) { cryptoCurrencyStatus, feeCryptoCurrencyStatus -> + if (isAvailableForSend && currentRoute.value == initialRoute) { + router.replaceAll(Confirm) + } else if (isUnavailableForSend) { + showAlertError() + } + }.launchIn(modelScope) + } + + private fun CryptoCurrencyStatus.hasAvailableStatus(): Boolean = this.value is CryptoCurrencyStatus.Loaded || + this.value is CryptoCurrencyStatus.Custom || + this.value is CryptoCurrencyStatus.NoQuote + + private fun CryptoCurrencyStatus.hasUnavailableStatus(): Boolean = this.value is CryptoCurrencyStatus.Unreachable || + this.value is CryptoCurrencyStatus.NoAmount || + this.value is CryptoCurrencyStatus.MissedDerivation || + this.value is CryptoCurrencyStatus.NoAccount + + private fun CryptoCurrencyStatus.isAvailableForSend(): Boolean = + this.hasAvailableStatus() && this.value.sources.networkSource.isActual() + + private fun CryptoCurrencyStatus.isUnavailableForSend(): Boolean = + this.hasUnavailableStatus() && this.value.sources.networkSource.isActual() + private fun subscribeOnBalanceHidden() { getBalanceHidingSettingsUseCase() .conflate() From 7a7e951f4fc6399c1b690e2f0ad725150b916c21 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 6 Mar 2026 12:02:03 +0000 Subject: [PATCH 07/60] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 42d7ab858c..5f2038f91c 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.35-1442" +tangemBlockchainSdk = "releases-5.34-1430" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.35-589" +tangemCardSdk = "develop-582" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 9f2b2eac718e781057dae9c44b67eb6a3c93ada7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 6 Mar 2026 17:11:59 +0300 Subject: [PATCH 08/60] Updated on 2026-08-14 --- .../com/tangem/tap/di/UiDependenciesModule.kt | 2 + .../com/tangem/tap/routing/RootContent.kt | 14 + core/ui/build.gradle.kts | 2 +- .../java/com/tangem/core/ui/UiDependencies.kt | 3 + .../ModalBottomSheetWithBackHandling.kt | 36 ++- .../modal/TangemModalBottomSheetWithFooter.kt | 1 + .../snackbar/CopiedTextSnackbarHost.kt | 1 + .../ui/components/snackbar/TangemSnackbar.kt | 1 + .../components/snackbar/TangemSnackbarHost.kt | 4 + .../components/snackbar/TangemTopSnackbar.kt | 180 ++++++++++++ .../snackbar/TangemTopSnackbarHost.kt | 274 ++++++++++++++++++ .../tangem/core/ui/message/EventMessage.kt | 7 + .../core/ui/message/EventMessageEffect.kt | 17 +- .../com/tangem/core/ui/res/TangemTheme.kt | 8 + .../referral/ui/ParticipateBottomBlock.kt | 23 +- .../feature/referral/ui/ReferralScreen.kt | 17 ++ .../ui/ContainerWithSnackbarHost.kt | 19 +- .../ui/TokenReceiveAssetsContent.kt | 41 ++- .../ui/TokenReceiveQrCodeContent.kt | 21 +- .../wallet/ui/WalletEventEffect.kt | 18 +- .../presentation/wallet/ui/WalletScreen2.kt | 41 +-- 21 files changed, 658 insertions(+), 72 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemTopSnackbar.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemTopSnackbarHost.kt diff --git a/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt b/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt index b5b3c5ea29..bf500d19e8 100644 --- a/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt @@ -6,6 +6,7 @@ import com.tangem.core.decompose.ui.DefaultUiMessageSender import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.UiDependencies +import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHostState import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.EventMessageHandler import com.tangem.core.ui.theme.AppThemeModeHolder @@ -30,6 +31,7 @@ internal object UiDependenciesModule { override val vibratorHapticManager = vibratorHapticManager override val appThemeModeHolder = appThemeModeHolder override val globalSnackbarHostState: SnackbarHostState = SnackbarHostState() + override val globalTopSnackbarHostState: TangemTopSnackbarHostState = TangemTopSnackbarHostState() override val eventMessageHandler: EventMessageHandler = EventMessageHandler() override val designFeatureToggles: DesignFeatureToggles = designFeatureToggles } diff --git a/app/src/main/java/com/tangem/tap/routing/RootContent.kt b/app/src/main/java/com/tangem/tap/routing/RootContent.kt index c21f7d2617..3c941cecd3 100644 --- a/app/src/main/java/com/tangem/tap/routing/RootContent.kt +++ b/app/src/main/java/com/tangem/tap/routing/RootContent.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -28,9 +29,12 @@ import com.arkivanov.essenty.backhandler.BackHandler import com.tangem.common.routing.AppRoute import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.components.snackbar.TangemSnackbarHost +import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHost import com.tangem.core.ui.message.EventMessageEffect +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.LocalRootBackgroundColor import com.tangem.core.ui.res.LocalSnackbarHostState +import com.tangem.core.ui.res.LocalTopSnackbarHostState import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.security.ProvideSecureFlagController import com.tangem.tap.routing.component.RoutingComponent @@ -93,6 +97,16 @@ internal fun RootContent( .padding(all = 16.dp), hostState = snackbarHostState, ) + + if (LocalRedesignEnabled.current) { + TangemTopSnackbarHost( + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(all = 16.dp), + hostState = LocalTopSnackbarHostState.current, + ) + } } } EventMessageEffect() diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index f9b0a562a0..9be1ed52f9 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -24,7 +24,7 @@ dependencies { /** Project - Core */ implementation(projects.core.res) implementation(projects.core.utils) - implementation(projects.core.decompose) + api(projects.core.decompose) implementation(projects.core.error) /** AndroidX libraries */ diff --git a/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt b/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt index 10c50f5742..84b5333435 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt @@ -2,6 +2,7 @@ package com.tangem.core.ui import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Stable +import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHostState import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.EventMessageHandler import com.tangem.core.ui.theme.AppThemeModeHolder @@ -15,6 +16,8 @@ interface UiDependencies { val globalSnackbarHostState: SnackbarHostState + val globalTopSnackbarHostState: TangemTopSnackbarHostState + val eventMessageHandler: EventMessageHandler val designFeatureToggles: DesignFeatureToggles diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt index 8b8f7d3fd4..a62e171f5f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt @@ -1,16 +1,27 @@ package com.tangem.core.ui.components.bottomsheets.internal import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.key.* import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHost +import com.tangem.core.ui.res.LocalHazeState +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.LocalTopSnackbarHostState +import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -31,6 +42,9 @@ fun ModalBottomSheetWithBackHandling( contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, content: @Composable ColumnScope.() -> Unit, ) { + val topSnackbarHostState = LocalTopSnackbarHostState.current + val isRedesignEnabled = LocalRedesignEnabled.current + ModalBottomSheet( onDismissRequest = onDismissRequest, modifier = modifier, @@ -47,7 +61,27 @@ fun ModalBottomSheetWithBackHandling( shouldDismissOnBackPress = onBack == null, ), content = { - content() + if (isRedesignEnabled) { + Box { + val hazeState = rememberHazeState() + + Column(Modifier.hazeSourceTangem(hazeState, zIndex = -1f)) { + content() + } + + CompositionLocalProvider(LocalHazeState provides hazeState) { + TangemTopSnackbarHost( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(horizontal = 16.dp) + .padding(top = 24.dp), + hostState = topSnackbarHostState, + ) + } + } + } else { + content() + } BackHandler(enabled = onBack != null && sheetState.targetValue != SheetValue.Hidden) { onBack?.invoke() 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 4f05ab4074..53d05fca82 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 @@ -167,6 +167,7 @@ inline fun BasicModalBottomSheetWit val model = config.content as? T ?: return val bsContent: @Composable ColumnScope.() -> Unit = { + // FIXME: Use LocalWindowSize.current val maxHeight = LocalConfiguration.current.screenHeightDp * MODAL_SHEET_MAX_HEIGHT val initial = 0 val scrollState = rememberScrollState(initial = initial) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/CopiedTextSnackbarHost.kt b/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/CopiedTextSnackbarHost.kt index 3cff3dd5c0..0cfe9b7b65 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/CopiedTextSnackbarHost.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/CopiedTextSnackbarHost.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier * [REDACTED_AUTHOR] */ +@Deprecated("Use TangemTopSnackbarHost instead. Will be removed with redesign") @Composable fun CopiedTextSnackbarHost(hostState: SnackbarHostState, modifier: Modifier = Modifier) { SnackbarHost(hostState = hostState, modifier = modifier) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemSnackbar.kt b/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemSnackbar.kt index 27603516a5..2ab7bcbf76 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemSnackbar.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemSnackbar.kt @@ -24,6 +24,7 @@ import com.tangem.core.ui.res.TangemThemePreview * [REDACTED_AUTHOR] */ +@Deprecated("With redesign, this component is no longer used. Please use both TangemSnackbar and TangemTopSnackbar") @Composable fun TangemSnackbar(data: SnackbarData, modifier: Modifier = Modifier, actionOnNewLine: Boolean = false) { Snackbar( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemSnackbarHost.kt b/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemSnackbarHost.kt index 6ad91b3281..5b1abbc198 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemSnackbarHost.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemSnackbarHost.kt @@ -27,6 +27,10 @@ import com.tangem.core.ui.res.TangemThemePreview * [REDACTED_AUTHOR] */ +@Deprecated( + "With redesign, this component is no longer used. " + + "Please use both TangemSnackbarHost and TangemTopSnackbarHost", +) @Composable fun TangemSnackbarHost( modifier: Modifier = Modifier, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemTopSnackbar.kt b/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemTopSnackbar.kt new file mode 100644 index 0000000000..a35b7a0e09 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemTopSnackbar.kt @@ -0,0 +1,180 @@ +package com.tangem.core.ui.components.snackbar + +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.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * Top snackbar with an optional leading icon and an optional action button. + * Intended to be used with [TangemTopSnackbarHost]. + * + * @param snackbarMessage message data + * @param modifier modifier + */ +@Suppress("LongMethod") +@Composable +fun TangemTopSnackbar(snackbarMessage: SnackbarMessage, modifier: Modifier = Modifier) { + val actionLabel = snackbarMessage.actionLabel + val action = snackbarMessage.action + val hasAction = actionLabel != null && action != null + + var isTextOverflowing by remember { mutableStateOf(false) } + val shape = if (isTextOverflowing) { + RoundedCornerShape(TangemTheme.dimens2.x5) + } else { + TangemTheme.shapes.roundedCornersXLarge + } + + Column( + modifier = modifier + .shadow(elevation = TangemTheme.dimens.elevation4, shape = shape, clip = false) + .background(color = TangemTheme.colors2.controls.backgroundDefault, shape = shape) + .clip(shape) + .hazeEffectTangem() + .sizeIn(minHeight = TangemTheme.dimens2.x11) + .padding(start = TangemTheme.dimens2.x5, end = TangemTheme.dimens2.x1) + .padding(vertical = TangemTheme.dimens2.x1), + verticalArrangement = Arrangement.Center, + ) { + Row( + modifier = Modifier.padding(top = if (isTextOverflowing) TangemTheme.dimens2.x3 else 0.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (snackbarMessage.startIconId != null) { + Icon( + painter = painterResource(id = snackbarMessage.startIconId), + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens2.x5), + tint = TangemTheme.colors2.graphic.neutral.secondary, + ) + + SpacerW(TangemTheme.dimens2.x2) + } + + Text( + text = snackbarMessage.message.resolveReference(), + modifier = Modifier.weight(1f, fill = false), + color = TangemTheme.colors.text.secondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = TangemTheme.typography.body2, + onTextLayout = { if (hasAction) isTextOverflowing = it.hasVisualOverflow }, + ) + + SpacerW(TangemTheme.dimens2.x4) + + if (hasAction && !isTextOverflowing) { + SecondaryTangemButton( + text = actionLabel, + onClick = action, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + ) + } + } + + if (hasAction && isTextOverflowing) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding( + top = TangemTheme.dimens2.x4, + bottom = TangemTheme.dimens2.x1, + ), + contentAlignment = Alignment.CenterEnd, + ) { + SecondaryTangemButton( + text = actionLabel, + onClick = action, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + ) + } + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_TangemTopSnackbar_WithAction() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier.padding(TangemTheme.dimens.spacing16), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + TangemTopSnackbar( + snackbarMessage = SnackbarMessage( + startIconId = R.drawable.ic_eye_off_outline_24, + message = stringReference("Balances hidden"), + actionLabel = stringReference("Undo"), + action = {}, + ), + ) + TangemTopSnackbar( + snackbarMessage = SnackbarMessage( + startIconId = R.drawable.ic_eye_off_outline_24, + message = stringReference( + "Balances hidden long long text that should be truncated with ellipsis at the end", + ), + actionLabel = stringReference("Undo"), + action = {}, + ), + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_TangemTopSnackbar_NoAction() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier.padding(TangemTheme.dimens.spacing16), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + TangemTopSnackbar( + snackbarMessage = SnackbarMessage( + startIconId = R.drawable.ic_check_24, + message = stringReference("Text copied to clipboard"), + ), + ) + TangemTopSnackbar( + snackbarMessage = SnackbarMessage( + message = stringReference("Operation completed"), + ), + ) + TangemTopSnackbar( + snackbarMessage = SnackbarMessage( + message = stringReference( + "Operation completed long long text that should be truncated with ellipsis at the end", + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemTopSnackbarHost.kt b/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemTopSnackbarHost.kt new file mode 100644 index 0000000000..dac23bfd15 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemTopSnackbarHost.kt @@ -0,0 +1,274 @@ +@file:Suppress("MagicNumber") +package com.tangem.core.ui.components.snackbar + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.AccessibilityManager +import androidx.compose.ui.platform.LocalAccessibilityManager +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.res.TangemTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.resume + +/** + * Snackbar host that displays a [TangemTopSnackbar] sliding and scaling in from the top of the screen. + * Typical placement: at the top of the screen's root [Box], above the main content. + * + * ``` + * Box(Modifier.fillMaxSize()) { + * MainContent() + * TangemTopSnackbarHost( + * hostState = topSnackbarHostState, + * modifier = Modifier + * .align(Alignment.TopCenter) + * .statusBarsPadding() + * .padding(top = TangemTheme.dimens.spacing8), + * ) + * } + * ``` + * + * @param hostState state that controls which snackbar is shown + * @param modifier modifier applied to the host container + */ +@Composable +fun TangemTopSnackbarHost(hostState: TangemTopSnackbarHostState, modifier: Modifier = Modifier) { + val myDepth = remember(hostState) { hostState.registerHost() } + DisposableEffect(hostState) { + onDispose { hostState.unregisterHost(myDepth) } + } + + // Only the deepest host in the composition tree handles the snackbar. + val isDeepest = hostState.activeHostDepth == myDepth + val currentSnackbar = if (isDeepest) hostState.currentSnackbar else null + val accessibilityManager = LocalAccessibilityManager.current + + LaunchedEffect(currentSnackbar) { + if (currentSnackbar == null) return@LaunchedEffect + val duration = currentSnackbar.duration.toMillis( + hasAction = currentSnackbar.action != null, + accessibilityManager = accessibilityManager, + ) + delay(duration) + currentSnackbar.onDismissRequest() + } + + ScaleFromTopWithFade( + current = currentSnackbar, + modifier = modifier, + ) { snackbar -> + TangemTopSnackbar( + snackbarMessage = snackbar, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + ) + } +} + +private fun SnackbarMessage.Duration.toMillis(hasAction: Boolean, accessibilityManager: AccessibilityManager?): Long { + val original = + when (this) { + SnackbarMessage.Duration.Indefinite -> Long.MAX_VALUE + SnackbarMessage.Duration.Long -> 10000L + SnackbarMessage.Duration.Short -> 4000L + } + if (accessibilityManager == null) { + return original + } + return accessibilityManager.calculateRecommendedTimeoutMillis( + originalTimeoutMillis = original, + containsIcons = true, + containsText = true, + containsControls = hasAction, + ) +} + +@Stable +class TangemTopSnackbarHostState { + + private val mutex = Mutex() + + var currentSnackbar by mutableStateOf(null) + + // Tracks which depth level is the deepest registered host. + // Composed as observable state so hosts recompose when a deeper/shallower one is added/removed. + var activeHostDepth by mutableIntStateOf(0) + private set + + private var hostDepthCounter = 0 + + internal fun registerHost(): Int { + hostDepthCounter++ + activeHostDepth = hostDepthCounter + return hostDepthCounter + } + + internal fun unregisterHost(depth: Int) { + if (depth == hostDepthCounter) { + hostDepthCounter-- + activeHostDepth = hostDepthCounter + } + } + + suspend fun showSnackbar( + message: String, + actionLabel: String? = null, + withDismissAction: Boolean = false, + duration: SnackbarMessage.Duration = + if (actionLabel == null) SnackbarMessage.Duration.Short else SnackbarMessage.Duration.Indefinite, + ) { + showSnackbar( + SnackbarMessage( + message = stringReference(message), + actionLabel = actionLabel?.let { stringReference(it) }, + duration = duration, + onDismissRequest = {}, + action = if (withDismissAction) { + null + } else { + {} + }, + ), + ) + } + + suspend fun showSnackbar(message: SnackbarMessage) { + mutex.withLock { + try { + suspendCancellableCoroutine { continuation -> + currentSnackbar = message.withDismiss( + onDismiss = { if (continuation.isActive) continuation.resume(Unit) }, + ) + } + } finally { + currentSnackbar = null + } + } + } +} + +private fun SnackbarMessage.withDismiss(onDismiss: () -> Unit): SnackbarMessage { + return copy( + onDismissRequest = { + onDismissRequest() + onDismiss() + }, + action = if (action != null) { + { + action.invoke() + onDismiss() + } + } else { + null + }, + ) +} + +// Adapted from Material3's FadeInFadeOutWithScale, with scale pivot anchored at the top center. +@Suppress("UnsafeCallOnNullableType") +@Composable +private fun ScaleFromTopWithFade( + current: SnackbarMessage?, + modifier: Modifier = Modifier, + content: @Composable (SnackbarMessage) -> Unit, +) { + val state = remember { ScaleFromTopState() } + if (current != state.current) { + state.current = current + val keys = state.items.map { it.key }.toMutableList() + if (!keys.contains(current)) keys.add(current) + state.items.clear() + keys.filterNotNull().mapTo(state.items) { key -> + ScaleFromTopItem(key) { children -> + val isVisible = key == current + val opacity = animatedOpacity( + animation = tween(durationMillis = 200), + visible = isVisible, + onAnimationFinish = { + if (key != state.current) { + state.items.removeAll { it.key == key } + state.scope?.invalidate() + } + }, + ) + val scale = animatedScale( + enterAnimation = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + exitAnimation = tween(durationMillis = 180), + visible = isVisible, + ) + Box( + Modifier.graphicsLayer( + scaleX = scale.value, + scaleY = scale.value, + alpha = opacity.value, + transformOrigin = TransformOrigin(pivotFractionX = 0.5f, pivotFractionY = 0f), + ), + ) { + children() + } + } + } + } + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { + state.scope = currentRecomposeScope + state.items.forEach { (item, transition) -> key(item) { transition { content(item!!) } } } + } +} + +@Suppress("DoubleMutabilityForCollection") +private class ScaleFromTopState { + var current: Any? = Any() + var items = mutableListOf>() + var scope: RecomposeScope? = null +} + +private data class ScaleFromTopItem( + val key: T, + val transition: @Composable (content: @Composable () -> Unit) -> Unit, +) + +@Composable +private fun animatedOpacity( + animation: AnimationSpec, + visible: Boolean, + onAnimationFinish: () -> Unit = {}, +): State { + val alpha = remember { Animatable(if (visible) 0f else 1f) } + LaunchedEffect(visible) { + alpha.animateTo(if (visible) 1f else 0f, animationSpec = animation) + onAnimationFinish() + } + return alpha.asState() +} + +@Composable +private fun animatedScale( + enterAnimation: AnimationSpec, + exitAnimation: AnimationSpec, + visible: Boolean, +): State { + val scale = remember { Animatable(if (visible) 0.85f else 1f) } + LaunchedEffect(visible) { + scale.animateTo( + targetValue = if (visible) 1f else 0.85f, + animationSpec = if (visible) enterAnimation else exitAnimation, + ) + } + return scale.asState() +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt index 3da6b245e1..b0131e2484 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt @@ -18,6 +18,7 @@ import com.tangem.core.ui.extensions.resourceReference @Immutable sealed interface EventMessage : UiMessage +@Immutable data class ToastMessage( val message: TextReference, val duration: Duration = ToastMessage.Duration.Short, @@ -48,8 +49,10 @@ data class ToastMessage( * @param actionLabel The label of the action button. Optional, `null` by default. * @param action The action to perform when the action button is clicked. Optional, `null` by default. * */ +@Immutable data class SnackbarMessage( val message: TextReference, + @field:DrawableRes val startIconId: Int? = null, val duration: Duration = Duration.Short, val onDismissRequest: () -> Unit = {}, val actionLabel: TextReference? = null, @@ -92,6 +95,7 @@ data class SnackbarMessage( * `true` by default. * @param onDismissRequest The action to perform when the dialog is dismissed. * */ +@Immutable data class DialogMessage( val message: TextReference, val title: TextReference? = null, @@ -146,6 +150,7 @@ data class DialogMessage( } } +@Immutable data class GlobalLoadingMessage(val isShow: Boolean) : EventMessage /** @@ -159,6 +164,7 @@ data class GlobalLoadingMessage(val isShow: Boolean) : EventMessage * @param secondAction The second action to perform. Optional, `null` by default. * @param onDismissRequest The action to perform when the bottom sheet is dismissed. * */ +@Immutable data class BottomSheetMessage( @DrawableRes val iconResId: Int? = null, val title: TextReference? = null, @@ -202,6 +208,7 @@ data class BottomSheetMessage( } } +@Immutable data class BottomSheetMessageV2( val messageBottomSheetUMV2: MessageBottomSheetUMV2, ) : EventMessage diff --git a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt index c7e40837a1..75d08a8dfc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt @@ -23,10 +23,13 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetV2 +import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHostState import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.LocalEventMessageHandler +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.LocalSnackbarHostState +import com.tangem.core.ui.res.LocalTopSnackbarHostState import com.tangem.core.ui.res.TangemTheme @Composable @@ -36,6 +39,10 @@ fun EventMessageEffect( onShowSnackbar: suspend (SnackbarMessage, Context) -> Unit = { message, context -> showSnackbar(snackbarHostState, message, context) }, + topSnackbarHostState: TangemTopSnackbarHostState = LocalTopSnackbarHostState.current, + onShowTopSnackbar: suspend (SnackbarMessage) -> Unit = { message -> + topSnackbarHostState.showSnackbar(message) + }, onShowToast: (ToastMessage, Context) -> Unit = { message, context -> showToast(message, context) }, ) { val messageEvent by messageHandler.collectAsState() @@ -45,11 +52,16 @@ fun EventMessageEffect( var bottomSheetMessage: BottomSheetMessage? by remember { mutableStateOf(value = null) } var bottomSheetMessageV2: BottomSheetMessageV2? by remember { mutableStateOf(value = null) } var loadingMessage: GlobalLoadingMessage? by remember { mutableStateOf(value = null) } + val isRedesignEnabled = LocalRedesignEnabled.current EventEffect(event = messageEvent) { message -> when (message) { is SnackbarMessage -> { - onShowSnackbar(message, context) + if (isRedesignEnabled) { + onShowTopSnackbar(message) + } else { + onShowSnackbar(message, context) + } } is DialogMessage -> { dialogMessage = message @@ -220,7 +232,8 @@ private fun showToast(message: ToastMessage, context: Context) { Toast.makeText( /* context = */ context, /* text = */ message.message.resolveReference(context.resources), - /* duration = */ when (message.duration) { + /* duration = */ + when (message.duration) { ToastMessage.Duration.Short -> Toast.LENGTH_SHORT ToastMessage.Duration.Long -> Toast.LENGTH_LONG }, diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index 829a8ff0e1..46b8751e71 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -20,6 +20,7 @@ import com.tangem.core.ui.components.SystemBarsIconsController import com.tangem.core.ui.components.TangemShimmer import com.tangem.core.ui.components.powersaving.PowerSavingState import com.tangem.core.ui.components.powersaving.rememberPowerSavingState +import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHostState import com.tangem.core.ui.components.text.BladeAnimation import com.tangem.core.ui.components.text.rememberBladeAnimation import com.tangem.core.ui.haptic.DefaultHapticManager @@ -54,6 +55,7 @@ fun TangemTheme( vibratorHapticManager = uiDependencies.vibratorHapticManager, snackbarHostState = uiDependencies.globalSnackbarHostState, eventMessageHandler = uiDependencies.eventMessageHandler, + topSnackbarHostState = uiDependencies.globalTopSnackbarHostState, overrideSystemBarColors = overrideSystemBarColors, typography = typography, dimens = dimens, @@ -76,6 +78,7 @@ fun TangemTheme( vibratorHapticManager: VibratorHapticManager? = null, eventMessageHandler: EventMessageHandler = remember { EventMessageHandler() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + topSnackbarHostState: TangemTopSnackbarHostState = remember { TangemTopSnackbarHostState() }, overrideSystemBarColors: Boolean = true, content: @Composable () -> Unit, ) { @@ -119,6 +122,7 @@ fun TangemTheme( LocalIsInDarkTheme provides isDark, LocalHapticManager provides hapticManager, LocalSnackbarHostState provides snackbarHostState, + LocalTopSnackbarHostState provides topSnackbarHostState, LocalEventMessageHandler provides eventMessageHandler, LocalWindowSize provides windowSize, LocalBladeAnimation provides rememberBladeAnimation(), @@ -389,6 +393,10 @@ val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") } +val LocalTopSnackbarHostState = staticCompositionLocalOf { + error("No TangemTopSnackbarHostState provided") +} + val LocalEventMessageHandler = staticCompositionLocalOf { error("No EventMessageHandler provided") } diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ParticipateBottomBlock.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ParticipateBottomBlock.kt index a9fb37777a..1795f345ba 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ParticipateBottomBlock.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ParticipateBottomBlock.kt @@ -33,8 +33,12 @@ import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.rows.RoundableCornersRow import com.tangem.core.ui.extensions.pluralStringResourceSafe 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.message.SnackbarMessage +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.LocalTopSnackbarHostState import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.feature.referral.domain.models.ExpectedAward @@ -352,6 +356,8 @@ private fun AdditionalButtons( val coroutineScope = rememberCoroutineScope() val resources = LocalContext.current.resources + val isRedesignEnabled = LocalRedesignEnabled.current + val tangemTopSnackbarHostState = LocalTopSnackbarHostState.current Row( modifier = Modifier.fillMaxWidth(), @@ -366,10 +372,19 @@ private fun AdditionalButtons( clipboardManager.setText(AnnotatedString(code)) coroutineScope.launch { - snackbarHostState.showSnackbar( - message = resources.getStringSafe(R.string.referral_promo_code_copied), - duration = SnackbarDuration.Short, - ) + if (isRedesignEnabled) { + tangemTopSnackbarHostState.showSnackbar( + SnackbarMessage( + startIconId = R.drawable.ic_check_24, + message = resourceReference(R.string.referral_promo_code_copied), + ), + ) + } else { + snackbarHostState.showSnackbar( + message = resources.getStringSafe(R.string.referral_promo_code_copied), + duration = SnackbarDuration.Short, + ) + } } }, modifier = Modifier.weight(1f), 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 76efe5d729..a9590eae22 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 @@ -45,6 +45,9 @@ 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.stringReference import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.LocalTopSnackbarHostState import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -104,10 +107,24 @@ internal fun ReferralScreen(stateHolder: ReferralStateHolder) { val errorSnackbar = stateHolder.errorSnackbar val coroutineScope = rememberCoroutineScope() val resources = LocalContext.current.resources + val isRedesignEnabled = LocalRedesignEnabled.current + val topSnackbarHostState = LocalTopSnackbarHostState.current SideEffect { if (errorSnackbar != null) { coroutineScope.launch { + if (isRedesignEnabled) { + topSnackbarHostState.showSnackbar( + message = resources.getMessageForErrorSnackbar(errorSnackbar.throwable), + actionLabel = resources.getStringSafe(R.string.warning_button_ok), + duration = SnackbarMessage.Duration.Indefinite, + ) + + errorSnackbar.onOkClicked() + + return@launch + } + val result = snackbarHostState.showSnackbar( message = resources.getMessageForErrorSnackbar(errorSnackbar.throwable), actionLabel = resources.getStringSafe(R.string.warning_button_ok), diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/ContainerWithSnackbarHost.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/ContainerWithSnackbarHost.kt index 94415b927d..c0258ad337 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/ContainerWithSnackbarHost.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/ContainerWithSnackbarHost.kt @@ -8,16 +8,23 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.snackbar.CopiedTextSnackbarHost +import com.tangem.core.ui.res.LocalRedesignEnabled +@Deprecated( + "Use TangemTopSnackbarHost instead. Will be removed with redesign", +) @Composable internal fun ContainerWithSnackbarHost(snackbarHostState: SnackbarHostState, content: @Composable () -> Unit) { Box { content() - CopiedTextSnackbarHost( - hostState = snackbarHostState, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 80.dp), - ) + + if (LocalRedesignEnabled.current.not()) { + CopiedTextSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 80.dp), + ) + } } } \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt index c18b1c9440..63202e22b0 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt @@ -46,6 +46,9 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.LocalTopSnackbarHostState import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.tokenreceive.entity.ReceiveAddress @@ -203,6 +206,8 @@ private fun AddressBlock(assetsUM: ReceiveAssetsUM, snackbarHostState: SnackbarH val coroutineScope = rememberCoroutineScope() val context = LocalContext.current val resources = context.resources + val isRedesignEnabled = LocalRedesignEnabled.current + val topSnackbarHostState = LocalTopSnackbarHostState.current assetsUM.addresses .fastFilter { it.type is ReceiveAddress.Type.Ens } @@ -214,11 +219,20 @@ private fun AddressBlock(assetsUM: ReceiveAssetsUM, snackbarHostState: SnackbarH assetsUM.onCopyClick(address) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) coroutineScope.launch { - snackbarHostState.showSnackbar( - message = resources.getStringSafe( - R.string.wallet_notification_address_copied, - ), - ) + if (isRedesignEnabled) { + topSnackbarHostState.showSnackbar( + SnackbarMessage( + startIconId = R.drawable.ic_check_24, + message = resourceReference(R.string.wallet_notification_address_copied), + ), + ) + } else { + snackbarHostState.showSnackbar( + message = resources.getStringSafe( + R.string.wallet_notification_address_copied, + ), + ) + } } }, address = address.value, @@ -413,6 +427,8 @@ private fun ButtonsBlock(snackbarHostState: SnackbarHostState, onCopyClick: () - val coroutineScope = rememberCoroutineScope() val context = LocalContext.current val resources = context.resources + val isRedesignEnabled = LocalRedesignEnabled.current + val topSnackbarHostState = LocalTopSnackbarHostState.current Row( modifier = Modifier.width(IntrinsicSize.Min), @@ -427,9 +443,18 @@ private fun ButtonsBlock(snackbarHostState: SnackbarHostState, onCopyClick: () - onCopyClick() hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) coroutineScope.launch { - snackbarHostState.showSnackbar( - message = resources.getStringSafe(R.string.wallet_notification_address_copied), - ) + if (isRedesignEnabled) { + topSnackbarHostState.showSnackbar( + SnackbarMessage( + startIconId = R.drawable.ic_check_24, + message = resourceReference(R.string.wallet_notification_address_copied), + ), + ) + } else { + snackbarHostState.showSnackbar( + message = resources.getStringSafe(R.string.wallet_notification_address_copied), + ) + } } }, ), diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt index 39b2363e21..0c4a083cec 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt @@ -31,6 +31,9 @@ import androidx.compose.ui.unit.dp import com.tangem.core.res.getStringSafe import com.tangem.core.ui.components.* import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.LocalTopSnackbarHostState import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TokenReceiveQrCodeBottomSheetTestTags @@ -147,6 +150,9 @@ private fun Buttons( val context = LocalContext.current val resources = context.resources + val isRedesignEnabled = LocalRedesignEnabled.current + val tangemTopSnackbarHostState = LocalTopSnackbarHostState.current + Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -159,9 +165,18 @@ private fun Buttons( hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) onCopyClick() coroutineScope.launch { - snackbarHostState.showSnackbar( - message = resources.getStringSafe(R.string.wallet_notification_address_copied), - ) + if (isRedesignEnabled) { + tangemTopSnackbarHostState.showSnackbar( + SnackbarMessage( + startIconId = R.drawable.ic_check_24, + message = resourceReference(R.string.wallet_notification_address_copied), + ), + ) + } else { + snackbarHostState.showSnackbar( + message = resources.getStringSafe(R.string.wallet_notification_address_copied), + ) + } } }, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletEventEffect.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletEventEffect.kt index 838acf9e43..480382ce94 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletEventEffect.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletEventEffect.kt @@ -10,6 +10,9 @@ import com.tangem.core.res.getStringSafe import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.res.LocalTopSnackbarHostState import com.tangem.core.ui.utils.requestPermission import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent @@ -69,18 +72,17 @@ internal fun WalletEventEffectLegacy( @Composable internal fun WalletEventEffect( walletsPagerState: PagerState, - snackbarHostState: SnackbarHostState, event: StateEvent, onCollapseBalance: () -> Unit, ) { - val resources = LocalContext.current.resources - var showPermissionRequest by remember { mutableStateOf Unit, () -> Unit>?>(null) } HandlePermissionRequest( permissionRequestParams = showPermissionRequest, onPermissionRequestResult = { showPermissionRequest = null }, ) + val tangemTopSnackbarHostState = LocalTopSnackbarHostState.current + EventEffect( event = event, onTrigger = { value -> @@ -92,12 +94,14 @@ internal fun WalletEventEffect( walletsPagerState.scrollToPage(page = value.newIndex) } is WalletEvent.ShowError -> { - snackbarHostState.showSnackbar(message = value.text.resolveReference(resources)) + tangemTopSnackbarHostState.showSnackbar(SnackbarMessage(message = value.text)) } is WalletEvent.CopyAddress -> { - snackbarHostState.showSnackbar( - message = resources.getStringSafe(R.string.wallet_notification_address_copied), - duration = SnackbarDuration.Short, + tangemTopSnackbarHostState.showSnackbar( + SnackbarMessage( + startIconId = R.drawable.ic_check_24, + message = resourceReference(R.string.wallet_notification_address_copied), + ), ) } is WalletEvent.DemonstrateWalletsScrollPreview -> { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index a874aec01e..6e0ea2edc8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -18,8 +18,6 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -47,18 +45,14 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.components.rememberIsKeyboardVisible import com.tangem.core.ui.components.sheetscaffold.* -import com.tangem.core.ui.components.snackbar.CopiedTextSnackbar -import com.tangem.core.ui.components.snackbar.TangemSnackbar import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingTopBar import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior -import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.res.* import com.tangem.feature.wallet.presentation.common.preview.WalletScreenPreviewData import com.tangem.feature.wallet.presentation.wallet.state.model.NOT_INITIALIZED_WALLET_INDEX import com.tangem.feature.wallet.presentation.wallet.state.model.WalletBalanceUM -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState import com.tangem.feature.wallet.presentation.wallet.ui.components.MarketsHint import com.tangem.feature.wallet.presentation.wallet.ui.components.MarketsTooltip @@ -85,7 +79,6 @@ internal fun WalletScreen2( val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() } - val snackbarHostState = remember(::SnackbarHostState) val walletsPagerState = rememberPagerState( initialPage = state.selectedWalletIndex, pageCount = { state.wallets2.size }, @@ -104,7 +97,6 @@ internal fun WalletScreen2( WalletContent2( state = state, walletsPagerState = walletsPagerState, - snackbarHostState = snackbarHostState, behavior = behavior, bottomSheetContent = bottomSheetContent, bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider, @@ -113,7 +105,6 @@ internal fun WalletScreen2( WalletEventEffect( walletsPagerState = walletsPagerState, - snackbarHostState = snackbarHostState, event = state.event, onCollapseBalance = { if (behavior.state.collapsedFraction < 1f) { @@ -131,7 +122,6 @@ private fun WalletContent2( state: WalletScreenState, walletsPagerState: PagerState, behavior: TangemCollapsingAppBarBehavior, - snackbarHostState: SnackbarHostState, bottomSheetHeaderHeightProvider: () -> Dp, onBottomSheetStateChange: (BottomSheetState) -> Unit, bottomSheetContent: @Composable (() -> Unit), @@ -143,7 +133,6 @@ private fun WalletContent2( BaseScaffoldWithMarkets( state = state, - snackbarHostState = snackbarHostState, bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider, onBottomSheetStateChange = onBottomSheetStateChange, bottomSheetContent = bottomSheetContent, @@ -278,7 +267,6 @@ private fun WalletContent2( @Composable private inline fun BaseScaffoldWithMarkets( state: WalletScreenState, - snackbarHostState: SnackbarHostState, bottomSheetHeaderHeightProvider: () -> Dp, modifier: Modifier = Modifier, noinline onBottomSheetStateChange: (BottomSheetState) -> Unit, @@ -290,10 +278,7 @@ private inline fun BaseScaffoldWithMarkets( val isKeyboardVisible by rememberIsKeyboardVisible() - val scaffoldState = rememberTangemBottomSheetScaffoldState( - bottomSheetState = bottomSheetState, - snackbarHostState = snackbarHostState, - ) + val scaffoldState = rememberTangemBottomSheetScaffoldState(bottomSheetState = bottomSheetState) val density = LocalDensity.current val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(density = this).toDp() } @@ -320,15 +305,6 @@ private inline fun BaseScaffoldWithMarkets( Box(modifier = modifier) { TangemBottomSheetScaffold( - snackbarHost = { snackbarHostState -> - WalletSnackbarHost( - snackbarHostState = snackbarHostState, - event = state.event, - modifier = Modifier - .padding(bottom = TangemTheme.dimens2.x1) - .navigationBarsPadding(), - ) - }, containerColor = Color.Unspecified, sheetContainerColor = backgroundColor.value, scaffoldState = scaffoldState, @@ -482,21 +458,6 @@ private fun BottomSheetStateEffects( } } -@Composable -private fun WalletSnackbarHost( - snackbarHostState: SnackbarHostState, - event: StateEvent, - modifier: Modifier = Modifier, -) { - SnackbarHost(hostState = snackbarHostState, modifier = modifier) { data -> - if (event is StateEvent.Triggered && event.data is WalletEvent.CopyAddress) { - CopiedTextSnackbar(data) - } else { - TangemSnackbar(data) - } - } -} - @Composable private fun rememberPageAlpha(pagerState: PagerState, currentPageIndex: Int): State { return remember { From 5463c87ff065e282f3d4fd9fdd5de2bc8152ce52 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 6 Mar 2026 11:13:07 +0400 Subject: [PATCH 09/60] Updated on 2026-08-14 --- .../tap/di/domain/ManageTokensDomainModule.kt | 14 ------ .../datasource/di/local/LocalTokenModule.kt | 21 -------- .../local/token/UserTokensResponseStore.kt | 22 --------- .../FetchWalletAccountsErrorHandler.kt | 4 +- .../store/LegacyUserTokensResponseStore.kt | 29 +++-------- .../FetchWalletAccountsErrorHandlerTest.kt | 4 +- .../DefaultCustomTokensRepository.kt | 24 --------- .../DefaultManageTokensRepository.kt | 49 ------------------- .../managetokens/di/ManageTokensDataModule.kt | 5 -- .../tangem/data/tokens/di/TokensDataModule.kt | 6 --- .../repository/DefaultCurrenciesRepository.kt | 47 ------------------ .../CheckHasLinkedTokensUseCase.kt | 28 ----------- .../CheckIsCurrencyNotAddedUseCase.kt | 25 ---------- .../repository/CustomTokensRepository.kt | 7 --- .../repository/ManageTokensRepository.kt | 8 --- .../tokens/repository/CurrenciesRepository.kt | 13 ----- 16 files changed, 11 insertions(+), 295 deletions(-) delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/di/local/LocalTokenModule.kt delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensResponseStore.kt rename core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserTokensResponseStore.kt => data/account/src/main/kotlin/com/tangem/data/account/store/LegacyUserTokensResponseStore.kt (50%) delete mode 100644 domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckHasLinkedTokensUseCase.kt delete mode 100644 domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckIsCurrencyNotAddedUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt index b527533fc1..2ab0eca738 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt @@ -40,14 +40,6 @@ internal object ManageTokensDomainModule { return FindTokenUseCase(customTokensRepository) } - @Provides - @Singleton - fun provideCheckIsCurrencyNotAddedUseCase( - customTokensRepository: CustomTokensRepository, - ): CheckIsCurrencyNotAddedUseCase { - return CheckIsCurrencyNotAddedUseCase(customTokensRepository) - } - @Provides @Singleton fun provideGetSupportedNetworksUseCase( @@ -64,12 +56,6 @@ internal object ManageTokensDomainModule { return ValidateDerivationPathUseCase(customTokensRepository) } - @Provides - @Singleton - fun provideCheckHasLinkedTokensUseCase(repository: ManageTokensRepository): CheckHasLinkedTokensUseCase { - return CheckHasLinkedTokensUseCase(repository) - } - @Provides @Singleton fun provideCheckCurrencyUnsupportedUseCase(repository: ManageTokensRepository): CheckCurrencyUnsupportedUseCase { diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/local/LocalTokenModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/local/LocalTokenModule.kt deleted file mode 100644 index 1165379d12..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/di/local/LocalTokenModule.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.datasource.di.local - -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.token.DefaultUserTokensResponseStore -import com.tangem.datasource.local.token.UserTokensResponseStore -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 LocalTokenModule { - - @Provides - @Singleton - fun provideUserTokensResponseStore(appPreferencesStore: AppPreferencesStore): UserTokensResponseStore { - return DefaultUserTokensResponseStore(appPreferencesStore = appPreferencesStore) - } -} \ 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 deleted file mode 100644 index 9da0c72837..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensResponseStore.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.datasource.local.token - -import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.domain.models.wallet.UserWalletId -import kotlinx.coroutines.flow.Flow - -/** - * Store of [UserTokensResponse] - * -[REDACTED_AUTHOR] - */ -interface UserTokensResponseStore { - - fun get(userWalletId: UserWalletId): Flow - - /** Get [UserTokensResponse] synchronously by [userWalletId] or null */ - 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/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt index 266d845b10..0e0b010f72 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -1,6 +1,7 @@ package com.tangem.data.account.fetcher import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher.FetchResult +import com.tangem.data.account.store.LegacyUserTokensResponseStore import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher import com.tangem.data.common.currency.UserTokensSaver @@ -14,7 +15,6 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse -import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.models.wallet.UserWalletId import timber.log.Timber import javax.inject.Inject @@ -35,7 +35,7 @@ import javax.inject.Singleton @Singleton internal class FetchWalletAccountsErrorHandler @Inject constructor( private val userTokensSaver: UserTokensSaver, - private val userTokensResponseStore: UserTokensResponseStore, + private val userTokensResponseStore: LegacyUserTokensResponseStore, private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory, private val walletServerBinder: WalletServerBinder, ) { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserTokensResponseStore.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/LegacyUserTokensResponseStore.kt similarity index 50% rename from core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserTokensResponseStore.kt rename to data/account/src/main/kotlin/com/tangem/data/account/store/LegacyUserTokensResponseStore.kt index 89f266e889..d289e36ca0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserTokensResponseStore.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/LegacyUserTokensResponseStore.kt @@ -1,45 +1,30 @@ -package com.tangem.datasource.local.token +package com.tangem.data.account.store import androidx.datastore.preferences.core.stringPreferencesKey import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.utils.getObject import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull -import com.tangem.datasource.local.preferences.utils.storeObject import com.tangem.domain.models.wallet.UserWalletId -import kotlinx.coroutines.flow.Flow +import javax.inject.Inject /** - * Default implementation of [UserTokensResponseStore] + * Legacy store for user tokens * * @property appPreferencesStore app preferences store * [REDACTED_AUTHOR] */ -internal class DefaultUserTokensResponseStore( +internal class LegacyUserTokensResponseStore @Inject constructor( private val appPreferencesStore: AppPreferencesStore, -) : UserTokensResponseStore { +) { - override fun get(userWalletId: UserWalletId): Flow { - return appPreferencesStore.getObject( - key = createPreferencesKey(userWalletId = userWalletId.stringValue), - ) - } - - override suspend fun getSyncOrNull(userWalletId: UserWalletId): UserTokensResponse? { + suspend fun getSyncOrNull(userWalletId: UserWalletId): UserTokensResponse? { return appPreferencesStore.getObjectSyncOrNull( key = createPreferencesKey(userWalletId = userWalletId.stringValue), ) } - override suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse) { - appPreferencesStore.storeObject( - key = createPreferencesKey(userWalletId = userWalletId.stringValue), - value = response, - ) - } - - override suspend fun clear(userWalletId: UserWalletId) { + suspend fun clear(userWalletId: UserWalletId) { appPreferencesStore.updateData { preferences -> val key = createPreferencesKey(userWalletId = userWalletId.stringValue) diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt index 80d6803bf8..7dff3d9df1 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt @@ -2,6 +2,7 @@ package com.tangem.data.account.fetcher import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO +import com.tangem.data.account.store.LegacyUserTokensResponseStore import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.common.currency.UserTokensSaver import com.tangem.data.common.wallet.WalletServerBinder @@ -14,7 +15,6 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse -import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.models.wallet.UserWalletId import io.mockk.clearMocks import io.mockk.coEvery @@ -34,7 +34,7 @@ class FetchWalletAccountsErrorHandlerTest { private val tangemTechApi: TangemTechApi = mockk() private val walletServerBinder: WalletServerBinder = mockk() private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) - private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true) + private val userTokensResponseStore: LegacyUserTokensResponseStore = mockk(relaxUnitFun = true) private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk() private val handler = FetchWalletAccountsErrorHandler( 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 492194a851..dcf575b79f 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 @@ -10,7 +10,6 @@ import com.tangem.data.common.network.NetworkFactory import com.tangem.data.managetokens.utils.TokenAddressesConverter import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.card.common.extensions.canHandleBlockchain import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains import com.tangem.domain.card.common.extensions.supportedBlockchains @@ -27,11 +26,9 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext -@Suppress("LongParameterList") internal class DefaultCustomTokensRepository( private val tangemTechApi: TangemTechApi, private val userWalletsListRepository: UserWalletsListRepository, - private val userTokensResponseStore: UserTokensResponseStore, private val excludedBlockchains: ExcludedBlockchains, private val dispatchers: CoroutineDispatcherProvider, private val networkFactory: NetworkFactory, @@ -64,27 +61,6 @@ internal class DefaultCustomTokensRepository( } } - override suspend fun isCurrencyNotAdded( - userWalletId: UserWalletId, - networkId: Network.ID, - derivationPath: Network.DerivationPath, - contractAddress: String?, - ): Boolean { - return withContext(dispatchers.io) { - val storedCurrencies = userTokensResponseStore.getSyncOrNull(userWalletId) - - requireNotNull(storedCurrencies) { - "User tokens not found for user wallet [$userWalletId] while checking if currency is not added" - } - - storedCurrencies.tokens.none { token -> - networkId.toBlockchain().toNetworkId() == token.networkId && - derivationPath.value == token.derivationPath && - contractAddress.equals(token.contractAddress, ignoreCase = true) - } - } - } - override suspend fun findToken( userWalletId: UserWalletId, contractAddress: String, 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 28b44ce20f..d053300248 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 @@ -17,7 +17,6 @@ import com.tangem.datasource.api.tangemTech.models.CoinsResponse import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.orDefault import com.tangem.datasource.local.config.testnet.TestnetTokensStorage -import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.card.common.TapWorkarounds.isTestCard import com.tangem.domain.card.common.extensions.* import com.tangem.domain.card.common.util.cardTypesResolver @@ -28,7 +27,6 @@ import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork import com.tangem.domain.managetokens.repository.ManageTokensRepository import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.DerivationIndex -import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.pagination.BatchFetchResult @@ -44,7 +42,6 @@ internal class DefaultManageTokensRepository( private val tangemTechApi: TangemTechApi, private val userWalletsListRepository: UserWalletsListRepository, private val manageTokensUpdateFetcher: ManageTokensUpdateFetcher, - private val userTokensResponseStore: UserTokensResponseStore, private val testnetTokensStorage: TestnetTokensStorage, private val excludedBlockchains: ExcludedBlockchains, private val dispatchers: CoroutineDispatcherProvider, @@ -280,52 +277,6 @@ internal class DefaultManageTokensRepository( } // endregion - override suspend fun hasLinkedTokens( - userWalletId: UserWalletId, - network: Network, - tempAddedTokens: Map>, - tempRemovedTokens: Map>, - ): Boolean { - val addedTokens = tempAddedTokens.mapToResponseTokens() - val removedTokens = tempRemovedTokens.mapToResponseTokens() - - val storedTokens = requireNotNull( - value = getSavedUserTokensResponseSync(userWalletId), - lazyMessage = { "Unable to find tokens response for user wallet with provided ID: $userWalletId" }, - ) - val newTokensList = storedTokens.tokens + addedTokens - removedTokens.toSet() - - return newTokensList.any { token -> - token.contractAddress != null && - token.networkId == network.backendId && - token.derivationPath == network.derivationPath.value - } - } - - private fun Map>.mapToResponseTokens(): List { - return flatMap { (token, networks) -> - token.availableNetworks - .filter { sourceNetwork -> networks.contains(sourceNetwork.network) } - .map { sourceNetwork -> - val networkId = sourceNetwork.network.toBlockchain().toNetworkId() - - UserTokensResponse.Token( - id = token.id.value, - networkId = networkId, - derivationPath = sourceNetwork.network.derivationPath.value, - name = token.name, - symbol = token.symbol, - decimals = sourceNetwork.decimals, - contractAddress = (sourceNetwork as? SourceNetwork.Default)?.contractAddress, - ) - } - } - } - - private suspend fun getSavedUserTokensResponseSync(key: UserWalletId): UserTokensResponse? { - return userTokensResponseStore.getSyncOrNull(userWalletId = key) - } - override suspend fun checkCurrencyUnsupportedState( userWalletId: UserWalletId, sourceNetwork: SourceNetwork, diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt index 01922b4996..2df7ecce2c 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt @@ -8,7 +8,6 @@ import com.tangem.data.managetokens.DefaultManageTokensRepository import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.config.testnet.TestnetTokensStorage -import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.managetokens.repository.CustomTokensRepository import com.tangem.domain.managetokens.repository.ManageTokensRepository @@ -29,7 +28,6 @@ internal object ManageTokensDataModule { tangemTechApi: TangemTechApi, userWalletsListRepository: UserWalletsListRepository, manageTokensUpdateFetcher: ManageTokensUpdateFetcher, - userTokensResponseStore: UserTokensResponseStore, testnetTokensStorage: TestnetTokensStorage, dispatchers: CoroutineDispatcherProvider, excludedBlockchains: ExcludedBlockchains, @@ -40,7 +38,6 @@ internal object ManageTokensDataModule { tangemTechApi = tangemTechApi, userWalletsListRepository = userWalletsListRepository, manageTokensUpdateFetcher = manageTokensUpdateFetcher, - userTokensResponseStore = userTokensResponseStore, testnetTokensStorage = testnetTokensStorage, excludedBlockchains = excludedBlockchains, networkFactory = networkFactory, @@ -54,7 +51,6 @@ internal object ManageTokensDataModule { fun provideCustomTokensRepository( tangemTechApi: TangemTechApi, userWalletsListRepository: UserWalletsListRepository, - userTokensResponseStore: UserTokensResponseStore, dispatchers: CoroutineDispatcherProvider, excludedBlockchains: ExcludedBlockchains, networkFactory: NetworkFactory, @@ -62,7 +58,6 @@ internal object ManageTokensDataModule { return DefaultCustomTokensRepository( tangemTechApi = tangemTechApi, userWalletsListRepository = userWalletsListRepository, - userTokensResponseStore = userTokensResponseStore, excludedBlockchains = excludedBlockchains, dispatchers = dispatchers, networkFactory = networkFactory, diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt index 2f577c7b2b..e0630ac246 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt @@ -3,7 +3,6 @@ package com.tangem.data.tokens.di import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.common.currency.CardCryptoCurrencyFactory -import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.tokens.repository.DefaultCurrenciesRepository import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository import com.tangem.data.tokens.repository.DefaultTokenReceiveWarningsViewedRepository @@ -11,7 +10,6 @@ import com.tangem.data.tokens.repository.DefaultYieldSupplyWarningsViewedReposit import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.TokenReceiveWarningActionStore -import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier @@ -35,7 +33,6 @@ internal object TokensDataModule { @Singleton fun provideCurrenciesRepository( tangemTechApi: TangemTechApi, - userTokensResponseStore: UserTokensResponseStore, userWalletsListRepository: UserWalletsListRepository, walletManagersFacade: WalletManagersFacade, cacheRegistry: CacheRegistry, @@ -43,7 +40,6 @@ internal object TokensDataModule { expressServiceFetcher: ExpressServiceFetcher, excludedBlockchains: ExcludedBlockchains, cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, - responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, ): CurrenciesRepository { return DefaultCurrenciesRepository( @@ -51,12 +47,10 @@ internal object TokensDataModule { userWalletsListRepository = userWalletsListRepository, walletManagersFacade = walletManagersFacade, cacheRegistry = cacheRegistry, - userTokensResponseStore = userTokensResponseStore, expressServiceFetcher = expressServiceFetcher, dispatchers = dispatchers, excludedBlockchains = excludedBlockchains, cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, - responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, ) } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index 952b4aa680..9e5a67df59 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -8,12 +8,9 @@ import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.common.currency.CardCryptoCurrencyFactory import com.tangem.data.common.currency.CryptoCurrencyFactory -import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.common.currency.getTokenId import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -21,7 +18,6 @@ import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.core.error.DataError import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.express.models.ExpressAsset -import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -36,9 +32,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import kotlinx.coroutines.plus import kotlinx.coroutines.withContext import timber.log.Timber import com.tangem.blockchain.common.FeePaidCurrency as FeePaidSdkCurrency @@ -52,28 +46,12 @@ internal class DefaultCurrenciesRepository( private val expressServiceFetcher: ExpressServiceFetcher, private val dispatchers: CoroutineDispatcherProvider, private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, - private val userTokensResponseStore: UserTokensResponseStore, - private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, excludedBlockchains: ExcludedBlockchains, ) : CurrenciesRepository { private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains) - override fun getWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow> { - return channelFlow { - val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) - - if (userWallet.isMultiCurrency) { - getMultiCurrencyWalletCurrenciesUpdates(userWalletId).collect(::send) - } else { - val currencies = getSingleCurrencyWalletWithCardCurrencies(userWalletId) - - send(currencies) - } - } - } - override suspend fun getSingleCurrencyWalletPrimaryCurrency( userWalletId: UserWalletId, refresh: Boolean, @@ -143,17 +121,6 @@ internal class DefaultCurrenciesRepository( } } - private fun getMultiCurrencyWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow> { - return channelFlow { - val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) - ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true) - - getMultiCurrencyWalletCurrencies(userWallet) - .onEach { send(it) } - .launchIn(scope = this + dispatchers.io) - } - } - override suspend fun getNetworkCoin( userWalletId: UserWalletId, networkId: Network.ID, @@ -281,16 +248,6 @@ internal class DefaultCurrenciesRepository( return (userWalletsListRepository.getSyncStrict(userWalletId) as? UserWallet.Cold)?.cardTypesResolver } - private fun getMultiCurrencyWalletCurrencies(userWallet: UserWallet): Flow> { - return getSavedUserTokensResponse(userWallet.walletId).map { storedTokens -> - responseCryptoCurrenciesFactory.createCurrencies( - response = storedTokens, - userWallet = userWallet, - accountIndex = DerivationIndex.Main, - ) - } - } - private suspend fun fetchExpressAssetsByNetworkIds( userWallet: UserWallet, cryptoCurrencies: List, @@ -338,8 +295,4 @@ internal class DefaultCurrenciesRepository( throw error } } - - private fun getSavedUserTokensResponse(key: UserWalletId): Flow { - return userTokensResponseStore.get(userWalletId = key).filterNotNull() - } } \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckHasLinkedTokensUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckHasLinkedTokensUseCase.kt deleted file mode 100644 index f839f26922..0000000000 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckHasLinkedTokensUseCase.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.domain.managetokens - -import arrow.core.Either -import com.tangem.domain.managetokens.model.ManagedCryptoCurrency -import com.tangem.domain.managetokens.repository.ManageTokensRepository -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId - -class CheckHasLinkedTokensUseCase( - private val repository: ManageTokensRepository, -) { - - suspend operator fun invoke( - userWalletId: UserWalletId, - network: Network, - tempAddedTokens: Map>, - tempRemovedTokens: Map>, - ): Either { - return Either.catch { - repository.hasLinkedTokens( - userWalletId = userWalletId, - network = network, - tempAddedTokens = tempAddedTokens, - tempRemovedTokens = tempRemovedTokens, - ) - } - } -} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckIsCurrencyNotAddedUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckIsCurrencyNotAddedUseCase.kt deleted file mode 100644 index 51b7543912..0000000000 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckIsCurrencyNotAddedUseCase.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.domain.managetokens - -import arrow.core.Either -import com.tangem.domain.managetokens.repository.CustomTokensRepository -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId - -class CheckIsCurrencyNotAddedUseCase( - private val repository: CustomTokensRepository, -) { - - suspend operator fun invoke( - userWalletId: UserWalletId, - networkId: Network.ID, - derivationPath: Network.DerivationPath, - contractAddress: String?, - ): Either = Either.catch { - repository.isCurrencyNotAdded( - userWalletId = userWalletId, - networkId = networkId, - derivationPath = derivationPath, - contractAddress = contractAddress, - ) - } -} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt index 219d138628..3d27222db6 100644 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt @@ -10,13 +10,6 @@ interface CustomTokensRepository { suspend fun validateContractAddress(contractAddress: String, networkId: Network.ID): Boolean - suspend fun isCurrencyNotAdded( - userWalletId: UserWalletId, - networkId: Network.ID, - derivationPath: Network.DerivationPath, - contractAddress: String?, - ): Boolean - suspend fun findToken( userWalletId: UserWalletId, contractAddress: String, diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/ManageTokensRepository.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/ManageTokensRepository.kt index 981bdac9b1..30dda9680b 100644 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/ManageTokensRepository.kt +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/ManageTokensRepository.kt @@ -4,7 +4,6 @@ import com.tangem.domain.managetokens.model.CurrencyUnsupportedState import com.tangem.domain.managetokens.model.ManageTokensListBatchFlow import com.tangem.domain.managetokens.model.ManageTokensListBatchingContext import com.tangem.domain.managetokens.model.ManagedCryptoCurrency -import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId interface ManageTokensRepository { @@ -15,13 +14,6 @@ interface ManageTokensRepository { batchSize: Int, ): ManageTokensListBatchFlow - suspend fun hasLinkedTokens( - userWalletId: UserWalletId, - network: Network, - tempAddedTokens: Map>, - tempRemovedTokens: Map>, - ): Boolean - suspend fun checkCurrencyUnsupportedState( userWalletId: UserWalletId, sourceNetwork: ManagedCryptoCurrency.SourceNetwork, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt index c830032607..a7d60f9374 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt @@ -7,7 +7,6 @@ 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.tokens.model.FeePaidCurrency -import kotlinx.coroutines.flow.Flow /** * Repository for everything related to the tokens of user wallet @@ -15,18 +14,6 @@ import kotlinx.coroutines.flow.Flow @Suppress("TooManyFunctions") interface CurrenciesRepository { - /** - * Retrieves the list of cryptocurrencies within a user wallet. - * - * This method returns a list of cryptocurrencies associated with the user wallet regardless of whether - * it is a multi-currency or single-currency wallet. - * - * @param userWalletId The unique identifier of the user wallet. - * @return A list of [CryptoCurrency]. - */ - @Deprecated("Use MultiWalletCryptoCurrenciesSupplier") - fun getWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow> - /** * Retrieves the primary cryptocurrency for a specific single-currency user wallet. * From 37a31042c0b5237bfdaf1246b255ee55309ee483 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 6 Mar 2026 15:38:09 +0400 Subject: [PATCH 10/60] Updated on 2026-08-14 --- .../utils/AccountCryptoCurrencyOperations.kt | 70 ++++ .../AccountCryptoCurrencyStatusFinder.kt | 203 +++++++---- .../AccountCryptoCurrencyStatusOperations.kt | 77 +++++ .../status/utils/CryptoCurrencyOperations.kt | 78 +++++ .../utils/CryptoCurrencyStatusOperations.kt | 89 +++++ .../GetAccountCurrencyStatusUseCaseTest.kt | 120 +++++-- .../AccountCryptoCurrencyOperationsTest.kt | 169 +++++++++ ...countCryptoCurrencyStatusOperationsTest.kt | 205 +++++++++++ .../utils/CryptoCurrencyOperationsTest.kt | 207 +++++++++++ .../CryptoCurrencyStatusOperationsTest.kt | 321 ++++++++++++++++++ 10 files changed, 1442 insertions(+), 97 deletions(-) create mode 100644 domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyOperations.kt create mode 100644 domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusOperations.kt create mode 100644 domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyOperations.kt create mode 100644 domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperations.kt create mode 100644 domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/AccountCryptoCurrencyOperationsTest.kt create mode 100644 domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusOperationsTest.kt create mode 100644 domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyOperationsTest.kt create mode 100644 domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperationsTest.kt diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyOperations.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyOperations.kt new file mode 100644 index 0000000000..c367b214df --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyOperations.kt @@ -0,0 +1,70 @@ +package com.tangem.domain.account.status.utils + +import arrow.core.Option +import arrow.core.raise.option +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.status.model.AccountCryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network + +/** + * Extension functions for retrieving [AccountCryptoCurrency] from an [AccountList]. + * + * This object provides convenient extension functions to search and retrieve [AccountCryptoCurrency] instances + * from an [AccountList] using various parameters such as cryptocurrency ID and network. + * + * Usage example: + * ``` + * val accountList: AccountList? = ... + * val result: Option = accountList.getAccountCryptoCurrency(currency) + * result.fold( + * ifEmpty = { /* handle not found */ }, + * ifSome = { accountCryptoCurrency -> /* use the found result */ } + * ) + * ``` + * + * @see AccountCryptoCurrency + * @see AccountList +[REDACTED_AUTHOR] + */ +object AccountCryptoCurrencyOperations { + + // region AccountList + + /** + * Retrieves the [AccountCryptoCurrency] for the specified [currency] from this [AccountList]. + * + * @receiver the [AccountList] to search within, can be null + * @param currency the cryptocurrency whose account association is to be retrieved + * @return [Option] containing the [AccountCryptoCurrency] if found, or [Option.None] otherwise + */ + fun AccountList?.getAccountCryptoCurrency(currency: CryptoCurrency): Option { + return getAccountCryptoCurrency(currencyId = currency.id, network = currency.network) + } + + /** + * Retrieves the [AccountCryptoCurrency] for the specified [currencyId] and [network] from this [AccountList]. + * + * @receiver the [AccountList] to search within, can be null + * @param currencyId the ID of the cryptocurrency whose account association is to be retrieved + * @param network the network associated with the cryptocurrency, can be null + * @return [Option] containing the [AccountCryptoCurrency] if found, or [Option.None] otherwise + */ + fun AccountList?.getAccountCryptoCurrency( + currencyId: CryptoCurrency.ID, + network: Network?, + ): Option = option { + val accountList = this@getAccountCryptoCurrency + + ensureNotNull(accountList) + + val accountCryptoCurrency = AccountCryptoCurrencyStatusFinder( + accountList = accountList, + currencyId = currencyId, + network = network, + ) + + ensureNotNull(accountCryptoCurrency) + } + // endregion +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt index fe5daf41d0..22033e6c37 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt @@ -1,7 +1,9 @@ package com.tangem.domain.account.status.utils import com.tangem.blockchain.common.Blockchain +import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.model.AccountCryptoCurrency import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatuses import com.tangem.domain.models.account.Account @@ -9,16 +11,23 @@ import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.lib.crypto.derivation.AccountNodeRecognizer /** - * Finds the status of a specific cryptocurrency associated with an account from the provided account status list. + * Finds a specific cryptocurrency or its status associated with an account from the provided account list. + * + * The base implementation uses [AccountList] for searching. For [AccountStatusList], it first converts + * to [AccountList], finds the [AccountCryptoCurrency], then retrieves the status using accountId and currencyId. * [REDACTED_AUTHOR] */ +@Suppress("MethodOverloading") internal object AccountCryptoCurrencyStatusFinder { + // region AccountCryptoCurrencyStatus methods + /** * Retrieves the [AccountCryptoCurrencyStatus] for the specified [currency] from the given [accountStatusList]. * @@ -48,25 +57,19 @@ internal object AccountCryptoCurrencyStatusFinder { currencyId: CryptoCurrency.ID, network: Network?, ): AccountCryptoCurrencyStatus? { - return accountStatusList.getExpectedAccountStatuses(network) - .asSequence() - .filterCryptoPortfolio() - .mapNotNull { accountStatus -> - val status = accountStatus.flattenCurrencies().firstOrNull { it.currency.id == currencyId } - ?: return@mapNotNull null + val accountList = accountStatusList.toAccountList().getOrNull() ?: return null + val accountCurrency = invoke(accountList, currencyId, network) ?: return null - AccountCryptoCurrencyStatus(account = accountStatus.account, status = status) - } - .firstOrNull() + return accountStatusList.findStatus(accountCurrency) } /** - * Retrieves a map of accounts to their corresponding list of [AccountCryptoCurrencyStatus] for the specified + * Retrieves a map of accounts to their corresponding list of [CryptoCurrencyStatus] for the specified * list of [currencies] from the given [accountStatusList]. * * @param accountStatusList the list of account statuses to search within. * @param currencies the list of cryptocurrencies whose statuses are to be retrieved. - * @return a map where the key is the account and the value is a list of [AccountCryptoCurrencyStatus]. + * @return a map where the key is the account and the value is a list of [CryptoCurrencyStatus]. */ operator fun invoke( accountStatusList: AccountStatusList, @@ -104,15 +107,86 @@ internal object AccountCryptoCurrencyStatusFinder { derivationPath: Network.DerivationPath, contractAddress: String?, ): AccountCryptoCurrencyStatus? { - return accountStatusList.getExpectedAccountStatuses( + val accountList = accountStatusList.toAccountList().getOrNull() ?: return null + val accountCurrency = invoke( + accountList = accountList, + networkId = networkId, + derivationPath = derivationPath, + contractAddress = contractAddress, + ) ?: return null + + return accountStatusList.findStatus(accountCurrency) + } + + // endregion + + // region AccountCryptoCurrency methods + + /** + * Retrieves the [AccountCryptoCurrency] for the specified [currency] from the given [accountList]. + * + * @param accountList the list of accounts to search within. + * @param currency the cryptocurrency to be retrieved. + * @return the [AccountCryptoCurrency] if found, otherwise null. + */ + operator fun invoke(accountList: AccountList, currency: CryptoCurrency): AccountCryptoCurrency? { + return invoke( + accountList = accountList, + currencyId = currency.id, + network = currency.network, + ) + } + + /** + * Retrieves the [AccountCryptoCurrency] for the specified [currencyId] and [network] + * from the given [accountList]. + * + * @param accountList the list of accounts to search within. + * @param currencyId the ID of the cryptocurrency to be retrieved. + * @param network the network associated with the cryptocurrency. + * @return the [AccountCryptoCurrency] if found, otherwise null. + */ + operator fun invoke( + accountList: AccountList, + currencyId: CryptoCurrency.ID, + network: Network?, + ): AccountCryptoCurrency? { + return accountList.getExpectedAccounts(network) + .asSequence() + .filterIsInstance() + .mapNotNull { account -> + val currency = account.cryptoCurrencies.firstOrNull { it.id == currencyId } + ?: return@mapNotNull null + + AccountCryptoCurrency(account = account, cryptoCurrency = currency) + } + .firstOrNull() + } + + /** + * Retrieves the [AccountCryptoCurrency] for the specified [networkId], [derivationPath], + * and optional [contractAddress] from the given [accountList]. + * + * @param accountList the list of accounts to search within. + * @param networkId the ID of the network associated with the cryptocurrency. + * @param derivationPath the derivation path of the account. + * @param contractAddress the optional contract address of the token (if applicable). + * @return the [AccountCryptoCurrency] if found, otherwise null. + */ + operator fun invoke( + accountList: AccountList, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + contractAddress: String?, + ): AccountCryptoCurrency? { + return accountList.getExpectedAccounts( rawNetworkId = networkId.rawId.value, derivationPath = derivationPath, ) .asSequence() - .filterCryptoPortfolio() - .mapNotNull { accountStatus -> - val status = accountStatus.flattenCurrencies().firstOrNull { - val currency = it.currency + .filterIsInstance() + .mapNotNull { account -> + val currency = account.cryptoCurrencies.firstOrNull { currency -> val isContractAddressMatch = contractAddress == null || currency.id.contractAddress.equals(contractAddress, ignoreCase = true) @@ -122,74 +196,81 @@ internal object AccountCryptoCurrencyStatusFinder { } ?: return@mapNotNull null - AccountCryptoCurrencyStatus(account = accountStatus.account, status = status) + AccountCryptoCurrency(account = account, cryptoCurrency = currency) } .firstOrNull() } - private fun AccountStatusList.getExpectedAccountStatuses(network: Network?): List { - return getExpectedAccountStatuses(rawNetworkId = network?.rawId, derivationPath = network?.derivationPath) + // endregion + + // region AccountStatusList helpers + + private fun AccountStatusList.findStatus(accountCurrency: AccountCryptoCurrency): AccountCryptoCurrencyStatus? { + val accountStatus = accountStatuses + .filterCryptoPortfolio() + .firstOrNull { it.account.accountId == accountCurrency.account.accountId } + ?: return null + + val currencyStatus = accountStatus.flattenCurrencies() + .firstOrNull { it.currency.id == accountCurrency.cryptoCurrency.id } + ?: return null + + return AccountCryptoCurrencyStatus(account = accountCurrency.account, status = currencyStatus) } - /** - * Retrieves the expected account statuses based on the provided [rawNetworkId] and [derivationPath]. - * If either parameter is null, all account statuses are returned. - * If both parameters are provided, it filters the accounts based on the derivation index. - * - * @param rawNetworkId the raw ID of the network to filter accounts by, can be null. - * @param derivationPath the derivation path of the network to filter accounts by, can be null. - * @return a list of [AccountStatus] that match the expected criteria. - */ - private fun AccountStatusList.getExpectedAccountStatuses( - rawNetworkId: String?, - derivationPath: Network.DerivationPath?, - ): List { - val possibleAccountIndex = if (rawNetworkId != null && derivationPath != null) { - getAccountIndexOrNull(rawNetworkId, derivationPath) - } else { - null + private fun AccountStatusList.getExpectedAccountStatuses(networks: List): List { + val possibleAccountIndexes = networks.mapNotNull { getAccountIndexOrNull(it.rawId, it.derivationPath) } + + if (possibleAccountIndexes.isEmpty()) return accountStatuses + + val filteredStatuses = accountStatuses.filter { accountStatus -> + val cryptoPortfolio = accountStatus.account as? Account.CryptoPortfolio ?: return@filter false + cryptoPortfolio.derivationIndex.value in possibleAccountIndexes } + return filteredStatuses + listOf(mainAccount) + } + + // endregion + + // region AccountList helpers + + private fun AccountList.getExpectedAccounts(network: Network?): List { + return getExpectedAccounts(rawNetworkId = network?.rawId, derivationPath = network?.derivationPath) + } + + private fun AccountList.getExpectedAccounts( + rawNetworkId: String?, + derivationPath: Network.DerivationPath?, + ): List { + val possibleAccountIndex = getAccountIndexOrNull(rawNetworkId, derivationPath) + return when (possibleAccountIndex) { - // currency can be in any account - null -> accountStatuses - // currency only in the main account + null -> accounts DerivationIndex.Main.value -> listOf(mainAccount) // currency only in the account with specific derivation index or in the main account else -> { - val accountStatus = accountStatuses.firstOrNull { accountStatus -> - val cryptoPortfolio = accountStatus.account as? Account.CryptoPortfolio ?: return@firstOrNull false - + val account = accounts.firstOrNull { account -> + val cryptoPortfolio = account as? Account.CryptoPortfolio ?: return@firstOrNull false cryptoPortfolio.derivationIndex.value == possibleAccountIndex } - - listOfNotNull(accountStatus, mainAccount) + listOfNotNull(account, mainAccount) } } } - private fun AccountStatusList.getExpectedAccountStatuses(networks: List): List { - val possibleAccountIndexes = networks.mapNotNull { it.getAccountIndexOrNull() } + // endregion - if (possibleAccountIndexes.isEmpty()) return this@getExpectedAccountStatuses.accountStatuses + // region Common helpers - val accountStatuses = this@getExpectedAccountStatuses.accountStatuses.filter { accountStatus -> - val cryptoPortfolio = accountStatus.account as? Account.CryptoPortfolio ?: return@filter false + private fun getAccountIndexOrNull(rawNetworkId: String?, derivationPath: Network.DerivationPath?): Int? { + if (rawNetworkId == null || derivationPath == null) return null - cryptoPortfolio.derivationIndex.value in possibleAccountIndexes - } - - return accountStatuses + listOf(mainAccount) - } - - private fun Network.getAccountIndexOrNull(): Int? { - return getAccountIndexOrNull(rawNetworkId = rawId, derivationPath = derivationPath) - } - - private fun getAccountIndexOrNull(rawNetworkId: String, derivationPath: Network.DerivationPath): Int? { val blockchain = Blockchain.fromId(id = rawNetworkId) val recognizer = AccountNodeRecognizer(blockchain) return recognizer.recognize(derivationPath)?.toInt() } + + // endregion } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusOperations.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusOperations.kt new file mode 100644 index 0000000000..98127c3a49 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusOperations.kt @@ -0,0 +1,77 @@ +package com.tangem.domain.account.status.utils + +import arrow.core.Option +import arrow.core.raise.option +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network + +/** + * Extension functions for retrieving [AccountCryptoCurrencyStatus] from an [AccountStatusList]. + * + * This object provides convenient extension functions to search and retrieve [AccountCryptoCurrencyStatus] + * instances from an [AccountStatusList] using various parameters such as cryptocurrency ID and network. + * + * The primary difference from [AccountCryptoCurrencyOperations] is that this object works with + * [AccountStatusList] and returns status information along with the account-currency association. + * + * Usage example: + * ``` + * val accountStatusList: AccountStatusList? = ... + * val result: Option = accountStatusList.getAccountCryptoCurrencyStatus(currency) + * result.fold( + * ifEmpty = { /* handle not found */ }, + * ifSome = { accountCurrencyStatus -> /* use the found result */ } + * ) + * ``` + * + * @see AccountCryptoCurrencyStatus + * @see AccountStatusList + * @see AccountCryptoCurrencyOperations +[REDACTED_AUTHOR] + */ +object AccountCryptoCurrencyStatusOperations { + + // region AccountStatusList + + /** + * Retrieves the [AccountCryptoCurrencyStatus] for the specified [currency] from this [AccountStatusList]. + * + * @receiver the [AccountStatusList] to search within, can be null + * @param currency the cryptocurrency whose account status association is to be retrieved + * @return [Option] containing the [AccountCryptoCurrencyStatus] if found, or [Option.None] otherwise + */ + fun AccountStatusList?.getAccountCryptoCurrencyStatus( + currency: CryptoCurrency, + ): Option { + return getAccountCryptoCurrencyStatus(currencyId = currency.id, network = currency.network) + } + + /** + * Retrieves the [AccountCryptoCurrencyStatus] for the specified [currencyId] and [network] + * from this [AccountStatusList]. + * + * @receiver the [AccountStatusList] to search within, can be null + * @param currencyId the ID of the cryptocurrency whose account status association is to be retrieved + * @param network the network associated with the cryptocurrency, can be null + * @return [Option] containing the [AccountCryptoCurrencyStatus] if found, or [Option.None] otherwise + */ + fun AccountStatusList?.getAccountCryptoCurrencyStatus( + currencyId: CryptoCurrency.ID, + network: Network?, + ): Option = option { + val accountStatusList = this@getAccountCryptoCurrencyStatus + + ensureNotNull(accountStatusList) + + val accountCryptoCurrencyStatus = AccountCryptoCurrencyStatusFinder( + accountStatusList = accountStatusList, + currencyId = currencyId, + network = network, + ) + + ensureNotNull(accountCryptoCurrencyStatus) + } + // endregion +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyOperations.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyOperations.kt new file mode 100644 index 0000000000..7fdc8d9cce --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyOperations.kt @@ -0,0 +1,78 @@ +package com.tangem.domain.account.status.utils + +import arrow.core.Option +import arrow.core.toOption +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.status.utils.AccountCryptoCurrencyOperations.getAccountCryptoCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network + +/** + * Extension functions for retrieving [CryptoCurrency] from an [AccountList] or [Account.CryptoPortfolio]. + * + * This object provides convenient extension functions to search and retrieve [CryptoCurrency] instances + * from account-related data structures using various parameters such as cryptocurrency ID and network. + * + * Unlike [AccountCryptoCurrencyOperations], this object returns only the [CryptoCurrency] without + * the associated account information. + * + * Usage example: + * ``` + * val accountList: AccountList? = ... + * val result: Option = accountList.getCryptoCurrency(currencyId, network) + * result.fold( + * ifEmpty = { /* handle not found */ }, + * ifSome = { currency -> /* use the found currency */ } + * ) + * ``` + * + * @see CryptoCurrency + * @see AccountList + * @see Account.CryptoPortfolio + * @see AccountCryptoCurrencyOperations +[REDACTED_AUTHOR] + */ +object CryptoCurrencyOperations { + + // region AccountList + + /** + * Retrieves the [CryptoCurrency] matching the specified [cryptoCurrency] from this [AccountList]. + * + * @receiver the [AccountList] to search within, can be null + * @param cryptoCurrency the cryptocurrency to match + * @return [Option] containing the [CryptoCurrency] if found, or [Option.None] otherwise + */ + fun AccountList?.getCryptoCurrency(cryptoCurrency: CryptoCurrency): Option { + return getCryptoCurrency(currencyId = cryptoCurrency.id, network = cryptoCurrency.network) + } + + /** + * Retrieves the [CryptoCurrency] for the specified [currencyId] and [network] from this [AccountList]. + * + * @receiver the [AccountList] to search within, can be null + * @param currencyId the ID of the cryptocurrency to be retrieved + * @param network the network associated with the cryptocurrency, can be null + * @return [Option] containing the [CryptoCurrency] if found, or [Option.None] otherwise + */ + fun AccountList?.getCryptoCurrency(currencyId: CryptoCurrency.ID, network: Network?): Option { + return getAccountCryptoCurrency(currencyId, network) + .map { it.cryptoCurrency } + } + // endregion + + // region Account.CryptoPortfolio + + /** + * Retrieves the [CryptoCurrency] matching the specified [currencyId] from this [Account.CryptoPortfolio]. + * + * @receiver the [Account.CryptoPortfolio] to search within + * @param currencyId the ID of the cryptocurrency to be retrieved + * @return [Option] containing the [CryptoCurrency] if found, or [Option.None] otherwise + */ + fun Account.CryptoPortfolio.getCryptoCurrency(currencyId: CryptoCurrency.ID): Option { + return cryptoCurrencies.firstOrNull { it.id == currencyId }.toOption() + } + // endregion +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperations.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperations.kt new file mode 100644 index 0000000000..b9e72590a1 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperations.kt @@ -0,0 +1,89 @@ +package com.tangem.domain.account.status.utils + +import arrow.core.Option +import arrow.core.toOption +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.utils.AccountCryptoCurrencyStatusOperations.getAccountCryptoCurrencyStatus +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network + +/** + * Extension functions for retrieving [CryptoCurrencyStatus] from an [AccountStatusList] or + * [AccountStatus.CryptoPortfolio]. + * + * This object provides convenient extension functions to search and retrieve [CryptoCurrencyStatus] + * instances from account status-related data structures using various parameters such as cryptocurrency ID + * and network. + * + * Unlike [AccountCryptoCurrencyStatusOperations], this object returns only the [CryptoCurrencyStatus] without + * the associated account information. + * + * Usage example: + * ``` + * val accountStatusList: AccountStatusList? = ... + * val result: Option = accountStatusList.getCryptoCurrencyStatus(currency) + * result.fold( + * ifEmpty = { /* handle not found */ }, + * ifSome = { status -> /* use the found status */ } + * ) + * ``` + * + * @see CryptoCurrencyStatus + * @see AccountStatusList + * @see AccountStatus.CryptoPortfolio + * @see AccountCryptoCurrencyStatusOperations +[REDACTED_AUTHOR] + */ +object CryptoCurrencyStatusOperations { + + // region AccountStatusList + + /** + * Retrieves the [CryptoCurrencyStatus] for the specified [currency] from this [AccountStatusList]. + * + * @receiver the [AccountStatusList] to search within, can be null + * @param currency the cryptocurrency whose status is to be retrieved + * @return [Option] containing the [CryptoCurrencyStatus] if found, or [Option.None] otherwise + */ + fun AccountStatusList?.getCryptoCurrencyStatus(currency: CryptoCurrency): Option { + return getCryptoCurrencyStatus(currencyId = currency.id, network = currency.network) + } + + /** + * Retrieves the [CryptoCurrencyStatus] for the specified [currencyId] and [network] + * from this [AccountStatusList]. + * + * @receiver the [AccountStatusList] to search within, can be null + * @param currencyId the ID of the cryptocurrency whose status is to be retrieved + * @param network the network associated with the cryptocurrency, can be null + * @return [Option] containing the [CryptoCurrencyStatus] if found, or [Option.None] otherwise + */ + fun AccountStatusList?.getCryptoCurrencyStatus( + currencyId: CryptoCurrency.ID, + network: Network?, + ): Option { + return getAccountCryptoCurrencyStatus(currencyId = currencyId, network = network) + .map(AccountCryptoCurrencyStatus::status) + } + // endregion + + // region AccountStatus.CryptoPortfolio + + /** + * Retrieves the [CryptoCurrencyStatus] for the specified [currencyId] + * from this [AccountStatus.CryptoPortfolio]. + * + * @receiver the [AccountStatus.CryptoPortfolio] to search within + * @param currencyId the ID of the cryptocurrency whose status is to be retrieved + * @return [Option] containing the [CryptoCurrencyStatus] if found, or [Option.None] otherwise + */ + fun AccountStatus.CryptoPortfolio.getCryptoCurrencyStatus( + currencyId: CryptoCurrency.ID, + ): Option { + return flattenCurrencies().firstOrNull { it.currency.id == currencyId }.toOption() + } + // endregion +} \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt index 5c35e33427..fbea44938c 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt @@ -7,11 +7,10 @@ import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.models.TokensGroupType import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.TotalFiatBalance -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.account.* import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.tokenlist.TokenList @@ -19,7 +18,10 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.test.core.assertNone import com.tangem.test.core.assertSome import com.tangem.test.core.getEmittedValues -import io.mockk.* +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerifyOrder +import io.mockk.mockk import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -78,9 +80,15 @@ class GetAccountCurrencyStatusUseCaseTest { priceChangeLce = lceLoading(), ) - val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns listOf(accountStatus) - } + val accountStatusList = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf(accountStatus), + totalAccounts = 1, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList @@ -101,10 +109,14 @@ class GetAccountCurrencyStatusUseCaseTest { priceChangeLce = lceLoading(), ) - val account = mockk(relaxed = true) { - every { this@mockk.derivationIndex } returns DerivationIndex(1).getOrNull()!! - every { this@mockk.cryptoCurrencies } returns listOf(currency) - } + val derivationIndex = DerivationIndex(1).getOrNull()!! + val account = Account.CryptoPortfolio( + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex), + accountName = AccountName("Test Account").getOrNull()!!, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), + derivationIndex = derivationIndex, + cryptoCurrencies = listOf(currency), + ) val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) val accountStatus = AccountStatus.CryptoPortfolio( account = account, @@ -116,9 +128,15 @@ class GetAccountCurrencyStatusUseCaseTest { priceChangeLce = lceLoading(), ) - val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns listOf(mainAccountStatus, accountStatus, mockk()) - } + val accountStatusList = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf(mainAccountStatus, accountStatus), + totalAccounts = 2, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList @@ -139,9 +157,10 @@ class GetAccountCurrencyStatusUseCaseTest { @Test fun `invokeSync returns Some if network is null`() = runTest { // Arrange - val account = mockk(relaxed = true) { - every { this@mockk.cryptoCurrencies } returns listOf(currency) - } + val account = Account.CryptoPortfolio.createMainAccount( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) val accountStatus = AccountStatus.CryptoPortfolio( account = account, @@ -153,9 +172,15 @@ class GetAccountCurrencyStatusUseCaseTest { priceChangeLce = lceLoading(), ) - val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns listOf(accountStatus) - } + val accountStatusList = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf(accountStatus), + totalAccounts = 1, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList @@ -197,9 +222,15 @@ class GetAccountCurrencyStatusUseCaseTest { priceChangeLce = lceLoading(), ) - val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns listOf(accountStatus) - } + val accountStatusList = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf(accountStatus), + totalAccounts = 1, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) coEvery { supplier(supplierParams) } returns flowOf(accountStatusList) @@ -221,10 +252,14 @@ class GetAccountCurrencyStatusUseCaseTest { priceChangeLce = lceLoading(), ) - val account = mockk(relaxed = true) { - every { this@mockk.derivationIndex } returns DerivationIndex(1).getOrNull()!! - every { this@mockk.cryptoCurrencies } returns listOf(currency) - } + val derivationIndex = DerivationIndex(1).getOrNull()!! + val account = Account.CryptoPortfolio( + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex), + accountName = AccountName("Test Account").getOrNull()!!, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), + derivationIndex = derivationIndex, + cryptoCurrencies = listOf(currency), + ) val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) val accountStatus = AccountStatus.CryptoPortfolio( account = account, @@ -236,9 +271,15 @@ class GetAccountCurrencyStatusUseCaseTest { priceChangeLce = lceLoading(), ) - val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns listOf(mainAccountStatus, accountStatus, mockk()) - } + val accountStatusList = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf(mainAccountStatus, accountStatus), + totalAccounts = 2, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) coEvery { supplier(supplierParams) } returns flowOf(accountStatusList) @@ -256,9 +297,10 @@ class GetAccountCurrencyStatusUseCaseTest { @Test fun `invoke returns data if network is null`() = runTest { // Arrange - val account = mockk(relaxed = true) { - every { this@mockk.cryptoCurrencies } returns listOf(currency) - } + val account = Account.CryptoPortfolio.createMainAccount( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) val accountStatus = AccountStatus.CryptoPortfolio( account = account, @@ -270,9 +312,15 @@ class GetAccountCurrencyStatusUseCaseTest { priceChangeLce = lceLoading(), ) - val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns listOf(accountStatus) - } + val accountStatusList = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf(accountStatus), + totalAccounts = 1, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) coEvery { supplier(supplierParams) } returns flowOf(accountStatusList) diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/AccountCryptoCurrencyOperationsTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/AccountCryptoCurrencyOperationsTest.kt new file mode 100644 index 0000000000..ef84efc6f8 --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/AccountCryptoCurrencyOperationsTest.kt @@ -0,0 +1,169 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.status.model.AccountCryptoCurrency +import com.tangem.domain.account.status.utils.AccountCryptoCurrencyOperations.getAccountCryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.assertNone +import com.tangem.test.core.assertSome +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Tests for [AccountCryptoCurrencyOperations]. + * +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AccountCryptoCurrencyOperationsTest { + + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val userWalletId = UserWalletId("011") + private val currency = cryptoCurrencyFactory.ethereum + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetAccountCryptoCurrencyByCurrency { + + @Test + fun `returns None when AccountList is null`() { + // Arrange + val accountList: AccountList? = null + // Act + val result = accountList.getAccountCryptoCurrency(currency) + // Assert + assertNone(result) + } + + @Test + fun `returns None when currency is not found in AccountList`() { + // Arrange + val accountList = AccountList.empty(userWalletId) + // Act + val result = accountList.getAccountCryptoCurrency(currency) + // Assert + assertNone(result) + } + + @Test + fun `returns Some when currency is found in AccountList`() { + // Arrange + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) + val expected = AccountCryptoCurrency( + account = accountList.mainAccount, + cryptoCurrency = currency, + ) + // Act + val result = accountList.getAccountCryptoCurrency(currency) + // Assert + assertSome(result, expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetAccountCryptoCurrencyByCurrencyIdAndNetwork { + + @Test + fun `returns None when AccountList is null`() { + // Arrange + val accountList: AccountList? = null + // Act + val result = accountList.getAccountCryptoCurrency( + currencyId = currency.id, + network = currency.network, + ) + // Assert + assertNone(result) + } + + @Test + fun `returns None when currency id is not found`() { + // Arrange + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) + val otherCurrencyId = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("bitcoin"), + suffix = CryptoCurrency.ID.Suffix.RawID("bitcoin"), + ) + // Act + val result = accountList.getAccountCryptoCurrency( + currencyId = otherCurrencyId, + network = currency.network, + ) + // Assert + assertNone(result) + } + + @Test + fun `returns Some when currency id is found with null network`() { + // Arrange + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) + val expected = AccountCryptoCurrency( + account = accountList.mainAccount, + cryptoCurrency = currency, + ) + // Act + val result = accountList.getAccountCryptoCurrency( + currencyId = currency.id, + network = null, + ) + // Assert + assertSome(result, expected) + } + + @Test + fun `returns Some when currency id and network match`() { + // Arrange + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) + val expected = AccountCryptoCurrency( + account = accountList.mainAccount, + cryptoCurrency = currency, + ) + // Act + val result = accountList.getAccountCryptoCurrency( + currencyId = currency.id, + network = currency.network, + ) + // Assert + assertSome(result, expected) + } + + @Test + fun `returns Some with first matching currency when multiple currencies exist`() { + // Arrange + val currencies = cryptoCurrencyFactory.ethereumAndStellar + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = currencies, + ) + val targetCurrency = currencies.first() + val expected = AccountCryptoCurrency( + account = accountList.mainAccount, + cryptoCurrency = targetCurrency, + ) + // Act + val result = accountList.getAccountCryptoCurrency( + currencyId = targetCurrency.id, + network = targetCurrency.network, + ) + // Assert + assertSome(result, expected) + } + } +} \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusOperationsTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusOperationsTest.kt new file mode 100644 index 0000000000..046e1a64a4 --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusOperationsTest.kt @@ -0,0 +1,205 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.utils.AccountCryptoCurrencyStatusOperations.getAccountCryptoCurrencyStatus +import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.assertNone +import com.tangem.test.core.assertSome +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Tests for [AccountCryptoCurrencyStatusOperations]. + * +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AccountCryptoCurrencyStatusOperationsTest { + + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val userWalletId = UserWalletId("011") + private val currency = cryptoCurrencyFactory.ethereum + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetAccountCryptoCurrencyStatusByCurrency { + + @Test + fun `returns None when AccountStatusList is null`() { + val accountStatusList: AccountStatusList? = null + val result = accountStatusList.getAccountCryptoCurrencyStatus(currency) + assertNone(result) + } + + @Test + fun `returns None when currency is not found in AccountStatusList`() { + val accountStatusList = createAccountStatusList(currencies = emptyList()) + val result = accountStatusList.getAccountCryptoCurrencyStatus(currency) + assertNone(result) + } + + @Test + fun `returns Some when currency is found in AccountStatusList`() { + val currencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency), + currencyStatuses = listOf(currencyStatus), + ) + val expected = AccountCryptoCurrencyStatus( + account = accountStatusList.mainAccount.account, + status = currencyStatus, + ) + val result = accountStatusList.getAccountCryptoCurrencyStatus(currency) + assertSome(result, expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetAccountCryptoCurrencyStatusByCurrencyIdAndNetwork { + + @Test + fun `returns None when AccountStatusList is null`() { + val accountStatusList: AccountStatusList? = null + val result = accountStatusList.getAccountCryptoCurrencyStatus( + currencyId = currency.id, + network = currency.network, + ) + assertNone(result) + } + + @Test + fun `returns None when currency id is not found`() { + val currencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency), + currencyStatuses = listOf(currencyStatus), + ) + val otherCurrencyId = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("bitcoin"), + suffix = CryptoCurrency.ID.Suffix.RawID("bitcoin"), + ) + val result = accountStatusList.getAccountCryptoCurrencyStatus( + currencyId = otherCurrencyId, + network = currency.network, + ) + assertNone(result) + } + + @Test + fun `returns Some when currency id is found with null network`() { + val currencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency), + currencyStatuses = listOf(currencyStatus), + ) + val expected = AccountCryptoCurrencyStatus( + account = accountStatusList.mainAccount.account, + status = currencyStatus, + ) + val result = accountStatusList.getAccountCryptoCurrencyStatus( + currencyId = currency.id, + network = null, + ) + assertSome(result, expected) + } + + @Test + fun `returns Some when currency id and network match`() { + val currencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency), + currencyStatuses = listOf(currencyStatus), + ) + val expected = AccountCryptoCurrencyStatus( + account = accountStatusList.mainAccount.account, + status = currencyStatus, + ) + val result = accountStatusList.getAccountCryptoCurrencyStatus( + currencyId = currency.id, + network = currency.network, + ) + assertSome(result, expected) + } + + @Test + fun `returns Some with first matching currency when multiple currencies exist`() { + val currencies = cryptoCurrencyFactory.ethereumAndStellar + val currencyStatuses = currencies.map { + CryptoCurrencyStatus(currency = it, value = CryptoCurrencyStatus.Loading) + } + val accountStatusList = createAccountStatusList( + currencies = currencies, + currencyStatuses = currencyStatuses, + ) + val targetCurrency = currencies.first() + val expected = AccountCryptoCurrencyStatus( + account = accountStatusList.mainAccount.account, + status = currencyStatuses.first(), + ) + val result = accountStatusList.getAccountCryptoCurrencyStatus( + currencyId = targetCurrency.id, + network = targetCurrency.network, + ) + assertSome(result, expected) + } + } + + private fun createAccountStatusList( + currencies: List, + currencyStatuses: List = emptyList(), + ): AccountStatusList { + val account = Account.CryptoPortfolio.createMainAccount( + userWalletId = userWalletId, + cryptoCurrencies = currencies, + ) + val tokenList = if (currencyStatuses.isEmpty()) { + TokenList.Empty + } else { + TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = currencyStatuses, + ) + } + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = tokenList, + priceChangeLce = lceLoading(), + ) + return AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf(accountStatus), + totalAccounts = 1, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + } +} \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyOperationsTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyOperationsTest.kt new file mode 100644 index 0000000000..043571ab75 --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyOperationsTest.kt @@ -0,0 +1,207 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCryptoCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.assertNone +import com.tangem.test.core.assertSome +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Tests for [CryptoCurrencyOperations]. + * +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CryptoCurrencyOperationsTest { + + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val userWalletId = UserWalletId("011") + private val currency = cryptoCurrencyFactory.ethereum + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetCryptoCurrencyFromAccountListByCurrency { + + @Test + fun `returns None when currency is not found in AccountList`() { + // Arrange + val accountList = AccountList.empty(userWalletId) + // Act + val result = accountList.getCryptoCurrency(currency) + // Assert + assertNone(result) + } + + @Test + fun `returns Some when currency is found in AccountList`() { + // Arrange + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) + // Act + val result = accountList.getCryptoCurrency(currency) + // Assert + assertSome(result, currency) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetCryptoCurrencyFromAccountListByCurrencyIdAndNetwork { + + @Test + fun `returns None when AccountList is null`() { + // Arrange + val accountList: AccountList? = null + // Act + val result = accountList.getCryptoCurrency( + currencyId = currency.id, + network = currency.network, + ) + // Assert + assertNone(result) + } + + @Test + fun `returns None when currency id is not found`() { + // Arrange + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) + val otherCurrencyId = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("bitcoin"), + suffix = CryptoCurrency.ID.Suffix.RawID("bitcoin"), + ) + // Act + val result = accountList.getCryptoCurrency( + currencyId = otherCurrencyId, + network = currency.network, + ) + // Assert + assertNone(result) + } + + @Test + fun `returns Some when currency id is found with null network`() { + // Arrange + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) + // Act + val result = accountList.getCryptoCurrency( + currencyId = currency.id, + network = null, + ) + // Assert + assertSome(result, currency) + } + + @Test + fun `returns Some when currency id and network match`() { + // Arrange + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) + // Act + val result = accountList.getCryptoCurrency( + currencyId = currency.id, + network = currency.network, + ) + // Assert + assertSome(result, currency) + } + + @Test + fun `returns Some with first matching currency when multiple currencies exist`() { + // Arrange + val currencies = cryptoCurrencyFactory.ethereumAndStellar + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = currencies, + ) + val targetCurrency = currencies.first() + // Act + val result = accountList.getCryptoCurrency( + currencyId = targetCurrency.id, + network = targetCurrency.network, + ) + // Assert + assertSome(result, targetCurrency) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetCryptoCurrencyFromCryptoPortfolio { + + @Test + fun `returns None when currency id is not found in CryptoPortfolio`() { + // Arrange + val account = Account.CryptoPortfolio.createMainAccount( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) + val otherCurrencyId = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("bitcoin"), + suffix = CryptoCurrency.ID.Suffix.RawID("bitcoin"), + ) + // Act + val result = account.getCryptoCurrency(otherCurrencyId) + // Assert + assertNone(result) + } + + @Test + fun `returns Some when currency id is found in CryptoPortfolio`() { + // Arrange + val account = Account.CryptoPortfolio.createMainAccount( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) + // Act + val result = account.getCryptoCurrency(currency.id) + // Assert + assertSome(result, currency) + } + + @Test + fun `returns None when CryptoPortfolio has no currencies`() { + // Arrange + val account = Account.CryptoPortfolio.createMainAccount( + userWalletId = userWalletId, + cryptoCurrencies = emptyList(), + ) + // Act + val result = account.getCryptoCurrency(currency.id) + // Assert + assertNone(result) + } + + @Test + fun `returns Some with matching currency when multiple currencies exist`() { + // Arrange + val currencies = cryptoCurrencyFactory.ethereumAndStellar + val account = Account.CryptoPortfolio.createMainAccount( + userWalletId = userWalletId, + cryptoCurrencies = currencies, + ) + val targetCurrency = currencies.last() + // Act + val result = account.getCryptoCurrency(targetCurrency.id) + // Assert + assertSome(result, targetCurrency) + } + } +} \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperationsTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperationsTest.kt new file mode 100644 index 0000000000..e79ed8bfbb --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperationsTest.kt @@ -0,0 +1,321 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus +import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.assertNone +import com.tangem.test.core.assertSome +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Tests for [CryptoCurrencyStatusOperations]. + * +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CryptoCurrencyStatusOperationsTest { + + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val userWalletId = UserWalletId("011") + private val currency = cryptoCurrencyFactory.ethereum + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetCryptoCurrencyStatusFromAccountStatusListByCurrency { + + @Test + fun `returns None when AccountStatusList is null`() { + // Arrange + val accountStatusList: AccountStatusList? = null + // Act + val result = accountStatusList.getCryptoCurrencyStatus(currency) + // Assert + assertNone(result) + } + + @Test + fun `returns None when currency is not found in AccountStatusList`() { + // Arrange + val accountStatusList = createAccountStatusList(currencies = emptyList()) + // Act + val result = accountStatusList.getCryptoCurrencyStatus(currency) + // Assert + assertNone(result) + } + + @Test + fun `returns Some when currency is found in AccountStatusList`() { + // Arrange + val currencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency), + currencyStatuses = listOf(currencyStatus), + ) + // Act + val result = accountStatusList.getCryptoCurrencyStatus(currency) + // Assert + assertSome(result, currencyStatus) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetCryptoCurrencyStatusFromAccountStatusListByCurrencyIdAndNetwork { + + @Test + fun `returns None when AccountStatusList is null`() { + // Arrange + val accountStatusList: AccountStatusList? = null + // Act + val result = accountStatusList.getCryptoCurrencyStatus( + currencyId = currency.id, + network = currency.network, + ) + // Assert + assertNone(result) + } + + @Test + fun `returns None when currency id is not found`() { + // Arrange + val currencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency), + currencyStatuses = listOf(currencyStatus), + ) + val otherCurrencyId = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("bitcoin"), + suffix = CryptoCurrency.ID.Suffix.RawID("bitcoin"), + ) + // Act + val result = accountStatusList.getCryptoCurrencyStatus( + currencyId = otherCurrencyId, + network = currency.network, + ) + // Assert + assertNone(result) + } + + @Test + fun `returns Some when currency id is found with null network`() { + // Arrange + val currencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency), + currencyStatuses = listOf(currencyStatus), + ) + // Act + val result = accountStatusList.getCryptoCurrencyStatus( + currencyId = currency.id, + network = null, + ) + // Assert + assertSome(result, currencyStatus) + } + + @Test + fun `returns Some when currency id and network match`() { + // Arrange + val currencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency), + currencyStatuses = listOf(currencyStatus), + ) + // Act + val result = accountStatusList.getCryptoCurrencyStatus( + currencyId = currency.id, + network = currency.network, + ) + // Assert + assertSome(result, currencyStatus) + } + + @Test + fun `returns Some with first matching currency status when multiple currencies exist`() { + // Arrange + val currencies = cryptoCurrencyFactory.ethereumAndStellar + val currencyStatuses = currencies.map { + CryptoCurrencyStatus(currency = it, value = CryptoCurrencyStatus.Loading) + } + val accountStatusList = createAccountStatusList( + currencies = currencies, + currencyStatuses = currencyStatuses, + ) + val targetCurrency = currencies.first() + val expectedStatus = currencyStatuses.first() + // Act + val result = accountStatusList.getCryptoCurrencyStatus( + currencyId = targetCurrency.id, + network = targetCurrency.network, + ) + // Assert + assertSome(result, expectedStatus) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetCryptoCurrencyStatusFromCryptoPortfolio { + + @Test + fun `returns None when currency id is not found in CryptoPortfolio`() { + // Arrange + val currencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val account = Account.CryptoPortfolio.createMainAccount( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(currencyStatus), + ), + priceChangeLce = lceLoading(), + ) + val otherCurrencyId = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("bitcoin"), + suffix = CryptoCurrency.ID.Suffix.RawID("bitcoin"), + ) + // Act + val result = accountStatus.getCryptoCurrencyStatus(otherCurrencyId) + // Assert + assertNone(result) + } + + @Test + fun `returns Some when currency id is found in CryptoPortfolio`() { + // Arrange + val currencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val account = Account.CryptoPortfolio.createMainAccount( + userWalletId = userWalletId, + cryptoCurrencies = listOf(currency), + ) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(currencyStatus), + ), + priceChangeLce = lceLoading(), + ) + // Act + val result = accountStatus.getCryptoCurrencyStatus(currency.id) + // Assert + assertSome(result, currencyStatus) + } + + @Test + fun `returns None when CryptoPortfolio has empty token list`() { + // Arrange + val account = Account.CryptoPortfolio.createMainAccount( + userWalletId = userWalletId, + cryptoCurrencies = emptyList(), + ) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Empty, + priceChangeLce = lceLoading(), + ) + // Act + val result = accountStatus.getCryptoCurrencyStatus(currency.id) + // Assert + assertNone(result) + } + + @Test + fun `returns Some with matching currency status when multiple currencies exist`() { + // Arrange + val currencies = cryptoCurrencyFactory.ethereumAndStellar + val currencyStatuses = currencies.map { + CryptoCurrencyStatus(currency = it, value = CryptoCurrencyStatus.Loading) + } + val account = Account.CryptoPortfolio.createMainAccount( + userWalletId = userWalletId, + cryptoCurrencies = currencies, + ) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = currencyStatuses, + ), + priceChangeLce = lceLoading(), + ) + val targetCurrency = currencies.last() + val expectedStatus = currencyStatuses.last() + // Act + val result = accountStatus.getCryptoCurrencyStatus(targetCurrency.id) + // Assert + assertSome(result, expectedStatus) + } + } + + private fun createAccountStatusList( + currencies: List, + currencyStatuses: List = emptyList(), + ): AccountStatusList { + val account = Account.CryptoPortfolio.createMainAccount( + userWalletId = userWalletId, + cryptoCurrencies = currencies, + ) + val tokenList = if (currencyStatuses.isEmpty()) { + TokenList.Empty + } else { + TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = currencyStatuses, + ) + } + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = tokenList, + priceChangeLce = lceLoading(), + ) + return AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf(accountStatus), + totalAccounts = 1, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + } +} \ No newline at end of file From 5a3e53cb3b12640436632cac53cde7c910c7484c Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 6 Mar 2026 21:07:20 +0500 Subject: [PATCH 11/60] Updated on 2026-08-14 --- .../com/tangem/core/ui/components/Fade.kt | 20 +- .../ui/ds/button/SecondaryTangemButton.kt | 2 + .../organizetokens/OrganizeTokensComponent.kt | 43 ++ .../organizetokens/di/OrganizeTokensModule.kt | 6 + .../entity/OrganizeRowItemUM.kt | 116 ++++++ .../organizetokens/entity/OrganizeTokensUM.kt | 27 ++ .../model/CryptoCurrenciesIdsResolver.kt | 20 + .../child/organizetokens/model/Intents.kt | 2 + .../model/OrganizeTokensModel.kt | 283 +++++++++++++ .../model/common/DraggableItemOperations.kt | 8 + .../model/common/DraggableItemsOperations.kt | 90 +++++ .../converter/OrganizeTokensListConverter.kt | 82 ++++ .../items/OrganizeAccountItemConverter.kt | 35 ++ .../items/OrganizeNetworkItemConverter.kt | 25 ++ .../items/OrganizeTokenItemConverter.kt | 67 ++++ .../model/dnd/DragAndDropAdapter.kt | 217 ++++++++++ .../model/dnd/DragAndDropAdapterLegacy.kt | 3 + .../model/dnd/DraggableGroupsOperations.kt | 66 ++++ .../OrganizeContentStateTransformer.kt | 39 ++ ...rganizeDisableBalanceSortingTransformer.kt | 14 + ...OrganizeSortingProgressStateTransformer.kt | 31 ++ .../organizetokens/ui/OrganizeDropDownMenu.kt | 43 ++ .../ui/OrganizeTokensContent.kt | 371 ++++++++++++++++++ .../ui/preview/OrganizeTokensPreview.kt | 129 ++++++ .../wallet/child/wallet/WalletComponent.kt | 10 + .../router/DefaultWalletRouter.kt | 21 +- .../presentation/router/InnerWalletRouter.kt | 3 + .../wallet/state/model/WalletDialogConfig.kt | 3 + 28 files changed, 1763 insertions(+), 13 deletions(-) create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/OrganizeTokensComponent.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/entity/OrganizeRowItemUM.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/entity/OrganizeTokensUM.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/OrganizeTokensModel.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/OrganizeTokensListConverter.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeAccountItemConverter.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeNetworkItemConverter.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeTokenItemConverter.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/dnd/DragAndDropAdapter.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeContentStateTransformer.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeDisableBalanceSortingTransformer.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeSortingProgressStateTransformer.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeDropDownMenu.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/preview/OrganizeTokensPreview.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt index ea58b6415a..9a4cb95448 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt @@ -39,22 +39,18 @@ fun BottomFade(modifier: Modifier = Modifier, backgroundColor: Color = TangemThe } /** - * A composable that draws a fade effect at the right end of the screen. Same as [BottomFade] - * but with a horizontal gradient. + * A composable that draws a fade effect at the bottom of the screen. Used on screens with a list of repeating + * elements and floating button at the bottom of the screen. */ @Composable -fun HorizontalFade(modifier: Modifier = Modifier, backgroundColor: Color = TangemTheme.colors.background.secondary) { +fun BottomFade(gradientBrush: Brush, modifier: Modifier = Modifier) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + Box( modifier = modifier - .fillMaxHeight() - .background( - brush = Brush.horizontalGradient( - colors = listOf( - Color.Transparent, - backgroundColor, - ), - ), - ), + .fillMaxWidth() + .height(TangemTheme.dimens.size100 + bottomBarHeight) + .background(gradientBrush), ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt index 95b2371aee..856d3ebce9 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt @@ -15,6 +15,7 @@ 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 +import com.tangem.core.ui.components.haze.hazeEffectTangem import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme @@ -83,6 +84,7 @@ fun SecondaryTangemButton( onClick = onClick, modifier = modifier .clip(shape.toShape(size)) + .hazeEffectTangem() .then(backgroundModifier), text = text, contentColor = contentColor, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/OrganizeTokensComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/OrganizeTokensComponent.kt new file mode 100644 index 0000000000..2a9b249380 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/OrganizeTokensComponent.kt @@ -0,0 +1,43 @@ +package com.tangem.feature.wallet.child.organizetokens + +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.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.feature.wallet.child.organizetokens.model.OrganizeTokensModel +import com.tangem.feature.wallet.child.organizetokens.ui.OrganizeTokensContent + +internal class OrganizeTokensComponent( + appComponentContext: AppComponentContext, + private val params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: OrganizeTokensModel = getOrCreateModel(params) + + override fun dismiss() { + params.callback.onDismiss() + } + + @Composable + override fun BottomSheet() { + val uiState by model.uiState.collectAsStateWithLifecycle() + + OrganizeTokensContent( + organizeTokensUM = uiState, + dragAndDropIntents = model.dragAndDropAdapter, + onDismiss = ::dismiss, + ) + } + + interface Callback { + fun onDismiss() + } + + data class Params( + val userWalletId: UserWalletId, + val callback: Callback, + ) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/di/OrganizeTokensModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/di/OrganizeTokensModule.kt index e27eb472ed..97ad9b7027 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/di/OrganizeTokensModule.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/di/OrganizeTokensModule.kt @@ -2,6 +2,7 @@ package com.tangem.feature.wallet.child.organizetokens.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model +import com.tangem.feature.wallet.child.organizetokens.model.OrganizeTokensModel import com.tangem.feature.wallet.child.organizetokens.model.OrganizeTokensModelLegacy import dagger.Binds import dagger.Module @@ -17,4 +18,9 @@ internal interface OrganizeTokensModule { @IntoMap @ClassKey(OrganizeTokensModelLegacy::class) fun bindOrganizeTokensModelLegacy(model: OrganizeTokensModelLegacy): Model + + @Binds + @IntoMap + @ClassKey(OrganizeTokensModel::class) + fun bindOrganizeTokensModel(model: OrganizeTokensModel): Model } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/entity/OrganizeRowItemUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/entity/OrganizeRowItemUM.kt new file mode 100644 index 0000000000..48dcba2564 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/entity/OrganizeRowItemUM.kt @@ -0,0 +1,116 @@ +package com.tangem.feature.wallet.child.organizetokens.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.ds.row.header.TangemHeaderRowUM +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM + +/** + * Helper class for the DND list items + * + * @property id ID of the item + * @property roundingModeUM item [RoundingModeUM] + * @property isShowShadow if true then item should be elevated + * */ +@Immutable +internal sealed class OrganizeRowItemUM { + abstract val id: String + abstract val roundingModeUM: RoundingModeUM + abstract val isShowShadow: Boolean + + /** + * Item for token. + * + * @property id ID of the token + * @property groupId ID of the network group which contains this token + * @property accountId ID of account which contains this token + * @property tokenRowUM state of the token item + * @property roundingModeUM item [RoundingModeUM] + * @property isShowShadow if true then item should be elevated + * */ + data class Token( + override val isShowShadow: Boolean = false, + override val roundingModeUM: RoundingModeUM = RoundingModeUM.None, + val tokenRowUM: TangemTokenRowUM, + val groupId: String, + val accountId: String = "", + ) : OrganizeRowItemUM() { + override val id: String = tokenRowUM.id + } + + /** + * Item for network group header. + * + * @property id ID of the network group + * @property accountId ID of account which contains this network group + * @property headerRowUM state of the network group header item + * @property roundingModeUM item [RoundingModeUM] + * @property isShowShadow if true then item should be elevated + * */ + data class Network( + override val roundingModeUM: RoundingModeUM = RoundingModeUM.None, + override val isShowShadow: Boolean = false, + val headerRowUM: TangemHeaderRowUM, + val accountId: String = "", + ) : OrganizeRowItemUM() { + override val id: String = headerRowUM.id + } + + /** + * Item for portfolio. + * + * @property id ID of the portfolio + * @property headerRowUM state of the portfolio item + * @property roundingModeUM item [RoundingModeUM] + * @property isShowShadow if true then item should be elevated + * */ + data class Portfolio( + override val roundingModeUM: RoundingModeUM = RoundingModeUM.None, + val headerRowUM: TangemHeaderRowUM, + ) : OrganizeRowItemUM() { + override val id: String = headerRowUM.id + override val isShowShadow: Boolean = false + } + + /** + * Helper item used to detect possible positions where a draggable item can be placed. + * + * @property id ID of the placeholder for corresponding ID of the group + * @property accountId ID of account which contains this placeholder + * */ + data class Placeholder( + override val id: String, + val accountId: String = "", + ) : OrganizeRowItemUM() { + override val isShowShadow: Boolean = false + override val roundingModeUM: RoundingModeUM = RoundingModeUM.None + } + + /** + * Update item [RoundingModeUM] + * + * @param mode new [RoundingModeUM] + * + * @return updated [DraggableItem] + * */ + fun updateRoundingMode(mode: RoundingModeUM): OrganizeRowItemUM = when (this) { + is Placeholder -> this + is Portfolio -> this.copy(roundingModeUM = mode) + is Network -> this.copy(roundingModeUM = mode) + is Token -> this.copy(roundingModeUM = mode) + } + + /** + * Update item shadow visibility + * + * @param show if true then item should be elevated + * + * @return updated [DraggableItem] + * */ + fun updateShadowVisibility(show: Boolean): OrganizeRowItemUM = when (this) { + is Portfolio, + is Placeholder, + -> this + is Network -> this.copy(isShowShadow = show) + is Token -> this.copy(isShowShadow = show) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/entity/OrganizeTokensUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/entity/OrganizeTokensUM.kt new file mode 100644 index 0000000000..4162f46d7f --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/entity/OrganizeTokensUM.kt @@ -0,0 +1,27 @@ +package com.tangem.feature.wallet.child.organizetokens.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.event.StateEvent +import kotlinx.collections.immutable.PersistentList + +@Immutable +internal data class OrganizeTokensUM( + val tokenList: PersistentList, + val organizeMenuUM: OrganizeMenuUM, + val isGrouped: Boolean, + val isAccountsMode: Boolean, + val scrollListToTop: StateEvent, + val cancelButton: TangemButtonUM, + val applyButton: TangemButtonUM, + val isBalanceHidden: Boolean, +) { + + data class OrganizeMenuUM( + val isEnabled: Boolean = false, + val isSortedByBalance: Boolean = false, + val isGrouped: Boolean = false, + val onSortClick: () -> Unit, + val onGroupClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/CryptoCurrenciesIdsResolver.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/CryptoCurrenciesIdsResolver.kt index cda6bbe86c..528b935475 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/CryptoCurrenciesIdsResolver.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/CryptoCurrenciesIdsResolver.kt @@ -5,6 +5,7 @@ import com.tangem.domain.account.status.model.AccountCryptoCurrencies import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.tokenlist.TokenList import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensListUM internal class CryptoCurrenciesIdsResolver { @@ -35,4 +36,23 @@ internal class CryptoCurrenciesIdsResolver { .toList() } } + + fun resolve(tokenList: List, accountStatusList: AccountStatusList?): AccountCryptoCurrencies { + if (accountStatusList == null) return emptyMap() + + val draggableTokens = tokenList.filterIsInstance() + + return accountStatusList.accountStatuses + .filterCryptoPortfolio() + .filter { it.tokenList != TokenList.Empty } + .associate { accountStatus -> + val currenciesById = accountStatus.flattenCurrencies().associateBy { it.currency.id.value } + + accountStatus.account to draggableTokens + .asSequence() + .filter { it.accountId == accountStatus.account.accountId.value } + .mapNotNull { token -> currenciesById[token.id]?.currency } + .toList() + } + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/Intents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/Intents.kt index 48dc8f24ca..1eef2b75b4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/Intents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/Intents.kt @@ -2,6 +2,7 @@ package com.tangem.feature.wallet.child.organizetokens.model import androidx.compose.runtime.Stable import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM import org.burnoutcrew.reorderable.ItemPosition internal interface OrganizeTokensIntents { @@ -25,6 +26,7 @@ internal interface DragAndDropIntents { fun canDragItemOver(dragOver: ItemPosition, dragging: ItemPosition): Boolean fun onItemDraggingStartLegacy(item: DraggableItem) + fun onItemDraggingStart(item: OrganizeRowItemUM) fun onItemDraggingEnd() } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/OrganizeTokensModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/OrganizeTokensModel.kt new file mode 100644 index 0000000000..6d6f148c84 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/OrganizeTokensModel.kt @@ -0,0 +1,283 @@ +package com.tangem.feature.wallet.child.organizetokens.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +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.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.ApplyTokenListSortingUseCase +import com.tangem.domain.account.status.usecase.ToggleTokenListGroupingUseCase +import com.tangem.domain.account.status.usecase.ToggleTokenListSortingUseCase +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.models.TokensSortType +import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent +import com.tangem.feature.wallet.child.organizetokens.analytics.PortfolioOrganizeTokensAnalyticsEvent +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM +import com.tangem.feature.wallet.child.organizetokens.model.dnd.DragAndDropAdapter +import com.tangem.feature.wallet.child.organizetokens.model.transformer.OrganizeContentStateTransformer +import com.tangem.feature.wallet.child.organizetokens.model.transformer.OrganizeDisableBalanceSortingTransformer +import com.tangem.feature.wallet.child.organizetokens.model.transformer.OrganizeSortingProgressStateTransformer +import com.tangem.feature.wallet.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.transformer.update +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@Stable +@ModelScoped +internal class OrganizeTokensModel @Inject constructor( + paramsContainer: ParamsContainer, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + override val dispatchers: CoroutineDispatcherProvider, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val analyticsEventsHandler: AnalyticsEventHandler, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val toggleTokenListGroupingUseCase: ToggleTokenListGroupingUseCase, + private val toggleTokenListSortingUseCase: ToggleTokenListSortingUseCase, + private val applyTokenListSortingUseCase: ApplyTokenListSortingUseCase, +) : Model(), OrganizeTokensIntents { + + private val params: OrganizeTokensComponent.Params = paramsContainer.require() + + private val selectedAppCurrencyFlow = createSelectedAppCurrencyFlow() + + private val userWalletId = params.userWalletId + + private var cachedAccountStatusList: AccountStatusList? = null + + private var isAccountsModeEnabled: Boolean = false + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + val dragAndDropAdapter by lazy(LazyThreadSafetyMode.NONE) { + DragAndDropAdapter(uiState) + } + + init { + analyticsEventsHandler.send(PortfolioOrganizeTokensAnalyticsEvent.ScreenOpened()) + + getBalanceHidingSettingsUseCase() + .onEach { balanceSettings -> + uiState.update { + it.copy( + isBalanceHidden = balanceSettings.isBalanceHidden, + ) + } + } + .launchIn(modelScope) + + bootstrapTokenList() + bootstrapDragAndDropUpdates() + } + + override fun onBackClick() { + params.callback.onDismiss() + } + + override fun onSortClick() { + val list = cachedAccountStatusList ?: return + if (list.sortType == TokensSortType.BALANCE) return + + analyticsEventsHandler.send(PortfolioOrganizeTokensAnalyticsEvent.ByBalance()) + + modelScope.launch { + toggleTokenListSortingUseCase(list).fold( + ifLeft = { + uiState.update { + it.copy( + organizeMenuUM = it.organizeMenuUM.copy(isEnabled = false), + ) + } + }, + ifRight = { accountStatusList -> + uiState.update( + OrganizeContentStateTransformer( + accountStatusList = accountStatusList, + isAccountsMode = isAccountsModeEnabled, + appCurrency = selectedAppCurrencyFlow.value, + ), + ) + cachedAccountStatusList = accountStatusList + }, + ) + } + } + + override fun onGroupClick() { + val list = cachedAccountStatusList ?: return + + analyticsEventsHandler.send(PortfolioOrganizeTokensAnalyticsEvent.Group()) + + modelScope.launch { + toggleTokenListGroupingUseCase(list).fold( + ifLeft = { + uiState.update { + it.copy( + organizeMenuUM = it.organizeMenuUM.copy(isEnabled = false), + ) + } + }, + ifRight = { accountStatusList -> + uiState.update( + OrganizeContentStateTransformer( + accountStatusList = accountStatusList, + isAccountsMode = isAccountsModeEnabled, + appCurrency = selectedAppCurrencyFlow.value, + ), + ) + cachedAccountStatusList = accountStatusList + }, + ) + } + } + + override fun onApplyClick() { + modelScope.launch { + uiState.update(OrganizeSortingProgressStateTransformer(true)) + val resolver = CryptoCurrenciesIdsResolver() + val isSortedByBalance = uiState.value.organizeMenuUM.isSortedByBalance + val isGroupedByNetwork = uiState.value.isGrouped + val tokensListUM = uiState.value.tokenList + + sendAnalyticsEvent( + isGroupedByNetwork = isGroupedByNetwork, + isSortedByBalance = isSortedByBalance, + ) + + val result = applyTokenListSortingUseCase( + sortedTokensIdsByAccount = resolver.resolve(tokensListUM, cachedAccountStatusList), + isGroupedByNetwork = isGroupedByNetwork, + isSortedByBalance = isSortedByBalance, + ) + + result.fold( + ifLeft = { + uiState.update { + it.copy( + organizeMenuUM = it.organizeMenuUM.copy(isEnabled = false), + ) + } + }, + ifRight = { + onBackClick() + uiState.update(OrganizeSortingProgressStateTransformer(false)) + }, + ) + } + } + + override fun onCancelClick() { + analyticsEventsHandler.send(PortfolioOrganizeTokensAnalyticsEvent.Cancel()) + onBackClick() + } + + private fun bootstrapTokenList() { + modelScope.launch { + val accountList = singleAccountStatusListSupplier.getSyncOrNull( + SingleAccountStatusListProducer.Params(userWalletId), + ) ?: return@launch + + isAccountsModeEnabled = isAccountsModeEnabledUseCase.invokeSync() + + uiState.update( + transformer = OrganizeContentStateTransformer( + accountStatusList = accountList, + isAccountsMode = isAccountsModeEnabled, + appCurrency = selectedAppCurrencyFlow.value, + ), + ) + + cachedAccountStatusList = accountList + } + } + + private fun bootstrapDragAndDropUpdates() { + dragAndDropAdapter.dragAndDropUpdates + .filterNotNull() + .distinctUntilChanged() + .onEach { (type, updatedTokenList) -> + disableSortingByBalanceIfListChanged(type) + uiState.update { it.copy(tokenList = updatedTokenList) } + } + .launchIn(modelScope) + } + + private fun disableSortingByBalanceIfListChanged(dragOperationType: DragAndDropAdapter.DragOperation.Type) { + if (dragOperationType !is DragAndDropAdapter.DragOperation.Type.End) return + + if (uiState.value.organizeMenuUM.isSortedByBalance && dragOperationType.isItemsOrderChanged) { + cachedAccountStatusList = cachedAccountStatusList?.copy(sortType = TokensSortType.NONE) + uiState.update(OrganizeDisableBalanceSortingTransformer) + } + } + + private fun createSelectedAppCurrencyFlow(): StateFlow { + return getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + } + + private fun sendAnalyticsEvent(isGroupedByNetwork: Boolean, isSortedByBalance: Boolean) { + analyticsEventsHandler.send( + PortfolioOrganizeTokensAnalyticsEvent.Apply( + grouping = if (isGroupedByNetwork) { + AnalyticsParam.OnOffState.On + } else { + AnalyticsParam.OnOffState.Off + }, + organizeSortType = if (isSortedByBalance) { + AnalyticsParam.OrganizeSortType.ByBalance + } else { + AnalyticsParam.OrganizeSortType.Manually + }, + ), + ) + } + + private fun getInitialState(): OrganizeTokensUM { + return OrganizeTokensUM( + tokenList = persistentListOf(), + organizeMenuUM = OrganizeTokensUM.OrganizeMenuUM( + onSortClick = ::onSortClick, + onGroupClick = ::onGroupClick, + ), + cancelButton = TangemButtonUM( + text = resourceReference(R.string.common_cancel), + onClick = ::onCancelClick, + type = TangemButtonType.Secondary, + ), + applyButton = TangemButtonUM( + text = resourceReference(R.string.common_apply), + onClick = ::onApplyClick, + type = TangemButtonType.Primary, + ), + scrollListToTop = consumedEvent(), + isBalanceHidden = true, + isGrouped = false, + isAccountsMode = false, + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/common/DraggableItemOperations.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/common/DraggableItemOperations.kt index 28cfeba4d9..39545e3663 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/common/DraggableItemOperations.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/common/DraggableItemOperations.kt @@ -1,10 +1,18 @@ package com.tangem.feature.wallet.child.organizetokens.model.common import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM internal fun getGroupPlaceholderLegacy(index: Int, accountId: String = ""): DraggableItem.Placeholder { return DraggableItem.Placeholder( id = "placeholder_${accountId}_${index.inc()}", accountId = accountId, ) +} + +internal fun getGroupPlaceholder(index: Int, accountId: String = ""): OrganizeRowItemUM.Placeholder { + return OrganizeRowItemUM.Placeholder( + id = "placeholder_${accountId}_${index.inc()}", + accountId = accountId, + ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/common/DraggableItemsOperations.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/common/DraggableItemsOperations.kt index 5735d4ee77..634bb22b07 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/common/DraggableItemsOperations.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/common/DraggableItemsOperations.kt @@ -1,6 +1,7 @@ package com.tangem.feature.wallet.child.organizetokens.model.common import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM import com.tangem.feature.wallet.child.organizetokens.entity.RoundingModeUM internal fun List.uniteItemsLegacy(isAccountsMode: Boolean): List { @@ -45,6 +46,48 @@ internal fun List.uniteItemsLegacy(isAccountsMode: Boolean): List }.toList() } +internal fun List.uniteItems(isAccountsMode: Boolean): List { + val items = this + val lastItemIndex = items.lastIndex + + return items + .asSequence() + .mapIndexed { index, item -> + val mode = when (index) { + 0 -> if (item is OrganizeRowItemUM.Placeholder) { + RoundingModeUM.None + } else { + RoundingModeUM.Top() + } + lastItemIndex -> RoundingModeUM.Bottom() + 1 -> if (items.first() is OrganizeRowItemUM.Placeholder) { + RoundingModeUM.Top() + } else { + RoundingModeUM.None + } + else -> when (item) { + is OrganizeRowItemUM.Placeholder -> RoundingModeUM.None + is OrganizeRowItemUM.Network -> if (isAccountsMode) { + RoundingModeUM.None + } else { + RoundingModeUM.Top(isShowGap = true) + } + is OrganizeRowItemUM.Token -> applyRoundingModeToToken( + isAccountsMode = isAccountsMode, + items = items, + index = index, + lastItemIndex = lastItemIndex, + ) + is OrganizeRowItemUM.Portfolio -> RoundingModeUM.Top(isShowGap = true) + } + } + + item + .updateRoundingMode(mode) + .updateShadowVisibility(show = false) + }.toList() +} + internal fun List.divideMovingItem(movingItem: DraggableItem): List { val mutableList = this.toMutableList() val listIterator = mutableList.listIterator() @@ -110,4 +153,51 @@ private fun applyRoundingModeToTokenLegacy( RoundingModeUM.Bottom(isShowGap = true) } else -> RoundingModeUM.None +} + +/** + * Applying rounding to tokens + * + * If is in accounts mode without grouping + * * PORTFOLIO + * * TOKEN + * * TOKEN <- add rounding + * * PORTFOLIO index + 1 is PORTFOLIO + * + * If is in accounts mode with grouping + * * PORTFOLIO + * * PLACEHOLDER + * * GROUPING + * * TOKEN + * * TOKEN <- add rounding + * * PLACEHOLDER index + 1 is PLACEHOLDER + * * PORTFOLIO index + 2 is PORTFOLIO + * * PLACEHOLDER + * + * If is not accounts mode without grouping + * * TOKEN + * * TOKEN <- add rounding + * + * If is not accounts mode with grouping + * * PLACEHOLDER + * * GROUPING + * * TOKEN + * * TOKEN <- add rounding + * * PLACEHOLDER index + 1 is PLACEHOLDER + */ +private fun applyRoundingModeToToken( + isAccountsMode: Boolean, + items: List, + index: Int, + lastItemIndex: Int, +) = when { + isAccountsMode && index + 1 < lastItemIndex && + (items[index + 1] is OrganizeRowItemUM.Portfolio || + items[index + 1] is OrganizeRowItemUM.Placeholder && items[index + 2] is OrganizeRowItemUM.Portfolio) -> { + RoundingModeUM.Bottom(isShowGap = true) + } + (!isAccountsMode || index + 1 == lastItemIndex) && items[index + 1] is OrganizeRowItemUM.Placeholder -> { + RoundingModeUM.Bottom(isShowGap = true) + } + else -> RoundingModeUM.None } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/OrganizeTokensListConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/OrganizeTokensListConverter.kt new file mode 100644 index 0000000000..7fdbdd5854 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/OrganizeTokensListConverter.kt @@ -0,0 +1,82 @@ +package com.tangem.feature.wallet.child.organizetokens.model.converter + +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM +import com.tangem.feature.wallet.child.organizetokens.model.common.getGroupPlaceholder +import com.tangem.feature.wallet.child.organizetokens.model.common.uniteItems +import com.tangem.feature.wallet.child.organizetokens.model.converter.items.OrganizeAccountItemConverter +import com.tangem.feature.wallet.child.organizetokens.model.converter.items.OrganizeNetworkItemConverter +import com.tangem.feature.wallet.child.organizetokens.model.converter.items.OrganizeTokenItemConverter +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.addIf +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.toPersistentList + +internal class OrganizeTokensListConverter( + private val isAccountsMode: Boolean, + private val appCurrency: AppCurrency, +) : Converter> { + + private val accountItemConverter by lazy(LazyThreadSafetyMode.NONE) { + OrganizeAccountItemConverter(appCurrency) + } + + private val tokenItemConverter by lazy(LazyThreadSafetyMode.NONE) { + OrganizeTokenItemConverter(appCurrency) + } + + override fun convert(value: AccountStatusList): PersistentList { + return value.accountStatuses + .asSequence() + .filterCryptoPortfolio() + .flatMap { accountStatus -> + buildList { + addIf( + condition = isAccountsMode, + create = { accountItemConverter.convert(accountStatus) }, + ) + when (val tokenList = accountStatus.tokenList) { + is TokenList.Ungrouped -> addAll( + elements = tokenItemConverter.convertList( + input = tokenList.currencies.mapToAccountCryptoCurrencyStatus(accountStatus), + ), + ) + is TokenList.GroupedByNetwork -> { + addIf( + condition = isAccountsMode, + create = { getGroupPlaceholder(-1, accountStatus.accountId.value) }, + ) + tokenList.groups.asSequence().forEachIndexed { index, (groupNetwork, currencies) -> + add(OrganizeNetworkItemConverter.convert(accountStatus.accountId to groupNetwork)) + addAll( + elements = tokenItemConverter.convertList( + input = currencies.mapToAccountCryptoCurrencyStatus(accountStatus), + ), + ) + add(getGroupPlaceholder(index, accountStatus.accountId.value)) + } + } + TokenList.Empty -> Unit + } + } + }.toList() + .uniteItems(isAccountsMode).toPersistentList() + } + + private fun List.mapToAccountCryptoCurrencyStatus( + accountStatus: AccountStatus.CryptoPortfolio, + ): List { + return map { cryptoCurrencyStatus -> + AccountCryptoCurrencyStatus( + account = accountStatus.account, + status = cryptoCurrencyStatus, + ) + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeAccountItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeAccountItemConverter.kt new file mode 100644 index 0000000000..ebba1ec794 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeAccountItemConverter.kt @@ -0,0 +1,35 @@ +package com.tangem.feature.wallet.child.organizetokens.model.converter.items + +import com.tangem.common.ui.account.toUM +import com.tangem.core.ui.ds.row.header.TangemHeaderRowUM +import com.tangem.core.ui.extensions.stringReference +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.TotalFiatBalance +import com.tangem.domain.models.account.AccountStatus +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM +import com.tangem.utils.converter.Converter + +internal class OrganizeAccountItemConverter( + private val appCurrency: AppCurrency, +) : Converter { + + override fun convert(value: AccountStatus.CryptoPortfolio): OrganizeRowItemUM.Portfolio { + val accountBalance = value.tokenList.totalFiatBalance as? TotalFiatBalance.Loaded + return OrganizeRowItemUM.Portfolio( + headerRowUM = TangemHeaderRowUM( + id = value.accountId.value, + title = value.account.accountName.toUM().value, + subtitle = stringReference( + accountBalance?.amount.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeNetworkItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeNetworkItemConverter.kt new file mode 100644 index 0000000000..4ef2f77614 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeNetworkItemConverter.kt @@ -0,0 +1,25 @@ +package com.tangem.feature.wallet.child.organizetokens.model.converter.items + +import com.tangem.core.ui.ds.row.header.TangemHeaderRowUM +import com.tangem.core.ui.ds.row.internal.TangemRowTailUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.network.Network +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM +import com.tangem.feature.wallet.impl.R +import com.tangem.utils.converter.Converter + +internal object OrganizeNetworkItemConverter : Converter, OrganizeRowItemUM.Network> { + + override fun convert(value: Pair): OrganizeRowItemUM.Network { + val (accountId, groupNetwork) = value + return OrganizeRowItemUM.Network( + headerRowUM = TangemHeaderRowUM( + id = groupNetwork.id.toString(), + title = stringReference(groupNetwork.name), + tailUM = TangemRowTailUM.Draggable(R.drawable.ic_group_drop_24), + ), + accountId = accountId.value, + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeTokenItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeTokenItemConverter.kt new file mode 100644 index 0000000000..c1cfa0e69b --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeTokenItemConverter.kt @@ -0,0 +1,67 @@ +package com.tangem.feature.wallet.child.organizetokens.model.converter.items + +import androidx.compose.ui.text.SpanStyle +import com.tangem.common.getTotalCryptoAmount +import com.tangem.common.getTotalFiatAmount +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.internal.TangemRowTailUM +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.cryptoStyled +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.formatStyled +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM +import com.tangem.feature.wallet.child.organizetokens.model.common.getTokenItemId +import com.tangem.feature.wallet.impl.R +import com.tangem.utils.converter.Converter + +internal class OrganizeTokenItemConverter( + private val appCurrency: AppCurrency, +) : Converter { + + private val iconStateConverter by lazy(LazyThreadSafetyMode.NONE) { + CryptoCurrencyToIconStateConverter() + } + + override fun convert(value: AccountCryptoCurrencyStatus): OrganizeRowItemUM.Token { + val (account, currencyStatus) = value + val currency = currencyStatus.currency + + return OrganizeRowItemUM.Token( + tokenRowUM = TangemTokenRowUM.Actionable( + id = getTokenItemId(currency.id), + headIconUM = TangemIconUM.Currency(iconStateConverter.convert(currencyStatus)), + titleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference(currency.name), + ), + subtitleUM = TangemTokenRowUM.SubtitleUM.Empty, + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = currencyStatus.getTotalFiatAmount().formatStyled { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ) + }, + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = currencyStatus.getTotalCryptoAmount().formatStyled { + cryptoStyled( + cryptoCurrency = currency, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ) + }, + ), + tailUM = TangemRowTailUM.Draggable(R.drawable.ic_drag_24), + onItemClick = null, + onItemLongClick = null, + ), + groupId = currency.network.id.toString(), + accountId = account.accountId.value, + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/dnd/DragAndDropAdapter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/dnd/DragAndDropAdapter.kt new file mode 100644 index 0000000000..079ba2c104 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/dnd/DragAndDropAdapter.kt @@ -0,0 +1,217 @@ +package com.tangem.feature.wallet.child.organizetokens.model.dnd + +import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM +import com.tangem.feature.wallet.child.organizetokens.entity.RoundingModeUM +import com.tangem.feature.wallet.child.organizetokens.model.DragAndDropIntents +import com.tangem.feature.wallet.child.organizetokens.model.common.uniteItems +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.mutate +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.burnoutcrew.reorderable.ItemPosition + +internal class DragAndDropAdapter( + private val organizeTokensUMFlow: StateFlow, +) : DragAndDropIntents { + + private var draggingItem: OrganizeRowItemUM? = null + private var draggingListState: OrganizeTokensUM? = null + + private val tokensUM: OrganizeTokensUM + get() = organizeTokensUMFlow.value + + private val draggableGroupsOperations by lazy(LazyThreadSafetyMode.NONE) { + DraggableGroupsOperations() + } + + val dragAndDropUpdates: StateFlow + field = MutableStateFlow(value = null) + + override fun canDragItemOver(dragOver: ItemPosition, dragging: ItemPosition): Boolean { + val tokensListUM = tokensUM.tokenList + + val (dragOverItem, draggingItem) = findItemsToMove( + items = tokensListUM, + moveOverItemKey = dragOver.key, + movedItemKey = dragging.key, + ) + + if (dragOverItem == null || draggingItem == null) { + return false + } + + val canDrag = when (draggingItem) { + is OrganizeRowItemUM.Network -> checkCanMoveHeaderOver( + item = draggingItem, + moveOverItem = dragOverItem, + ) + is OrganizeRowItemUM.Token -> checkCanMoveTokenOver( + item = draggingItem, + moveOverItem = dragOverItem, + isAccountsMode = tokensUM.isAccountsMode, + isGrouped = tokensUM.isGrouped, + ) + is OrganizeRowItemUM.Placeholder, + is OrganizeRowItemUM.Portfolio, + -> false + } + + return canDrag + } + + override fun onItemDraggingStartLegacy(item: DraggableItem) { + /* no-op */ + } + + override fun onItemDraggingStart(item: OrganizeRowItemUM) { + if (draggingItem != null) return + draggingItem = item + + dragAndDropUpdates.value = DragOperation( + type = DragOperation.Type.Start, + tokenList = when (item) { + is OrganizeRowItemUM.Placeholder, + is OrganizeRowItemUM.Portfolio, + -> tokensUM.tokenList + is OrganizeRowItemUM.Network -> draggableGroupsOperations + .collapseGroup(tokensUM.tokenList, item) + .divideMovingItem(item) + is OrganizeRowItemUM.Token -> tokensUM.tokenList.divideMovingItem(item) + }.toPersistentList(), + ) + + draggingListState = tokensUM + } + + override fun onItemDraggingEnd() { + val draggingItem = draggingItem ?: return + + dragAndDropUpdates.value = DragOperation( + type = DragOperation.Type.End(isItemsOrderChanged = checkIsItemsOrderChanged(tokensUM)), + tokenList = when (draggingItem) { + is OrganizeRowItemUM.Network -> draggableGroupsOperations + .expandGroups(tokensUM.tokenList, tokensUM.isAccountsMode) + .uniteItems(tokensUM.isAccountsMode) + is OrganizeRowItemUM.Token -> tokensUM.tokenList.uniteItems(tokensUM.isAccountsMode) + is OrganizeRowItemUM.Placeholder, + is OrganizeRowItemUM.Portfolio, + -> tokensUM.tokenList + }.toPersistentList(), + ) + + this.draggingItem = null + } + + override fun onItemDragged(from: ItemPosition, to: ItemPosition) { + dragAndDropUpdates.value = DragOperation( + type = DragOperation.Type.Dragged, + tokenList = tokensUM.tokenList.mutate { + it.add(to.index, it.removeAt(from.index)) + }.toPersistentList(), + ) + } + + private fun findItemsToMove( + items: List, + moveOverItemKey: Any?, + movedItemKey: Any?, + ): Pair { + var moveOverItem: OrganizeRowItemUM? = null + var movedItem: OrganizeRowItemUM? = null + + for (item in items) { + if (item.id == moveOverItemKey) { + moveOverItem = item + } + if (item.id == movedItemKey) { + movedItem = item + } + if (moveOverItem != null && movedItem != null) { + break + } + } + + return Pair(moveOverItem, movedItem) + } + + private fun checkCanMoveHeaderOver(item: OrganizeRowItemUM.Network, moveOverItem: OrganizeRowItemUM) = + when (moveOverItem) { + // Header can be moved only in its account + is OrganizeRowItemUM.Placeholder -> item.accountId == moveOverItem.accountId + else -> false + } + + private fun checkCanMoveTokenOver( + item: OrganizeRowItemUM.Token, + moveOverItem: OrganizeRowItemUM, + isGrouped: Boolean, + isAccountsMode: Boolean, + ): Boolean { + return when (moveOverItem) { + is OrganizeRowItemUM.Network -> false // Token item can not be moved to group item + is OrganizeRowItemUM.Token -> when { + // Token item can be moved only in its group + isGrouped -> item.groupId == moveOverItem.groupId + + // Token item can be moved only in its account + isAccountsMode -> item.accountId == moveOverItem.accountId + + // If ungrouped and not accounts mode then item can be moved anywhere + else -> true + } + is OrganizeRowItemUM.Portfolio, + is OrganizeRowItemUM.Placeholder, + -> false // Token item can not be moved to portfolio or placeholder + } + } + + private fun checkIsItemsOrderChanged(tokensUM: OrganizeTokensUM): Boolean { + fun OrganizeTokensUM?.getItemsIds(): List? = this?.tokenList?.mapNotNull { item -> + if (item is OrganizeRowItemUM.Placeholder) { + null + } else { + item.id + } + } + + return tokensUM.getItemsIds() != draggingListState.getItemsIds() + } + + private fun List.divideMovingItem(movingItem: OrganizeRowItemUM): List { + val mutableList = this.toMutableList() + val listIterator = mutableList.listIterator() + + while (listIterator.hasNext()) { + val item = listIterator.next() + + if (item.id == movingItem.id) { + val dividedItem = movingItem + .updateRoundingMode(RoundingModeUM.All()) + .updateShadowVisibility(show = true) + + listIterator.set(dividedItem) + break + } + } + + return mutableList + } + + data class DragOperation( + val type: Type, + val tokenList: PersistentList, + ) { + + sealed class Type { + + data object Start : Type() + + data object Dragged : Type() + + data class End(val isItemsOrderChanged: Boolean) : Type() + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/dnd/DragAndDropAdapterLegacy.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/dnd/DragAndDropAdapterLegacy.kt index f6de70d6e6..94308476d9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/dnd/DragAndDropAdapterLegacy.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/dnd/DragAndDropAdapterLegacy.kt @@ -1,6 +1,7 @@ package com.tangem.feature.wallet.child.organizetokens.model.dnd import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensListUM import com.tangem.feature.wallet.child.organizetokens.model.DragAndDropIntents import com.tangem.feature.wallet.child.organizetokens.model.common.divideMovingItem @@ -90,6 +91,8 @@ internal class DragAndDropAdapterLegacy( draggingListState = tokenListUM } + override fun onItemDraggingStart(item: OrganizeRowItemUM) { /* no-op */ } + override fun onItemDraggingEnd() { val draggingItem = draggingItem ?: return diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/dnd/DraggableGroupsOperations.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/dnd/DraggableGroupsOperations.kt index 6487f86b78..02230787ee 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/dnd/DraggableGroupsOperations.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/dnd/DraggableGroupsOperations.kt @@ -1,11 +1,14 @@ package com.tangem.feature.wallet.child.organizetokens.model.dnd import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM import com.tangem.feature.wallet.child.organizetokens.model.common.divideMovingItem +import com.tangem.feature.wallet.child.organizetokens.model.common.getGroupPlaceholder import com.tangem.feature.wallet.child.organizetokens.model.common.getGroupPlaceholderLegacy internal class DraggableGroupsOperations { + private var groupIdToTokens: Map>? = null private var groupIdToTokensLegacy: Map>? = null fun collapseGroupLegacy(items: List, movingGroup: DraggableItem.GroupHeader): List { @@ -67,4 +70,67 @@ internal class DraggableGroupsOperations { return expandedGroups } + + fun expandGroups(items: List, isAccountsMode: Boolean): List { + if (groupIdToTokens.isNullOrEmpty()) return items + + val accountList = items.filterIsInstance() + val currentGroups = items.filterIsInstance() + + val expandedGroups = if (isAccountsMode) { + accountList + .asSequence() + .flatMap { account -> + buildList { + add(account) + currentGroups + .asSequence() + .filter { it.accountId == account.id } + .forEachIndexed { index, group -> + if (index == 0) { + add(getGroupPlaceholder(accountId = group.accountId, index = -1)) + } + add(group) + addAll(groupIdToTokens?.get(group.id).orEmpty()) + add(getGroupPlaceholder(accountId = group.accountId, index = index)) + } + } + } + } else { + currentGroups + .asSequence() + .flatMapIndexed { index, group -> + buildList { + if (index == 0) { + add(getGroupPlaceholder(accountId = group.accountId, index = -1)) + } + add(group) + addAll(groupIdToTokens?.get(group.id).orEmpty()) + add(getGroupPlaceholder(accountId = group.accountId, index = index)) + } + } + }.toList() + + groupIdToTokens = null + + return expandedGroups + } + + fun collapseGroup( + items: List, + movingGroup: OrganizeRowItemUM.Network, + ): List { + if (!groupIdToTokens.isNullOrEmpty()) return items + + groupIdToTokens = items + .asSequence() + .filterIsInstance() + .groupBy { it.groupId } + + val itemsWithoutGroupTokens = items.filterNot { + it is OrganizeRowItemUM.Token && it.groupId == movingGroup.id + } + + return itemsWithoutGroupTokens + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeContentStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeContentStateTransformer.kt new file mode 100644 index 0000000000..3396068acb --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeContentStateTransformer.kt @@ -0,0 +1,39 @@ +package com.tangem.feature.wallet.child.organizetokens.model.transformer + +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM +import com.tangem.feature.wallet.child.organizetokens.model.converter.OrganizeTokensListConverter +import com.tangem.utils.transformer.Transformer + +internal class OrganizeContentStateTransformer( + private val accountStatusList: AccountStatusList, + private val isAccountsMode: Boolean, + private val appCurrency: AppCurrency, +) : Transformer { + + private val tokenListConverter by lazy(LazyThreadSafetyMode.NONE) { + OrganizeTokensListConverter( + isAccountsMode = isAccountsMode, + appCurrency = appCurrency, + ) + } + + override fun transform(prevState: OrganizeTokensUM): OrganizeTokensUM { + val isGrouping = accountStatusList.groupType == TokensGroupType.NETWORK + val isSortedByBalance = accountStatusList.sortType == TokensSortType.BALANCE + + return prevState.copy( + isGrouped = isGrouping, + isAccountsMode = isAccountsMode, + tokenList = tokenListConverter.convert(value = accountStatusList), + organizeMenuUM = prevState.organizeMenuUM.copy( + isEnabled = prevState.tokenList.isNotEmpty(), + isSortedByBalance = isSortedByBalance, + isGrouped = isGrouping, + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeDisableBalanceSortingTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeDisableBalanceSortingTransformer.kt new file mode 100644 index 0000000000..1242d0f1dd --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeDisableBalanceSortingTransformer.kt @@ -0,0 +1,14 @@ +package com.tangem.feature.wallet.child.organizetokens.model.transformer + +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM +import com.tangem.utils.transformer.Transformer + +internal object OrganizeDisableBalanceSortingTransformer : Transformer { + override fun transform(prevState: OrganizeTokensUM): OrganizeTokensUM { + return prevState.copy( + organizeMenuUM = prevState.organizeMenuUM.copy( + isSortedByBalance = false, + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeSortingProgressStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeSortingProgressStateTransformer.kt new file mode 100644 index 0000000000..e16022ef42 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeSortingProgressStateTransformer.kt @@ -0,0 +1,31 @@ +package com.tangem.feature.wallet.child.organizetokens.model.transformer + +import com.tangem.core.ui.ds.button.TangemButtonState +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM +import com.tangem.utils.transformer.Transformer + +internal class OrganizeSortingProgressStateTransformer( + private val isSortingInProgress: Boolean, +) : Transformer { + override fun transform(prevState: OrganizeTokensUM): OrganizeTokensUM { + return prevState.copy( + organizeMenuUM = prevState.organizeMenuUM.copy( + isEnabled = !isSortingInProgress, + ), + cancelButton = prevState.cancelButton.copy( + state = if (isSortingInProgress) { + TangemButtonState.Disabled + } else { + TangemButtonState.Default + }, + ), + applyButton = prevState.applyButton.copy( + state = if (isSortingInProgress) { + TangemButtonState.Loading + } else { + TangemButtonState.Default + }, + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeDropDownMenu.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeDropDownMenu.kt new file mode 100644 index 0000000000..b840828ac4 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeDropDownMenu.kt @@ -0,0 +1,43 @@ +package com.tangem.feature.wallet.child.organizetokens.ui + +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.contextmenu.TangemContextMenu +import com.tangem.core.ui.ds.contextmenu.TangemContextMenuCheckboxItem +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM +import com.tangem.feature.wallet.impl.R + +@Composable +internal fun OrganizeDropDownMenu( + organizeMenuUM: OrganizeTokensUM.OrganizeMenuUM, + showDropdownMenu: Boolean, + onDropdownDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + TangemContextMenu( + expanded = showDropdownMenu, + onDismissRequest = onDropdownDismiss, + offset = DpOffset.Zero, + modifier = modifier, + ) { + TangemContextMenuCheckboxItem( + title = TextReference.Res(R.string.organize_tokens_sort_by_balance), + isChecked = organizeMenuUM.isSortedByBalance, + onClick = organizeMenuUM.onSortClick, + ) + HorizontalDivider( + thickness = 0.5.dp, + color = TangemTheme.colors2.border.neutral.quaternary, + ) + TangemContextMenuCheckboxItem( + title = TextReference.Res(R.string.organize_tokens_group), + isChecked = organizeMenuUM.isGrouped, + onClick = organizeMenuUM.onGroupClick, + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt new file mode 100644 index 0000000000..1de1a10cab --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt @@ -0,0 +1,371 @@ +package com.tangem.feature.wallet.child.organizetokens.ui + +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import com.tangem.core.ui.components.BottomFade +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.ds.button.TangemButton +import com.tangem.core.ui.ds.row.header.TangemHeaderRow +import com.tangem.core.ui.ds.row.token.TangemTokenRow +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarActionContent +import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.reordarable.ReorderableItem +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.OrganizeTokensScreenTestTags +import com.tangem.core.ui.utils.lazyListItemPosition +import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM +import com.tangem.feature.wallet.child.organizetokens.entity.RoundingModeUM +import com.tangem.feature.wallet.child.organizetokens.model.DragAndDropIntents +import com.tangem.feature.wallet.child.organizetokens.ui.preview.OrganizeTokensPreview +import com.tangem.feature.wallet.impl.R +import dev.chrisbanes.haze.rememberHazeState +import org.burnoutcrew.reorderable.ItemPosition +import org.burnoutcrew.reorderable.ReorderableLazyListState +import org.burnoutcrew.reorderable.rememberReorderableLazyListState +import org.burnoutcrew.reorderable.reorderable + +@Composable +internal fun OrganizeTokensContent( + organizeTokensUM: OrganizeTokensUM, + dragAndDropIntents: DragAndDropIntents, + onDismiss: () -> Unit, +) { + var isShowDropdownMenu by rememberSaveable { mutableStateOf(false) } + + val hazeState = rememberHazeState() + + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + addBottomInsets = false, + containerColor = TangemTheme.colors2.surface.level2, + title = { + TangemTopBar( + title = resourceReference(R.string.organize_tokens_title), + endContent = { + TangemTopBarActionContent( + actionUM = TangemTopBarActionUM( + iconRes = R.drawable.ic_exchange_mini_24, + isActionable = true, + onClick = { isShowDropdownMenu = true }, + ghostModeProgress = 0f, + ), + iconSize = TangemTheme.dimens2.x7, + ) + OrganizeDropDownMenu( + organizeMenuUM = organizeTokensUM.organizeMenuUM, + showDropdownMenu = isShowDropdownMenu, + onDropdownDismiss = { isShowDropdownMenu = false }, + modifier = Modifier.hazeEffectTangem(hazeState), + ) + }, + ) + }, + content = { + TokenList( + organizeTokensUM = organizeTokensUM, + dragAndDropIntents = dragAndDropIntents, + modifier = Modifier.hazeSourceTangem(hazeState), + ) + }, + ) +} + +@Suppress("MagicNumber") +@Composable +private fun TokenList( + organizeTokensUM: OrganizeTokensUM, + dragAndDropIntents: DragAndDropIntents, + modifier: Modifier = Modifier, +) { + val tokensListState = rememberLazyListState() + + val hapticFeedback = LocalHapticFeedback.current + val tokenList = organizeTokensUM.tokenList + Box( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors2.surface.level2), + ) { + val onDragEnd: (Int, Int) -> Unit = remember { + { _, _ -> + dragAndDropIntents.onItemDraggingEnd() + } + } + val reorderableListState = rememberReorderableLazyListState( + onMove = dragAndDropIntents::onItemDragged, + listState = tokensListState, + canDragOver = dragAndDropIntents::canDragItemOver, + onDragEnd = onDragEnd, + ) + + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + + val listContentPadding = PaddingValues( + top = TangemTheme.dimens2.x1, + bottom = TangemTheme.dimens2.x1 + bottomBarHeight, + start = TangemTheme.dimens2.x3, + end = TangemTheme.dimens2.x3, + ) + + LazyColumn( + modifier = Modifier + .align(Alignment.TopCenter) + .reorderable(reorderableListState) + .testTag(OrganizeTokensScreenTestTags.TOKENS_LAZY_LIST) + .hazeSourceTangem(zIndex = 1f), + state = reorderableListState.listState, + contentPadding = listContentPadding, + ) { + itemsIndexed( + items = tokenList, + key = { _, item -> item.id }, + ) { index, item -> + + val onDragStart = remember(item) { + { + dragAndDropIntents.onItemDraggingStart(item) + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } + } + + DraggableItem( + index = index, + item = item, + reorderableState = reorderableListState, + onDragStart = onDragStart, + isBalanceHidden = organizeTokensUM.isBalanceHidden, + ) + } + } + + BottomFade( + gradientBrush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + TangemTheme.colors2.surface.level2.copy(0.9f), + TangemTheme.colors2.surface.level2, + ), + ), + modifier = Modifier.align(Alignment.BottomCenter), + ) + + BottomButtons(organizeTokensUM = organizeTokensUM) + } +} + +@Composable +private fun LazyItemScope.DraggableItem( + index: Int, + item: OrganizeRowItemUM, + reorderableState: ReorderableLazyListState, + onDragStart: () -> Unit, + isBalanceHidden: Boolean, +) { + var isDragging by remember { + mutableStateOf(value = false) + } + + val itemModifier = Modifier.applyShapeAndShadow(item.roundingModeUM, item.isShowShadow) + + ReorderableItem( + reorderableState = reorderableState, + index = index, + key = item.id, + ) { isItemDragging -> + isDragging = isItemDragging + + val modifierWithBackground = itemModifier + .background(color = TangemTheme.colors.background.primary) + .semantics { lazyListItemPosition = index } + + when (item) { + is OrganizeRowItemUM.Network -> TangemHeaderRow( + modifier = modifierWithBackground, + reorderableState = reorderableState, + headerRowUM = item.headerRowUM, + ) + is OrganizeRowItemUM.Portfolio -> TangemHeaderRow( + modifier = modifierWithBackground, + headerRowUM = item.headerRowUM, + isBalanceHidden = isBalanceHidden, + ) + is OrganizeRowItemUM.Token -> TangemTokenRow( + modifier = modifierWithBackground, + tokenRowUM = item.tokenRowUM, + reorderableState = reorderableState, + isBalanceHidden = isBalanceHidden, + ) + // Should be presented in the list but remain invisible + is OrganizeRowItemUM.Placeholder -> Box(modifier = Modifier.fillMaxWidth()) + } + } + + DisposableEffect(isDragging) { + onDispose { + if (isDragging) { + onDragStart() + } + } + } +} + +@Composable +private fun BoxScope.BottomButtons(organizeTokensUM: OrganizeTokensUM) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(bottom = bottomBarHeight + TangemTheme.dimens2.x4), + ) { + TangemButton( + buttonUM = organizeTokensUM.cancelButton, + modifier = Modifier.weight(1f), + ) + TangemButton( + buttonUM = organizeTokensUM.applyButton, + modifier = Modifier.weight(1f), + ) + } +} + +private fun Modifier.applyShapeAndShadow(roundingMode: RoundingModeUM, showShadow: Boolean): Modifier { + return composed { + val radius by animateDpAsState( + targetValue = when (roundingMode) { + is RoundingModeUM.None -> TangemTheme.dimens2.x0 + is RoundingModeUM.All -> TangemTheme.dimens2.x3 + is RoundingModeUM.Bottom, + is RoundingModeUM.Top, + -> TangemTheme.dimens2.x4 + }, + label = "item_shape_radius", + ) + val elevation by animateDpAsState( + targetValue = if (showShadow) { + TangemTheme.dimens2.x2 + } else { + TangemTheme.dimens2.x0 + }, + label = "item_elevation", + ) + + this + .padding(paddingValues = getItemGap(roundingMode)) + .shadow( + elevation = elevation, + shape = getItemShape(roundingMode, radius), + clip = true, + ) + } +} + +@Composable +@ReadOnlyComposable +private fun getItemGap(roundingMode: RoundingModeUM): PaddingValues { + val paddingValue = TangemTheme.dimens2.x1 + + return if (roundingMode.isShowGap) { + when (roundingMode) { + is RoundingModeUM.None -> PaddingValues(all = TangemTheme.dimens2.x0) + is RoundingModeUM.All -> PaddingValues(vertical = paddingValue) + is RoundingModeUM.Top -> PaddingValues(top = paddingValue) + is RoundingModeUM.Bottom -> PaddingValues(bottom = paddingValue) + } + } else { + PaddingValues(all = TangemTheme.dimens2.x0) + } +} + +@Stable +private fun getItemShape(roundingMode: RoundingModeUM, radius: Dp): Shape { + return when (roundingMode) { + is RoundingModeUM.None -> RectangleShape + is RoundingModeUM.Top -> RoundedCornerShape( + topStart = radius, + topEnd = radius, + ) + is RoundingModeUM.Bottom -> RoundedCornerShape( + bottomStart = radius, + bottomEnd = radius, + ) + is RoundingModeUM.All -> RoundedCornerShape( + size = radius, + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun OrganizeTokensContent_Preview( + @PreviewParameter(OrganizeTokensContentPreviewProvider::class) params: OrganizeTokensUM, +) { + TangemThemePreviewRedesign { + OrganizeTokensContent( + organizeTokensUM = params, + dragAndDropIntents = object : DragAndDropIntents { + override fun onItemDragged(from: ItemPosition, to: ItemPosition) {} + + override fun canDragItemOver(dragOver: ItemPosition, dragging: ItemPosition): Boolean = false + + override fun onItemDraggingStartLegacy(item: DraggableItem) {} + + override fun onItemDraggingStart(item: OrganizeRowItemUM) {} + + override fun onItemDraggingEnd() {} + }, + onDismiss = {}, + ) + } +} + +private class OrganizeTokensContentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + OrganizeTokensPreview.defaultState, + OrganizeTokensPreview.defaultState.copy(isGrouped = false), + ) +} +// endregion \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/preview/OrganizeTokensPreview.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/preview/OrganizeTokensPreview.kt new file mode 100644 index 0000000000..8ca6f08e99 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/preview/OrganizeTokensPreview.kt @@ -0,0 +1,129 @@ +package com.tangem.feature.wallet.child.organizetokens.ui.preview + +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.header.TangemHeaderRowUM +import com.tangem.core.ui.ds.row.internal.TangemRowTailUM +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM +import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM +import com.tangem.feature.wallet.child.organizetokens.entity.RoundingModeUM +import com.tangem.feature.wallet.impl.R +import kotlinx.collections.immutable.toPersistentList +import java.util.UUID + +internal object OrganizeTokensPreview { + + private const val networksSize = 10 + private const val tokensSize = 3 + + private val draggableToken = TangemTokenRowUM.Actionable( + id = UUID.randomUUID().toString(), + headIconUM = TangemIconUM.Currency( + CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_polygon_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ), + titleUM = TangemTokenRowUM.TitleUM.Content(stringReference(value = "Polygon")), + subtitleUM = TangemTokenRowUM.SubtitleUM.Empty, + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("$ 42,900.13"), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("733,71097 POL"), + ), + tailUM = TangemRowTailUM.Draggable(R.drawable.ic_group_drop_24), + onItemClick = null, + onItemLongClick = null, + ) + + private val tokenList = List(networksSize) { it } + .flatMap { index -> + val lastNetworkIndex = networksSize - 1 + val lastTokenIndex = tokensSize - 1 + val networkNumber = index + 1 + + val group = OrganizeRowItemUM.Network( + headerRowUM = TangemHeaderRowUM( + id = networkNumber.toString(), + title = stringReference(value = "$networkNumber"), + ), + roundingModeUM = when (index) { + 0 -> RoundingModeUM.Top() + lastNetworkIndex -> RoundingModeUM.Bottom() + else -> RoundingModeUM.None + }, + accountId = "account_$networkNumber", + ) + + val tokens: MutableList = mutableListOf() + repeat(times = tokensSize) { i -> + val tokenNumber = i + 1 + tokens.add( + OrganizeRowItemUM.Token( + tokenRowUM = draggableToken.copy( + id = "${group.id}_token_$tokenNumber", + titleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference(value = "Token $tokenNumber from $networkNumber network"), + ), + ), + groupId = group.id, + accountId = "account_$networkNumber", + roundingModeUM = when { + i == lastTokenIndex && index == lastNetworkIndex -> RoundingModeUM.Bottom() + else -> RoundingModeUM.None + }, + ), + ) + } + + val divider = OrganizeRowItemUM.Placeholder( + id = "divider_$networkNumber", + accountId = "account_$networkNumber", + ) + + buildList { + add(group) + addAll(tokens) + if (index != lastNetworkIndex) { + add(divider) + } + } + } + .toPersistentList() + + val defaultState by lazy { + OrganizeTokensUM( + tokenList = tokenList, + organizeMenuUM = OrganizeTokensUM.OrganizeMenuUM( + onSortClick = {}, + onGroupClick = {}, + ), + cancelButton = TangemButtonUM( + text = resourceReference(R.string.common_cancel), + onClick = {}, + type = TangemButtonType.Secondary, + ), + applyButton = TangemButtonUM( + text = resourceReference(R.string.common_apply), + onClick = {}, + type = TangemButtonType.Primary, + ), + scrollListToTop = consumedEvent(), + isAccountsMode = true, + isBalanceHidden = true, + isGrouped = false, + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index f7c026a60f..840e428d26 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -21,6 +21,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableDialogComponent import com.tangem.domain.tokens.model.details.TokenAction +import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent import com.tangem.feature.wallet.child.tokenActions.TokenActionsComponent import com.tangem.feature.wallet.child.wallet.model.WalletModel import com.tangem.feature.wallet.navigation.WalletRoute @@ -146,6 +147,15 @@ internal class WalletComponent @AssistedInject constructor( ), ) } + is WalletDialogConfig.OrganizeTokens -> { + OrganizeTokensComponent( + appComponentContext = childByContext(componentContext), + params = OrganizeTokensComponent.Params( + userWalletId = dialogConfig.userWalletId, + callback = model.innerWalletRouter.organizeCallbacks, + ), + ) + } } }, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 59252f1620..57ad1e77a7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -2,10 +2,12 @@ package com.tangem.feature.wallet.presentation.router import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency @@ -18,6 +20,7 @@ import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.redux.StateDialog import com.tangem.domain.tokens.model.details.NavigationAction import com.tangem.domain.tokens.model.details.TokenAction +import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent import com.tangem.feature.wallet.navigation.WalletRoute import com.tangem.feature.wallet.presentation.wallet.state.model.TokenActionButtonUM import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig @@ -32,6 +35,7 @@ internal class DefaultWalletRouter @Inject constructor( private val router: AppRouter, private val urlOpener: UrlOpener, private val reduxStateHolder: ReduxStateHolder, + private val designFeatureToggles: DesignFeatureToggles, ) : InnerWalletRouter { override val dialogNavigation: SlotNavigation = SlotNavigation() @@ -41,8 +45,17 @@ internal class DefaultWalletRouter @Inject constructor( onBufferOverflow = BufferOverflow.DROP_LATEST, ) + override val organizeCallbacks: OrganizeTokensComponent.Callback + get() = OrganizeCallbacks() + override fun openOrganizeTokensScreen(userWalletId: UserWalletId) { - navigateToFlow.tryEmit(WalletRoute.OrganizeTokens(userWalletId)) + if (designFeatureToggles.isRedesignEnabled) { + dialogNavigation.activate( + configuration = WalletDialogConfig.OrganizeTokens(userWalletId), + ) + } else { + navigateToFlow.tryEmit(WalletRoute.OrganizeTokens(userWalletId)) + } } override fun openDetailsScreen(selectedWalletId: UserWalletId) { @@ -165,4 +178,10 @@ internal class DefaultWalletRouter @Inject constructor( override fun openQrScanner() { router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.MainScreen)) } + + inner class OrganizeCallbacks : OrganizeTokensComponent.Callback { + override fun onDismiss() { + dialogNavigation.dismiss() + } + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index 53115f3db2..b05530a666 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -13,6 +13,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayDetailsConfig import com.tangem.domain.tokens.model.details.NavigationAction import com.tangem.domain.tokens.model.details.TokenAction +import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent import com.tangem.feature.wallet.navigation.WalletRoute import com.tangem.feature.wallet.presentation.wallet.state.model.TokenActionButtonUM import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig @@ -34,6 +35,8 @@ internal interface InnerWalletRouter { val navigateToFlow: SharedFlow + val organizeCallbacks: OrganizeTokensComponent.Callback + /** Open organize tokens screen */ fun openOrganizeTokensScreen(userWalletId: UserWalletId) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt index e1144d4983..01e89e2b5e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt @@ -41,4 +41,7 @@ internal sealed interface WalletDialogConfig { @Serializable data class KycRejected(val walletId: UserWalletId, val customerId: String) : WalletDialogConfig + + @Serializable + data class OrganizeTokens(val userWalletId: UserWalletId) : WalletDialogConfig } \ No newline at end of file From 8a4dbe1b4c229b1874e0dec6e347f1b666983d00 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 10 Mar 2026 15:11:56 +0300 Subject: [PATCH 12/60] Updated on 2026-08-14 --- app/build.gradle.kts | 1 + .../common/log/TangemAppLoggerInitializer.kt | 100 +++++++++++++++--- gradle/dependencies.toml | 2 + 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9644d056cb..2d10257dfe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -366,6 +366,7 @@ dependencies { implementation(deps.googlePlay.advertising) coreLibraryDesugaring(deps.desugar) implementation(deps.timber) + implementation(deps.kermit) implementation(deps.reKotlin) implementation(deps.zxing.qrCore) implementation(deps.coil) diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt b/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt index 924cbede00..8eaafd7505 100644 --- a/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt +++ b/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt @@ -1,11 +1,17 @@ package com.tangem.tap.common.log +import android.os.Build import android.util.Log +import co.touchlab.kermit.BaseLogger +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity import com.orhanobut.logger.AndroidLogAdapter -import com.orhanobut.logger.Logger import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.wallet.BuildConfig import timber.log.Timber +import java.util.regex.Pattern +import com.orhanobut.logger.Logger as PrettyLogger /** * Tangem app logger @@ -21,32 +27,102 @@ class TangemAppLoggerInitializer( /** Initialize */ fun initialize() { if (IS_LOG_ENABLED) { - Logger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy())) + PrettyLogger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy())) } Timber.plant(tree = createTimberTree()) + Logger.setLogWriters(KermitLogWriter(::finalLogOutput)) } private fun createTimberTree(): Timber.Tree { return object : Timber.DebugTree() { override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { - if (IS_LOG_ENABLED) { - Logger.log(priority, tag, message, t) - } - - if (PERMITTED_PRIORITY.contains(priority)) { - appLogsStore.saveLogMessage( - tag = tag ?: "TangemAppLogger", - message = message, - ) - } + finalLogOutput(priority = priority, tag = tag, message = message, t = t) } } } + private fun finalLogOutput(priority: Int, tag: String?, message: String, t: Throwable?) { + if (IS_LOG_ENABLED) { + PrettyLogger.log(priority, tag, message, t) + } + + if (PERMITTED_PRIORITY.contains(priority)) { + appLogsStore.saveLogMessage( + tag = tag ?: "TangemAppLogger", + message = message, + ) + } + } + @Suppress("BooleanPropertyNaming") private companion object { val IS_LOG_ENABLED: Boolean = BuildConfig.LOG_ENABLED val PERMITTED_PRIORITY = listOf(Log.ERROR, Log.INFO) } +} + +private class KermitLogWriter( + private val finalLogOutput: (priority: Int, tag: String?, message: String, t: Throwable?) -> Unit, +) : LogWriter() { + + private val fqcnIgnore = setOf( + LogWriter::class.java.name, + KermitLogWriter::class.java.name, + BaseLogger::class.java.name, + Logger::class.java.name, + ) + + override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + val priority = when (severity) { + Severity.Verbose -> PrettyLogger.VERBOSE + Severity.Debug -> PrettyLogger.DEBUG + Severity.Info -> PrettyLogger.INFO + Severity.Warn -> PrettyLogger.WARN + Severity.Error -> PrettyLogger.ERROR + Severity.Assert -> PrettyLogger.ASSERT + } + + val finalTag = if (tag != KERMIT_LOGGER_DEFAULT_TAG) { + tag + } else { + /** + * like in [Timber.DebugTree.tag] + */ + @Suppress("UnnecessaryLet", "ThrowingExceptionsWithoutMessageOrCause") + Throwable().stackTrace + .first { it.className !in fqcnIgnore } + .let(::createStackElementTag) + } + + finalLogOutput(priority, finalTag, message, throwable) + } + + /** + * copy from [Timber.DebugTree.createStackElementTag] + */ + @Suppress("MagicNumber") + private fun createStackElementTag(element: StackTraceElement): String? { + var tag = element.className.substringAfterLast('.') + val m = ANONYMOUS_CLASS.matcher(tag) + if (m.find()) { + tag = m.replaceAll("") + } + // Tag length limit was removed in API 26. + return if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) { + tag + } else { + tag.substring(0, MAX_TAG_LENGTH) + } + } + + private companion object { + private const val KERMIT_LOGGER_DEFAULT_TAG = "" + + /** + * copy from [Timber.DebugTree.Companion] + */ + private const val MAX_TAG_LENGTH = 23 + private val ANONYMOUS_CLASS = Pattern.compile("(\\$\\d+)+$") + } } \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 759030eecc..4cde3e5c09 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -78,6 +78,7 @@ retrofit = "2.11.0" retrofitMoshiConverter = "2.9.0" spongycastleCryptoCore = "1.58.0.0" timber = "4.7.1" +kermit = "2.1.0" viewBindingDelegate = "1.5.9" xmlShimmer = "1.1.3" zxingQrCode = "3.5.1" @@ -284,6 +285,7 @@ retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit retrofit-response-type-keeper = { module = "com.squareup.retrofit2:response-type-keeper", version.ref = "retrofit" } retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofitMoshiConverter" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } viewBindingDelegate = { module = "com.github.kirich1409:viewbindingpropertydelegate-noreflection", version.ref = "viewBindingDelegate" } xmlShimmer = { module = "com.github.skydoves:androidveil", version.ref = "xmlShimmer" } zxing-qrCore = { module = "com.google.zxing:core", version.ref = "zxingQrCode" } From 8a79e65e2e97900347a28dcda647392f95248654 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 10 Mar 2026 15:12:44 +0300 Subject: [PATCH 13/60] Updated on 2026-08-14 --- .../tap/di/domain/QrScanningDomainModule.kt | 7 + .../qrscanning/di/QrScanningDataModule.kt | 8 +- .../parser/QrContentClassifierParser.kt | 94 ++++++ .../data/qrscanning/parser/QrSentUriParser.kt | 61 ++++ .../DefaultQrScanningEventsRepository.kt | 133 ++------ .../DefaultQrScanningEventsRepositoryTest.kt | 4 +- .../qrscanning/QrContentClassifierTest.kt | 305 ++++++++++++++++++ domain/qr-scanning/models/build.gradle.kts | 5 + .../qrscanning/models/ClassifiedQrContent.kt | 23 ++ .../repository/QrScanningEventsRepository.kt | 3 + .../usecases/ClassifyQrCodeUseCase.kt | 13 + features/wallet/impl/build.gradle.kts | 4 + .../wallet/child/wallet/model/WalletModel.kt | 73 +++++ .../router/DefaultWalletRouter.kt | 18 ++ .../presentation/router/InnerWalletRouter.kt | 3 + .../wallet/qr/ClassifiedQrContent.kt | 23 ++ .../wallet/qr/QrContentClassifier.kt | 120 +++++++ .../wallet/qr/QrContentClassifierTest.kt | 303 +++++++++++++++++ 18 files changed, 1099 insertions(+), 101 deletions(-) create mode 100644 data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt create mode 100644 data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt create mode 100644 data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt create mode 100644 domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt create mode 100644 domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/ClassifiedQrContent.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifier.kt create mode 100644 features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt index ec86de8c46..25a32f91ed 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt @@ -1,6 +1,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository +import com.tangem.domain.qrscanning.usecases.ClassifyQrCodeUseCase import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase @@ -31,4 +32,10 @@ internal object QrScanningDomainModule { fun provideParseQrCodeUseCase(repository: QrScanningEventsRepository): ParseQrCodeUseCase { return ParseQrCodeUseCase(repository) } + + @Provides + @Singleton + fun provideClassifyQrCodeUseCase(repository: QrScanningEventsRepository): ClassifyQrCodeUseCase { + return ClassifyQrCodeUseCase(repository) + } } \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt index efe697f667..9fd1b269d8 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt @@ -1,5 +1,6 @@ package com.tangem.data.qrscanning.di +import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository import dagger.Module @@ -15,6 +16,11 @@ internal object QrScanningDataModule { @Provides @Singleton fun provideQrScanningEventsRepository(): QrScanningEventsRepository { - return DefaultQrScanningEventsRepository() + return DefaultQrScanningEventsRepository( + qrContentClassifierParser = QrContentClassifierParser( + QrContentClassifierParser.DefaultBlockchainDataProvider + (), + ), + ) } } \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt new file mode 100644 index 0000000000..7a881c2a6f --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt @@ -0,0 +1,94 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import java.net.URLDecoder + +internal class QrContentClassifierParser( + private val blockchainDataProvider: BlockchainDataProvider, + private val paymentUriParser: QrSentUriParser = QrSentUriParser(), +) { + + fun parse(qrCode: String, userCurrencies: List): ClassifiedQrContent { + if (qrCode.startsWith(WC_PREFIX)) { + return ClassifiedQrContent.WalletConnect(qrCode) + } + + if (isDAppWcUrl(qrCode)) { + return ClassifiedQrContent.WalletConnect(qrCode) + } + + val coins = userCurrencies.filterIsInstance() + val uniqueCoins = coins.distinctBy { it.network.id } + + val paymentUri = tryParsePaymentUri(qrCode, uniqueCoins) + if (paymentUri != null) return paymentUri + + val matchingCurrencies = uniqueCoins.filter { coin -> + blockchainDataProvider.validateAddress(coin.network, qrCode) + } + + if (matchingCurrencies.isNotEmpty()) { + return ClassifiedQrContent.PlainAddress( + address = qrCode, + matchingCurrencies = matchingCurrencies, + ) + } + + return ClassifiedQrContent.Unknown(qrCode) + } + + private fun tryParsePaymentUri(qrCode: String, coins: List): ClassifiedQrContent.PaymentUri? { + return coins.firstNotNullOfOrNull { coin -> + val matchedScheme = blockchainDataProvider.getShareSchemes(coin.network) + .sortedByDescending { it.length } + .firstOrNull { qrCode.startsWith(it) } + ?: return@firstNotNullOfOrNull null + + val withoutScheme = qrCode.removePrefix(matchedScheme) + val parsed = paymentUriParser.parse(withoutScheme) ?: return@firstNotNullOfOrNull null + + ClassifiedQrContent.PaymentUri( + currency = coin, + address = parsed.address, + amount = parsed.amount, + memo = parsed.memo, + ) + } + } + + private fun isDAppWcUrl(qrCode: String): Boolean { + if (!qrCode.startsWith(HTTP_PREFIX) && !qrCode.startsWith(HTTPS_PREFIX)) return false + + val uriParam = paymentUriParser.extractParameters(qrCode)[PARAM_URI] ?: return false + val decodedUri = runCatching { URLDecoder.decode( + uriParam, + QrSentUriParser.CHARSET_UTF8, + ) }.getOrDefault(uriParam) + return decodedUri.startsWith(WC_PREFIX) + } + + internal interface BlockchainDataProvider { + fun getShareSchemes(network: Network): List + fun validateAddress(network: Network, address: String): Boolean + } + + internal class DefaultBlockchainDataProvider : BlockchainDataProvider { + override fun getShareSchemes(network: Network): List { + return runCatching { network.toBlockchain().getShareScheme() }.getOrDefault(emptyList()) + } + + override fun validateAddress(network: Network, address: String): Boolean { + return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false) + } + } + + private companion object { + const val HTTP_PREFIX = "http://" + const val HTTPS_PREFIX = "https://" + const val PARAM_URI = "uri" + const val WC_PREFIX = "wc:" + } +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt new file mode 100644 index 0000000000..ea80fb564d --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt @@ -0,0 +1,61 @@ +package com.tangem.data.qrscanning.parser + +import java.math.BigDecimal +import java.net.URLDecoder + +internal class QrSentUriParser { + + data class Result( + val address: String, + val amount: BigDecimal?, + val memo: String?, + val params: Map, + ) + + fun parse(withoutScheme: String): Result? { + val address = withoutScheme.takeWhile { + it != CHAIN_DELIMITER && it != FUNCTION_DELIMITER && it != PARAM_DELIMITER + } + if (address.isBlank()) return null + + val params = extractParameters(withoutScheme) + val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull() + val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let { + runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it) + } + + return Result( + address = address, + amount = amount, + memo = memo, + params = params, + ) + } + + fun extractParameters(from: String): Map { + val paramsBlock = from.substringAfter(PARAM_DELIMITER, missingDelimiterValue = "") + if (paramsBlock.isBlank()) return emptyMap() + + return paramsBlock.split(PARAMS_DELIMITER) + .mapNotNull { param -> + val parts = param.split(PARAM_VALUE_DELIMITER, limit = 2) + if (parts.size == 2) parts[0].lowercase() to parts[1] else null + } + .toMap() + } + + companion object { + const val CHAIN_DELIMITER = '@' + const val FUNCTION_DELIMITER = '/' + const val PARAM_DELIMITER = '?' + const val PARAMS_DELIMITER = '&' + const val PARAM_VALUE_DELIMITER = '=' + const val PARAM_AMOUNT = "amount" + const val PARAM_MEMO = "memo" + const val PARAM_MESSAGE = "message" + const val PARAM_ADDRESS = "address" + const val PARAM_VALUE = "value" + const val PARAM_UINT256 = "uint256" + const val CHARSET_UTF8 = "UTF-8" + } +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt index c5573994ce..33d6158669 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt @@ -2,7 +2,10 @@ package com.tangem.data.qrscanning.repository import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.core.ui.utils.parseBigDecimalOrNull +import com.tangem.data.qrscanning.parser.QrSentUriParser +import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.domain.qrscanning.models.QrResult import com.tangem.domain.qrscanning.models.RawQrResult import com.tangem.domain.qrscanning.models.SourceType @@ -14,9 +17,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.yield import java.math.BigDecimal -import java.net.URLDecoder -internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository { +internal class DefaultQrScanningEventsRepository( + private val qrContentClassifierParser: QrContentClassifierParser, + private val paymentUriParser: QrSentUriParser = QrSentUriParser(), +) : QrScanningEventsRepository { private data class QrScanningEvent(val qrCode: RawQrResult) @@ -37,120 +42,50 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository { override fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult { val withoutSchema = stripSchema(qrCode, cryptoCurrency) + val parsed = paymentUriParser.parse(withoutSchema) + ?: return QrResult(address = withoutSchema) - // A poor man's ERC-681 parser: we want to extract only the destination address, and we don't care - // about other parts of the ERC-681 payload string like `chain_id` and/or `function_name`. - // - // We're extracting the destination address by parsing the given string until we meet - // any of the possible string delimiters (@ ? /). - val address = withoutSchema.takeWhile { char -> - char != CHAIN_DELIMITER && char != FUNCTION_DELIMITER && char != PARAM_DELIMITER + val result = QrResult(address = parsed.address) + result.amount = parsed.amount + result.memo = parsed.memo + + // ERC-681: if 'address' parameter exists, currency must be a token, + // and the URI address must match the token's contract address. + parsed.params[QrSentUriParser.PARAM_ADDRESS]?.let { addressValue -> + val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult() + if (tokenCurrency.contractAddress.equals(parsed.address, ignoreCase = true)) { + result.address = addressValue + } else { + return QrResult() + } } - val result = QrResult(address = address) - - extractParameters(withoutSchema) - .forEach { entry -> - when (entry.key) { - Parameter.Amount -> { - // According to BIP-0021, the value is specified in decimals. No conversion needed - result.amount = entry.value.parseBigDecimalOrNull() - } - Parameter.Message, - Parameter.Memo, - -> { - result.memo = URLDecoder.decode(entry.value, "UTF-8") - } - Parameter.Address -> { - // If 'address' parameter is exists, then currency must be TOKEN. - val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult() - - // Overrides destination address for token transfers (ERC-681) - // `address` parameter is used only if the contract address, encoded in the QR, - // matches the contract address of the token. - // Otherwise, the scanned string is likely malformed, and we stop the entire parsing routin - if (tokenCurrency.contractAddress.equals(address, ignoreCase = true)) { - result.address = entry.value - } else { - return QrResult() - } - } - Parameter.Value, - Parameter.Uint256, - -> { - // Extra convert parses scientific notation to decimal - // This is necessary to be able comparing BigDecimal values - result.amount = entry.value.parseBigDecimalOrNull() - ?.toPlainString()?.toBigDecimalOrNull() - ?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals)) - } - } - } + // ERC-681: value/uint256 is in the smallest unit, needs conversion + val valueStr = parsed.params[QrSentUriParser.PARAM_VALUE] + ?: parsed.params[QrSentUriParser.PARAM_UINT256] + if (valueStr != null) { + result.amount = valueStr.parseBigDecimalOrNull() + ?.toPlainString()?.toBigDecimalOrNull() + ?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals)) + } return result } + override fun classify(qrCode: String, userCurrencies: List): ClassifiedQrContent { + return qrContentClassifierParser.parse(qrCode, userCurrencies) + } + private fun stripSchema(raw: String, currency: CryptoCurrency): String { val qrSchemas = currency.network.toBlockchain().getShareScheme() - // The most specific (i.e. the most lengthy) prefixes always come first qrSchemas .sortedByDescending { it.length } .forEach { schema -> val stripped = raw.split(schema) - if (stripped.size > 1) return stripped.last() } return raw } - - private fun extractParameters(from: String): Map { - val parametersBlock = from.substringAfter(PARAM_DELIMITER) - if (parametersBlock.isBlank()) return emptyMap() - - val paramList = parametersBlock.split(PARAMS_DELIMITER) - .mapNotNull { param -> - val parameterWithValue = param.split(PARAM_VALUE_DELIMITER) - if (parameterWithValue.size == 2) { - val name = Parameter.getParam(parameterWithValue.first()) - val value = parameterWithValue.last() - - if (name != null) { - name to value - } else { - null - } - } else { - null - } - }.associate { it } - - return paramList - } - - private enum class Parameter { - Amount, - Message, - Memo, - Address, - Value, - Uint256, - ; - - companion object { - fun getParam(name: String): Parameter? { - return Parameter.entries.firstOrNull { it.name.equals(name, ignoreCase = true) } - } - } - } - - private companion object { - // See https://eips.ethereum.org/EIPS/eip-681 for details. - const val CHAIN_DELIMITER = '@' // ERC-681 [ "@" chain_id ] - const val FUNCTION_DELIMITER = '/' // ERC-681 [ "/" function_name ] - const val PARAM_DELIMITER = '?' // BIP-021, ERC-681 - const val PARAMS_DELIMITER = '&' - const val PARAM_VALUE_DELIMITER = '=' - } } \ No newline at end of file diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt index 5e58da8cc7..3aecfcfbfa 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt @@ -2,6 +2,7 @@ package com.tangem.data.qrscanning import com.google.common.truth.Truth import com.tangem.blockchain.common.Blockchain +import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -13,7 +14,8 @@ import java.math.BigDecimal internal class DefaultQrScanningEventsRepositoryTest { - private val repository = DefaultQrScanningEventsRepository() + private val qrContentClassifier: QrContentClassifierParser = mockk() + private val repository = DefaultQrScanningEventsRepository(qrContentClassifier) private val cryptoCurrencyId = mockk() private val network = mockk() diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt new file mode 100644 index 0000000000..b4dc4c9976 --- /dev/null +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt @@ -0,0 +1,305 @@ +package com.tangem.data.qrscanning + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.qrscanning.parser.QrContentClassifierParser +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import java.math.BigDecimal + +internal class QrContentClassifierTest { + + private val blockchainDataProvider = mockk { + every { getShareSchemes(any()) } returns emptyList() + every { validateAddress(any(), any()) } returns false + } + private val classifier = QrContentClassifierParser(blockchainDataProvider) + + // region WalletConnect + + @Test + fun `WalletConnect URI is classified correctly`() { + val uri = "wc:a4f86d5-72ac-46ad-a1aa-9e@1-f5c0c@2" + val result = classifier.parse(uri, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java) + assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(uri) + } + + @Test + fun `WalletConnect URI takes priority over address matching`() { + val uri = "wc:something" + every { blockchainDataProvider.validateAddress(any(), uri) } returns true + + val result = classifier.parse(uri, listOf(bitcoinCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java) + } + + @Test + fun `dApp URL with wc uri query param is classified as WalletConnect`() { + val dAppUrl = "https://uniswap.org/app/wc?uri=wc:6ea45@2?relay-protocol=irn&symKey=f2de&expiryTimestamp=123" + + val result = classifier.parse(dAppUrl, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java) + assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(dAppUrl) + } + + @Test + fun `HTTP URL without wc uri param is not classified as WalletConnect`() { + val url = "https://example.com/page?foo=bar" + + val result = classifier.parse(url, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `HTTP URL with uri param not starting with wc is not WalletConnect`() { + val url = "https://example.com/page?uri=https://other.com" + + val result = classifier.parse(url, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + // endregion + + // region PaymentUri + + @Test + fun `Bitcoin BIP-021 URI with amount is parsed`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5" + val result = classifier.parse(qr, listOf(bitcoinCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.currency).isEqualTo(bitcoinCoin) + assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(paymentUri.amount).isEqualTo(BigDecimal("0.5")) + assertThat(paymentUri.memo).isNull() + } + + @Test + fun `Bitcoin URI without params returns address only`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + val result = classifier.parse(qr, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(paymentUri.amount).isNull() + assertThat(paymentUri.memo).isNull() + } + + @Test + fun `Bitcoin URI with message param is parsed as memo`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.0&message=test%20memo" + val result = classifier.parse(qr, listOf(bitcoinCoin)) + + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.0")) + assertThat(paymentUri.memo).isEqualTo("test memo") + } + + @Test + fun `URI with memo parameter is parsed`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?memo=hello" + val result = classifier.parse(qr, listOf(bitcoinCoin)) + + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.memo).isEqualTo("hello") + } + + @Test + fun `Ethereum ERC-681 URI with chain_id and function is parsed`() { + every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") + + val qr = "ethereum:0x1234567890abcdef1234567890abcdef12345678@1/transfer?amount=1.5" + val result = classifier.parse(qr, listOf(ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.address).isEqualTo("0x1234567890abcdef1234567890abcdef12345678") + assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.5")) + } + + @Test + fun `URI scheme not matching user currencies falls through`() { + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + val result = classifier.parse(qr, listOf(ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `Longest matching scheme is preferred`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns + listOf("bitcoin:", "bitcoin://") + + val qr = "bitcoin://1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + val result = classifier.parse(qr, listOf(bitcoinCoin)) + + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + } + + // endregion + + // region PlainAddress + + @Test + fun `Plain address matches single currency`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + + val result = classifier.parse(address, listOf(ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.address).isEqualTo(address) + assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin) + } + + @Test + fun `Plain address matches multiple currencies`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + every { blockchainDataProvider.validateAddress(bscCoin.network, address) } returns true + + val result = classifier.parse(address, listOf(ethereumCoin, bscCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.matchingCurrencies).hasSize(2) + } + + // endregion + + // region Unknown + + @Test + fun `Random string returns Unknown`() { + val result = classifier.parse("hello world", listOf(bitcoinCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + assertThat((result as ClassifiedQrContent.Unknown).raw).isEqualTo("hello world") + } + + @Test + fun `Empty string returns Unknown`() { + val result = classifier.parse("", listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `Empty currencies list returns Unknown`() { + val result = classifier.parse("0x1234567890abcdef1234567890abcdef12345678", emptyList()) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + // endregion + + // region Edge cases + + @Test + fun `Tokens are filtered out, only Coins are used`() { + val token = CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = buildNetwork("ethereum"), + name = "USDT", + symbol = "USDT", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ) + + val result = classifier.parse("0x1234", listOf(token)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `Duplicate coins with same network are deduplicated`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + + val result = classifier.parse(address, listOf(ethereumCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.matchingCurrencies).hasSize(1) + } + + @Test + fun `Payment URI takes priority over plain address match`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + every { blockchainDataProvider.validateAddress(bitcoinCoin.network, any()) } returns true + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1" + val result = classifier.parse(qr, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + } + + // endregion + + // region Helpers + + private val bitcoinCoin = buildCoin("bitcoin") + private val ethereumCoin = buildCoin("ethereum") + private val bscCoin = buildCoin("bsc") + + private fun buildCoin(rawNetworkId: String): CryptoCurrency.Coin { + return CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId), + ), + network = buildNetwork(rawNetworkId), + name = rawNetworkId, + symbol = rawNetworkId.take(3).uppercase(), + decimals = 8, + iconUrl = null, + isCustom = false, + ) + } + + private fun buildNetwork(rawNetworkId: String): Network { + return Network( + id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), + backendId = rawNetworkId, + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + // endregion +} \ No newline at end of file diff --git a/domain/qr-scanning/models/build.gradle.kts b/domain/qr-scanning/models/build.gradle.kts index 7ff7fb7522..8a6e467961 100644 --- a/domain/qr-scanning/models/build.gradle.kts +++ b/domain/qr-scanning/models/build.gradle.kts @@ -1,4 +1,9 @@ plugins { alias(deps.plugins.kotlin.jvm) id("configuration") +} +dependencies { + + /** Domain */ + implementation(projects.domain.models) } \ No newline at end of file diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt new file mode 100644 index 0000000000..4c482b56a5 --- /dev/null +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.qrscanning.models + +import com.tangem.domain.models.currency.CryptoCurrency +import java.math.BigDecimal + +sealed class ClassifiedQrContent { + + data class WalletConnect(val uri: String) : ClassifiedQrContent() + + data class PaymentUri( + val currency: CryptoCurrency, + val address: String, + val amount: BigDecimal?, + val memo: String?, + ) : ClassifiedQrContent() + + data class PlainAddress( + val address: String, + val matchingCurrencies: List, + ) : ClassifiedQrContent() + + data class Unknown(val raw: String) : ClassifiedQrContent() +} \ No newline at end of file diff --git a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/repository/QrScanningEventsRepository.kt b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/repository/QrScanningEventsRepository.kt index 323e57d915..c9a2c43dfe 100644 --- a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/repository/QrScanningEventsRepository.kt +++ b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/repository/QrScanningEventsRepository.kt @@ -1,6 +1,7 @@ package com.tangem.domain.qrscanning.repository import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.domain.qrscanning.models.QrResult import com.tangem.domain.qrscanning.models.RawQrResult import com.tangem.domain.qrscanning.models.SourceType @@ -13,4 +14,6 @@ interface QrScanningEventsRepository { fun subscribeToScanningResults(type: SourceType): Flow fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult + + fun classify(qrCode: String, userCurrencies: List): ClassifiedQrContent } \ No newline at end of file diff --git a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt new file mode 100644 index 0000000000..16180975e0 --- /dev/null +++ b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.qrscanning.usecases + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository + +class ClassifyQrCodeUseCase( + private val repository: QrScanningEventsRepository, +) { + operator fun invoke(qrCode: String, userCurrencies: List): ClassifiedQrContent { + return repository.classify(qrCode, userCurrencies) + } +} \ No newline at end of file diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index b7e61208c1..4c28061ca1 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -97,6 +97,10 @@ dependencies { implementation(projects.domain.markets.models) implementation(projects.domain.models) implementation(projects.domain.networks) + implementation(projects.domain.qrScanning) + implementation(projects.domain.qrScanning.models) + implementation(projects.domain.walletConnect) + implementation(projects.domain.walletConnect.models) implementation(projects.domain.nft) implementation(projects.domain.nft.models) implementation(projects.domain.hotWallet) 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 94987a166e..fcc3750444 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 @@ -22,8 +22,16 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.* import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase import com.tangem.domain.notifications.repository.NotificationsRepository +import com.tangem.domain.qrscanning.models.QrResultSource +import com.tangem.domain.qrscanning.models.SourceType +import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.walletconnect.WcPairService +import com.tangem.domain.walletconnect.model.WcPairRequest import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase +import com.tangem.domain.qrscanning.usecases.ClassifyQrCodeUseCase import com.tangem.domain.settings.* import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase import com.tangem.domain.wallets.usecase.* @@ -32,6 +40,7 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.router.InnerWalletRouter import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent +import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.feature.wallet.presentation.wallet.analytics.utils.SelectedWalletAnalyticsSender import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory import com.tangem.feature.wallet.presentation.wallet.domain.WalletContentFetcher @@ -104,6 +113,10 @@ internal class WalletModel @Inject constructor( private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getWalletIconUseCase: GetWalletIconUseCase, private val walletFeatureToggles: WalletFeatureToggles, + private val listenToQrScanningUseCase: ListenToQrScanningUseCase, + private val wcPairService: WcPairService, + private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, + private val classifyQrCodeUseCase: ClassifyQrCodeUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -134,6 +147,7 @@ internal class WalletModel @Inject constructor( subscribeToScreenBackgroundState() subscribeOnPushNotificationsPermission() subscribeTangemPayOnWalletState() + subscribeToMainScreenQrScanning() enableNotificationsIfNeeded() clickIntents.initialize(innerWalletRouter, modelScope) @@ -730,6 +744,65 @@ internal class WalletModel @Inject constructor( } } + private fun subscribeToMainScreenQrScanning() { + listenToQrScanningUseCase.listen(SourceType.MAIN_SCREEN) + .getOrElse { emptyFlow() } + .onEach { rawResult -> handleQrResult(rawResult.qrCode, rawResult.resultSource) } + .launchIn(modelScope) + } + + private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) { + val userWalletId = stateHolder.getSelectedWalletId() + + val currencies = multiWalletCryptoCurrenciesSupplier + .getSyncOrNull(MultiWalletCryptoCurrenciesProducer.Params(userWalletId)) + ?.toList() + .orEmpty() + val classified = classifyQrCodeUseCase(qrCode, currencies) + + when (classified) { + is ClassifiedQrContent.WalletConnect -> { + val source = when (resultSource) { + QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD + QrResultSource.CAMERA, + QrResultSource.GALLERY, + -> WcPairRequest.Source.QR + } + wcPairService.pair( + WcPairRequest( + userWalletId = userWalletId, + uri = classified.uri, + source = source, + ), + ) + } + is ClassifiedQrContent.PaymentUri -> { + innerWalletRouter.openSend( + userWalletId = userWalletId, + currency = classified.currency, + address = classified.address, + amount = classified.amount?.toPlainString(), + tag = classified.memo, + ) + } + is ClassifiedQrContent.PlainAddress -> { + if (classified.matchingCurrencies.size == 1) { + innerWalletRouter.openSend( + userWalletId = userWalletId, + currency = classified.matchingCurrencies.first(), + address = classified.address, + amount = null, + tag = null, + ) + } + // TODO: [REDACTED_TASK_KEY] Network selection bottom sheet for multiple network matches + } + is ClassifiedQrContent.Unknown -> { + // TODO: [REDACTED_TASK_KEY] Error handling for unsupported and invalid QR codes + } + } + } + private fun enableNotificationsIfNeeded() { modelScope.launch { val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 57ad1e77a7..f123c9c3c1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -179,6 +179,24 @@ internal class DefaultWalletRouter @Inject constructor( router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.MainScreen)) } + override fun openSend( + userWalletId: UserWalletId, + currency: CryptoCurrency, + address: String, + amount: String?, + tag: String?, + ) { + router.push( + AppRoute.Send( + userWalletId = userWalletId, + currency = currency, + destinationAddress = address, + amount = amount, + tag = tag, + ), + ) + } + inner class OrganizeCallbacks : OrganizeTokensComponent.Callback { override fun onDismiss() { dialogNavigation.dismiss() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index b05530a666..d87153341b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -92,4 +92,7 @@ internal interface InnerWalletRouter { /** Open QR scanner screen */ fun openQrScanner() + + /** Open send screen with prefilled destination */ + fun openSend(userWalletId: UserWalletId, currency: CryptoCurrency, address: String, amount: String?, tag: String?) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/ClassifiedQrContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/ClassifiedQrContent.kt new file mode 100644 index 0000000000..706a82c5e8 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/ClassifiedQrContent.kt @@ -0,0 +1,23 @@ +package com.tangem.feature.wallet.presentation.wallet.qr + +import com.tangem.domain.models.currency.CryptoCurrency +import java.math.BigDecimal + +internal sealed class ClassifiedQrContent { + + data class WalletConnect(val uri: String) : ClassifiedQrContent() + + data class PaymentUri( + val currency: CryptoCurrency, + val address: String, + val amount: BigDecimal?, + val memo: String?, + ) : ClassifiedQrContent() + + data class PlainAddress( + val address: String, + val matchingCurrencies: List, + ) : ClassifiedQrContent() + + data class Unknown(val raw: String) : ClassifiedQrContent() +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifier.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifier.kt new file mode 100644 index 0000000000..dfabae82b1 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifier.kt @@ -0,0 +1,120 @@ +package com.tangem.feature.wallet.presentation.wallet.qr + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import java.net.URLDecoder + +internal class QrContentClassifier( + private val blockchainDataProvider: BlockchainDataProvider, +) { + + fun classify(qrCode: String, userCurrencies: List): ClassifiedQrContent { + if (qrCode.startsWith(WC_PREFIX)) { + return ClassifiedQrContent.WalletConnect(qrCode) + } + + if (isDAppWcUrl(qrCode)) { + return ClassifiedQrContent.WalletConnect(qrCode) + } + + val coins = userCurrencies.filterIsInstance() + val uniqueCoins = coins.distinctBy { it.network.id } + + val paymentUri = tryParsePaymentUri(qrCode, uniqueCoins) + if (paymentUri != null) return paymentUri + + val matchingCurrencies = uniqueCoins.filter { coin -> + blockchainDataProvider.validateAddress(coin.network, qrCode) + } + + if (matchingCurrencies.isNotEmpty()) { + return ClassifiedQrContent.PlainAddress( + address = qrCode, + matchingCurrencies = matchingCurrencies, + ) + } + + return ClassifiedQrContent.Unknown(qrCode) + } + + private fun tryParsePaymentUri(qrCode: String, coins: List): ClassifiedQrContent.PaymentUri? { + return coins.firstNotNullOfOrNull { coin -> + val matchedScheme = blockchainDataProvider.getShareSchemes(coin.network) + .sortedByDescending { it.length } + .firstOrNull { qrCode.startsWith(it) } + ?: return@firstNotNullOfOrNull null + + val withoutScheme = qrCode.removePrefix(matchedScheme) + val address = withoutScheme.takeWhile { + it != CHAIN_DELIMITER && it != FUNCTION_DELIMITER && it != PARAM_DELIMITER + } + val params = extractParameters(withoutScheme) + + if (address.isBlank()) return@firstNotNullOfOrNull null + + val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull() + val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let { + runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it) + } + + ClassifiedQrContent.PaymentUri( + currency = coin, + address = address, + amount = amount, + memo = memo, + ) + } + } + + private fun isDAppWcUrl(qrCode: String): Boolean { + if (!qrCode.startsWith(HTTP_PREFIX) && !qrCode.startsWith(HTTPS_PREFIX)) return false + + val uriParam = extractParameters(qrCode)[PARAM_URI] ?: return false + val decodedUri = runCatching { URLDecoder.decode(uriParam, CHARSET_UTF8) }.getOrDefault(uriParam) + return decodedUri.startsWith(WC_PREFIX) + } + + private fun extractParameters(from: String): Map { + val paramsBlock = from.substringAfter(PARAM_DELIMITER, missingDelimiterValue = "") + if (paramsBlock.isBlank()) return emptyMap() + + return paramsBlock.split(PARAMS_DELIMITER) + .mapNotNull { param -> + val parts = param.split(PARAM_VALUE_DELIMITER, limit = 2) + if (parts.size == 2) parts[0].lowercase() to parts[1] else null + } + .toMap() + } + + internal interface BlockchainDataProvider { + fun getShareSchemes(network: Network): List + fun validateAddress(network: Network, address: String): Boolean + } + + internal class DefaultBlockchainDataProvider : BlockchainDataProvider { + override fun getShareSchemes(network: Network): List { + return runCatching { network.toBlockchain().getShareScheme() }.getOrDefault(emptyList()) + } + + override fun validateAddress(network: Network, address: String): Boolean { + return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false) + } + } + + private companion object { + const val HTTP_PREFIX = "http://" + const val HTTPS_PREFIX = "https://" + const val PARAM_URI = "uri" + const val WC_PREFIX = "wc:" + const val CHAIN_DELIMITER = '@' + const val FUNCTION_DELIMITER = '/' + const val PARAM_DELIMITER = '?' + const val PARAMS_DELIMITER = '&' + const val PARAM_VALUE_DELIMITER = '=' + const val PARAM_AMOUNT = "amount" + const val PARAM_MEMO = "memo" + const val PARAM_MESSAGE = "message" + const val CHARSET_UTF8 = "UTF-8" + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt new file mode 100644 index 0000000000..5b6afef517 --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt @@ -0,0 +1,303 @@ +package com.tangem.feature.wallet.presentation.wallet.qr + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import java.math.BigDecimal + +internal class QrContentClassifierTest { + + private val blockchainDataProvider = mockk { + every { getShareSchemes(any()) } returns emptyList() + every { validateAddress(any(), any()) } returns false + } + private val classifier = QrContentClassifier(blockchainDataProvider) + + // region WalletConnect + + @Test + fun `WalletConnect URI is classified correctly`() { + val uri = "wc:a4f86d5-72ac-46ad-a1aa-9e@1-f5c0c@2" + val result = classifier.classify(uri, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java) + assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(uri) + } + + @Test + fun `WalletConnect URI takes priority over address matching`() { + val uri = "wc:something" + every { blockchainDataProvider.validateAddress(any(), uri) } returns true + + val result = classifier.classify(uri, listOf(bitcoinCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java) + } + + @Test + fun `dApp URL with wc uri query param is classified as WalletConnect`() { + val dAppUrl = "https://uniswap.org/app/wc?uri=wc:6ea45@2?relay-protocol=irn&symKey=f2de&expiryTimestamp=123" + + val result = classifier.classify(dAppUrl, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java) + assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(dAppUrl) + } + + @Test + fun `HTTP URL without wc uri param is not classified as WalletConnect`() { + val url = "https://example.com/page?foo=bar" + + val result = classifier.classify(url, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `HTTP URL with uri param not starting with wc is not WalletConnect`() { + val url = "https://example.com/page?uri=https://other.com" + + val result = classifier.classify(url, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + // endregion + + // region PaymentUri + + @Test + fun `Bitcoin BIP-021 URI with amount is parsed`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5" + val result = classifier.classify(qr, listOf(bitcoinCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.currency).isEqualTo(bitcoinCoin) + assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(paymentUri.amount).isEqualTo(BigDecimal("0.5")) + assertThat(paymentUri.memo).isNull() + } + + @Test + fun `Bitcoin URI without params returns address only`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + val result = classifier.classify(qr, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(paymentUri.amount).isNull() + assertThat(paymentUri.memo).isNull() + } + + @Test + fun `Bitcoin URI with message param is parsed as memo`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.0&message=test%20memo" + val result = classifier.classify(qr, listOf(bitcoinCoin)) + + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.0")) + assertThat(paymentUri.memo).isEqualTo("test memo") + } + + @Test + fun `URI with memo parameter is parsed`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?memo=hello" + val result = classifier.classify(qr, listOf(bitcoinCoin)) + + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.memo).isEqualTo("hello") + } + + @Test + fun `Ethereum ERC-681 URI with chain_id and function is parsed`() { + every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") + + val qr = "ethereum:0x1234567890abcdef1234567890abcdef12345678@1/transfer?amount=1.5" + val result = classifier.classify(qr, listOf(ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.address).isEqualTo("0x1234567890abcdef1234567890abcdef12345678") + assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.5")) + } + + @Test + fun `URI scheme not matching user currencies falls through`() { + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + val result = classifier.classify(qr, listOf(ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `Longest matching scheme is preferred`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns + listOf("bitcoin:", "bitcoin://") + + val qr = "bitcoin://1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + val result = classifier.classify(qr, listOf(bitcoinCoin)) + + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + } + + // endregion + + // region PlainAddress + + @Test + fun `Plain address matches single currency`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + + val result = classifier.classify(address, listOf(ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.address).isEqualTo(address) + assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin) + } + + @Test + fun `Plain address matches multiple currencies`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + every { blockchainDataProvider.validateAddress(bscCoin.network, address) } returns true + + val result = classifier.classify(address, listOf(ethereumCoin, bscCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.matchingCurrencies).hasSize(2) + } + + // endregion + + // region Unknown + + @Test + fun `Random string returns Unknown`() { + val result = classifier.classify("hello world", listOf(bitcoinCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + assertThat((result as ClassifiedQrContent.Unknown).raw).isEqualTo("hello world") + } + + @Test + fun `Empty string returns Unknown`() { + val result = classifier.classify("", listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `Empty currencies list returns Unknown`() { + val result = classifier.classify("0x1234567890abcdef1234567890abcdef12345678", emptyList()) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + // endregion + + // region Edge cases + + @Test + fun `Tokens are filtered out, only Coins are used`() { + val token = CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = buildNetwork("ethereum"), + name = "USDT", + symbol = "USDT", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ) + + val result = classifier.classify("0x1234", listOf(token)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `Duplicate coins with same network are deduplicated`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + + val result = classifier.classify(address, listOf(ethereumCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.matchingCurrencies).hasSize(1) + } + + @Test + fun `Payment URI takes priority over plain address match`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + every { blockchainDataProvider.validateAddress(bitcoinCoin.network, any()) } returns true + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1" + val result = classifier.classify(qr, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + } + + // endregion + + // region Helpers + + private val bitcoinCoin = buildCoin("bitcoin") + private val ethereumCoin = buildCoin("ethereum") + private val bscCoin = buildCoin("bsc") + + private fun buildCoin(rawNetworkId: String): CryptoCurrency.Coin { + return CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId), + ), + network = buildNetwork(rawNetworkId), + name = rawNetworkId, + symbol = rawNetworkId.take(3).uppercase(), + decimals = 8, + iconUrl = null, + isCustom = false, + ) + } + + private fun buildNetwork(rawNetworkId: String): Network { + return Network( + id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), + backendId = rawNetworkId, + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + // endregion +} \ No newline at end of file From dbacd5b2a6057aa8f9519a58075f420d84994857 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 10 Mar 2026 13:13:23 +0100 Subject: [PATCH 14/60] Updated on 2026-08-14 --- .../common/ui/markets/MarketListItemV2.kt | 323 ++++++++++++++++++ .../common/ui/markets/MarketsListItem.kt | 23 ++ ...MarketListItem.kt => MarketsListItemV1.kt} | 10 +- .../MarketChartListItemPreviewDataProvider.kt | 4 +- .../tangem/common/ui/tokens/TokenPriceText.kt | 77 +++++ .../tangem/core/ui/ds/image/TangemIconUM.kt | 18 +- .../ui/ds/opportunities/OpportunitiesBG.kt | 8 +- .../core/ui/ds/row/TangemRowContainer.kt | 8 +- .../src/main/res/drawable/ic_laurel_left.xml | 9 + .../src/main/res/drawable/ic_laurel_right.xml | 9 + .../feed/components/articles/ArticleHeader.kt | 5 +- .../feed/ui/feed/components/articles/Tags.kt | 6 +- 12 files changed, 475 insertions(+), 25 deletions(-) create mode 100644 common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItemV2.kt create mode 100644 common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketsListItem.kt rename common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/{MarketListItem.kt => MarketsListItemV1.kt} (97%) create mode 100644 core/ui/src/main/res/drawable/ic_laurel_left.xml create mode 100644 core/ui/src/main/res/drawable/ic_laurel_right.xml diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItemV2.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItemV2.kt new file mode 100644 index 0000000000..179ed39563 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItemV2.kt @@ -0,0 +1,323 @@ +package com.tangem.common.ui.markets + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tangem.common.ui.charts.MarketChartMini +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.common.ui.markets.preview.MarketChartListItemPreviewDataProvider +import com.tangem.common.ui.tokens.TokenPriceText +import com.tangem.core.ui.R +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerW4 +import com.tangem.core.ui.components.marketprice.PriceChangeInPercent +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.res.LocalIsInDarkTheme +import com.tangem.core.ui.res.LocalWindowSize +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.MarketsTestTags +import com.tangem.core.ui.test.TokenElementsTestTags +import com.tangem.core.ui.windowsize.WindowSizeType +import com.tangem.utils.StringsSigns.MINUS +import kotlin.random.Random + +@Composable +fun MarketsListItemV2(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { + MarketListItemContentV2( + modifier = modifier + .fillMaxWidth() + .clip(RectangleShape) + .clickable(onClick = onClick) + .testTag(MarketsTestTags.TOKENS_LIST_ITEM), + model = model, + ) +} + +@Composable +fun MarketListItemContentV2(model: MarketsListItemUM, modifier: Modifier = Modifier) { + val windowSize = LocalWindowSize.current + TangemRowContainer( + modifier = modifier, + content = { + TangemIcon( + tangemIconUM = TangemIconUM.Url(model.iconUrl, fallbackRes = R.drawable.ic_custom_token_44), + modifier = Modifier + .size(40.dp) + .layoutId(layoutId = TangemRowLayoutId.HEAD), + ) + + TokenTitle( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.START_TOP) + .padding(horizontal = TangemTheme.dimens2.x2), + name = model.name, + currencySymbol = model.currencySymbol, + ) + + TokenPriceText( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.END_TOP) + .testTag(tag = TokenElementsTestTags.TOKEN_FIAT_AMOUNT), + price = model.price.text, + priceChangeType = model.price.changeType, + ) + + TokenSubtitle( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.START_BOTTOM) + .padding(end = TangemTheme.dimens2.x2, start = TangemTheme.dimens2.x3), + ratingPosition = model.ratingPosition, + marketCap = model.marketCap, + // stakingRate = model.stakingRate, TODO in [REDACTED_TASK_KEY] + ) + + PriceChangeInPercent( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM), + textStyle = TangemTheme.typography2.captionRegular12, + type = model.trendType, + valueInPercent = model.trendPercentText, + ) + if (windowSize.widthAtLeast(WindowSizeType.Small)) { + Chart( + modifier = Modifier + .padding(start = TangemTheme.dimens2.x2) + .layoutId(layoutId = TangemRowLayoutId.TAIL), + chartType = model.chartType, + chartRawData = model.chartData, + ) + } + }, + ) +} + +@Composable +private fun TokenTitle(name: String, currencySymbol: String, modifier: Modifier = Modifier) { + Row(modifier = modifier) { + Text( + modifier = Modifier + .weight(1f, fill = false) + .alignByBaseline(), + text = name, + color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.bodySemibold16, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + SpacerW4() + Text( + modifier = Modifier.alignByBaseline(), + text = currencySymbol, + color = TangemTheme.colors2.text.neutral.secondary, + style = TangemTheme.typography2.captionSemibold12, + maxLines = 1, + overflow = TextOverflow.Visible, + ) + } +} + +@Composable +private fun TokenSubtitle( + ratingPosition: String?, + marketCap: String?, + // stakingRate: TextReference?, TODO in [REDACTED_TASK_KEY] + modifier: Modifier = Modifier, +) { + val ratingColor = mapRatingToColor(ratingPosition) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + TokenRatingPlace( + ratingPosition = ratingPosition, + ratingColor = ratingColor, + ) + if (marketCap != null) { + TokenMarketCapText( + ratingColor = ratingColor, + modifier = Modifier.weight(1f, fill = false), + text = marketCap, + ) + } + } +} + +@Composable +private fun RowScope.TokenRatingPlace(ratingPosition: String?, ratingColor: Color) { + Row( + modifier = Modifier + .alignByBaseline() + .heightIn(min = TangemTheme.dimens2.x4), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + Icon( + modifier = Modifier.size(height = TangemTheme.dimens2.x4, width = TangemTheme.dimens2.x2), + imageVector = ImageVector.vectorResource(R.drawable.ic_laurel_left), + tint = ratingColor, + contentDescription = null, + ) + + Text( + textAlign = TextAlign.Center, + text = ratingPosition ?: MINUS, + color = ratingColor, + style = TangemTheme.typography2.captionSemibold12.copy(letterSpacing = 0.sp), + maxLines = 1, + ) + + Icon( + modifier = Modifier.size(height = TangemTheme.dimens2.x4, width = TangemTheme.dimens2.x2), + imageVector = ImageVector.vectorResource(R.drawable.ic_laurel_right), + tint = ratingColor, + contentDescription = null, + ) + } +} + +@Composable +private fun RowScope.TokenMarketCapText(text: String, ratingColor: Color, modifier: Modifier = Modifier) { + Text( + modifier = modifier.alignByBaseline(), + text = text, + color = ratingColor, + style = TangemTheme.typography2.captionSemibold12, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun Chart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawData?, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .padding(vertical = 6.dp) + .size(height = TangemTheme.dimens2.x6, width = TangemTheme.dimens2.x12), + ) { + if (chartRawData != null) { + MarketChartMini( + rawData = chartRawData, + type = chartType, + ) + } else { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(TangemTheme.dimens2.x3) + .align(Alignment.Center), + radius = 3.dp, + ) + } + } +} + +@Composable +private fun mapRatingToColor(rating: String?): Color { + val isDarkTheme = LocalIsInDarkTheme.current + + return when (rating) { + "1" -> if (isDarkTheme) Color(GOLD_PLACE_COLOR_NIGHT) else Color(GOLD_PLACE_COLOR_LIGHT) + "2" -> if (isDarkTheme) Color(SILVER_PLACE_COLOR_NIGHT) else Color(SILVER_PLACE_COLOR_LIGHT) + "3" -> if (isDarkTheme) Color(BRONZE_PLACE_COLOR_NIGHT) else Color(BRONZE_PLACE_COLOR_LIGHT) + else -> TangemTheme.colors2.text.neutral.secondary + } +} + +private const val GOLD_PLACE_COLOR_NIGHT = 0xFFFBEE76 +private const val GOLD_PLACE_COLOR_LIGHT = 0xFFD9B900 +private const val SILVER_PLACE_COLOR_NIGHT = 0xFFAABEF7 +private const val SILVER_PLACE_COLOR_LIGHT = 0xFF6680CC +private const val BRONZE_PLACE_COLOR_NIGHT = 0xFFFF9976 +private const val BRONZE_PLACE_COLOR_LIGHT = 0xFFCC7F66 + +// region preview +@Preview(showBackground = true, widthDp = 360, name = "normal") +@Composable +private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::class) state: MarketsListItemUM) { + TangemThemePreviewRedesign { + var state1 by remember { mutableStateOf(state) } + var state2 by remember { mutableStateOf(state) } + var prices by remember { + mutableStateOf( + listOf( + 100 to PriceChangeType.NEUTRAL, + 200 to PriceChangeType.NEUTRAL, + ), + ) + } + + Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) { + MarketsListItemV2( + modifier = Modifier, + model = state1, + ) + MarketsListItemV2( + modifier = Modifier, + model = state2, + ) + Row { + Button( + onClick = { + state1 = state1.copy( + trendType = PriceChangeType.entries.random(), + ) + state2 = state2.copy( + trendType = PriceChangeType.entries.random(), + ) + }, + ) { Text(text = "trend") } + + Button( + onClick = { + prices = prices.map { (price, _) -> + if (Random.nextBoolean()) { + price.inc() to PriceChangeType.UP + } else { + price.dec() to PriceChangeType.DOWN + } + } + state1 = state1.copy( + price = MarketsListItemUM.Price( + text = "0.${prices[0].first}023 $", + changeType = prices[0].second, + ), + ) + state2 = state2.copy( + price = MarketsListItemUM.Price( + text = "0.${prices[1].first}023 $", + changeType = prices[1].second, + ), + ) + }, + ) { Text(text = "price") } + } + } + } +} \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketsListItem.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketsListItem.kt new file mode 100644 index 0000000000..037457f908 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketsListItem.kt @@ -0,0 +1,23 @@ +package com.tangem.common.ui.markets + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.res.LocalRedesignEnabled + +@Composable +fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { + if (LocalRedesignEnabled.current) { + MarketsListItemV2( + model = model, + modifier = modifier, + onClick = onClick, + ) + } else { + MarketsListItemV1( + model = model, + modifier = modifier, + onClick = onClick, + ) + } +} \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItem.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketsListItemV1.kt similarity index 97% rename from common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItem.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketsListItemV1.kt index 3215446426..bfa927d620 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItem.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketsListItemV1.kt @@ -38,8 +38,8 @@ import com.tangem.utils.StringsSigns.MINUS import kotlin.random.Random @Composable -fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { - MarketsListItemContent( +fun MarketsListItemV1(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { + MarketsListItemContentV1( modifier = modifier .fillMaxWidth() .clip(RectangleShape) @@ -50,7 +50,7 @@ fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onC } @Composable -private fun MarketsListItemContent(model: MarketsListItemUM, modifier: Modifier = Modifier) { +private fun MarketsListItemContentV1(model: MarketsListItemUM, modifier: Modifier = Modifier) { val windowSize = LocalWindowSize.current Row( @@ -273,11 +273,11 @@ private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::cl } Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) { - MarketsListItem( + MarketsListItemV1( modifier = Modifier, model = state1, ) - MarketsListItem( + MarketsListItemV1( modifier = Modifier, model = state2, ) diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/preview/MarketChartListItemPreviewDataProvider.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/preview/MarketChartListItemPreviewDataProvider.kt index 7cac4e8314..29cd1685ff 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/preview/MarketChartListItemPreviewDataProvider.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/preview/MarketChartListItemPreviewDataProvider.kt @@ -16,7 +16,7 @@ class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvide name = "Bitcoin", currencySymbol = "BTC", iconUrl = "", - ratingPosition = "10", + ratingPosition = "1", marketCap = "$6.233 B", price = MarketsListItemUM.Price(text = "31 285.72$"), trendPercentText = "12.43%", @@ -33,7 +33,7 @@ class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvide name = "Bitcoin", currencySymbol = "BTC", iconUrl = null, - ratingPosition = "10", + ratingPosition = "2", marketCap = "$6.233 B", price = MarketsListItemUM.Price(text = "31 285.72$"), trendPercentText = "12.43%", diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenPriceText.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenPriceText.kt index b470546536..df80ddeae9 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenPriceText.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenPriceText.kt @@ -6,8 +6,12 @@ import androidx.compose.animation.core.tween import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme /** @@ -18,6 +22,23 @@ import com.tangem.core.ui.res.TangemTheme */ @Composable fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) { + if (LocalRedesignEnabled.current) { + TokenPriceTextV2( + price = price, + modifier = modifier, + priceChangeType = priceChangeType, + ) + } else { + TokenPriceTextV1( + price = price, + modifier = modifier, + priceChangeType = priceChangeType, + ) + } +} + +@Composable +private fun TokenPriceTextV1(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) { val growColor = TangemTheme.colors.text.accent val fallColor = TangemTheme.colors.text.warning val generalColor = TangemTheme.colors.text.primary1 @@ -52,4 +73,60 @@ fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceChangeType style = TangemTheme.typography.body2, overflow = TextOverflow.Visible, ) +} + +@Composable +private fun TokenPriceTextV2(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) { + val growColor = TangemTheme.colors2.text.status.accent + val fallColor = TangemTheme.colors2.text.status.warning + val generalColor = TangemTheme.colors2.text.neutral.primary + val decimalColor = TangemTheme.colors2.text.neutral.secondary + + val color = remember(generalColor) { Animatable(generalColor) } + var isAnimationSkipped by remember { mutableStateOf(false) } + + LaunchedEffect(price) { + if (!isAnimationSkipped) { + isAnimationSkipped = true + return@LaunchedEffect + } + + if (priceChangeType != null) { + val nextColor = when (priceChangeType) { + PriceChangeType.UP -> growColor + PriceChangeType.DOWN -> fallColor + PriceChangeType.NEUTRAL -> return@LaunchedEffect + } + + color.animateTo(nextColor, snap()) + color.animateTo(generalColor, tween(durationMillis = 500)) + } + } + + val annotatedText = remember(price) { + buildAnnotatedString { + val dotIndex = price.indexOf(".") + + if (dotIndex == -1) { + append(price) + } else { + append(price.take(dotIndex)) + + withStyle( + style = SpanStyle(color = decimalColor), + ) { + append(price.substring(dotIndex)) + } + } + } + } + + Text( + modifier = modifier, + text = annotatedText, + color = color.value, + maxLines = 1, + style = TangemTheme.typography2.bodySemibold16, + overflow = TextOverflow.Visible, + ) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt index 2c6ecb3b07..a1f0c98b5b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt @@ -2,9 +2,6 @@ package com.tangem.core.ui.ds.image import androidx.annotation.DrawableRes import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -50,7 +47,8 @@ sealed interface TangemIconUM { /** Image represented from network by url */ data class Url( - val url: String, + val url: String?, + @DrawableRes val fallbackRes: Int, ) : TangemIconUM } @@ -91,14 +89,12 @@ fun TangemIcon(tangemIconUM: TangemIconUM, modifier: Modifier = Modifier) { .crossfade(enable = true) .allowHardware(enable = false) .build(), - loading = { CircleShimmer() }, + loading = { CircleShimmer(modifier) }, error = { - Box( - modifier = Modifier - .background( - color = TangemTheme.colors2.surface.level3, - shape = CircleShape, - ), + Icon( + imageVector = ImageVector.vectorResource(tangemIconUM.fallbackRes), + contentDescription = null, + modifier = modifier, ) }, contentDescription = null, diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt index 8fd7342071..5a392b66cd 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt @@ -121,7 +121,13 @@ private fun BoxScope.BackgroundLayer(icon: TangemIconUM, blurRadius: Dp = 26.dp) is TangemIconUM.Icon -> SolidColorBackground(icon.tintReference(), blurRadius) is TangemIconUM.Ident -> Unit is TangemIconUM.Image -> ResBackground(icon.imageRes, blurRadius) - is TangemIconUM.Url -> UrlColorBackground(icon.url, blurRadius) + is TangemIconUM.Url -> { + icon.url?.let { iconUrl -> + UrlColorBackground(iconUrl, blurRadius) + } ?: run { + ResBackground(icon.fallbackRes, blurRadius) + } + } } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt index 026dc41e70..71d79083c4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt @@ -54,13 +54,13 @@ fun TangemRowContainer( // Head composable measurement val headPlaceable = measurables.measure( layoutId = TangemRowLayoutId.HEAD, - constraints = constraints, + constraints = constraints.copy(minWidth = 0), ) // Tail composable measurement val tailPlaceable = measurables.measure( layoutId = TangemRowLayoutId.TAIL, - constraints = constraints, + constraints = constraints.copy(minWidth = 0), ) val availableWidthForBody = layoutWidth - headPlaceable.widthOrZero() - tailPlaceable.widthOrZero() @@ -70,7 +70,7 @@ fun TangemRowContainer( layoutId = TangemRowLayoutId.END_TOP, constraints = constraints.copy( minWidth = 0, - maxWidth = availableWidthForBody - startTopMinWidth, + maxWidth = max(0, availableWidthForBody - startTopMinWidth), ), ) @@ -79,7 +79,7 @@ fun TangemRowContainer( layoutId = TangemRowLayoutId.END_BOTTOM, constraints = constraints.copy( minWidth = 0, - maxWidth = availableWidthForBody - startBottomMinWidth, + maxWidth = max(0, availableWidthForBody - startTopMinWidth), ), ) diff --git a/core/ui/src/main/res/drawable/ic_laurel_left.xml b/core/ui/src/main/res/drawable/ic_laurel_left.xml new file mode 100644 index 0000000000..7fb94f1dab --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_laurel_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_laurel_right.xml b/core/ui/src/main/res/drawable/ic_laurel_right.xml new file mode 100644 index 0000000000..99199504e4 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_laurel_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleHeader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleHeader.kt index fadece2c4e..90e3717cd0 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleHeader.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleHeader.kt @@ -154,7 +154,10 @@ private fun ArticleHeaderV2( text = tag.text, tangemIconUM = when (val content = tag.leadingContent) { LabelLeadingContentUM.None -> null - is LabelLeadingContentUM.Token -> TangemIconUM.Url(content.iconUrl) + is LabelLeadingContentUM.Token -> TangemIconUM.Url( + url = content.iconUrl, + fallbackRes = R.drawable.ic_alert_24, + ) }, shape = TangemBadgeShape.Rounded, size = TangemBadgeSize.X9, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/Tags.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/Tags.kt index f5ec98879e..2554569a61 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/Tags.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/Tags.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.SubcomposeMeasureScope import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R import com.tangem.core.ui.components.label.Label import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM import com.tangem.core.ui.components.label.entity.LabelUM @@ -40,7 +41,10 @@ internal fun Tags(tags: ImmutableList, modifier: Modifier = Modifier) { text = tag.text, tangemIconUM = when (val content = tag.leadingContent) { LabelLeadingContentUM.None -> null - is LabelLeadingContentUM.Token -> TangemIconUM.Url(content.iconUrl) + is LabelLeadingContentUM.Token -> TangemIconUM.Url( + url = content.iconUrl, + fallbackRes = R.drawable.ic_alert_24, + ) }, shape = TangemBadgeShape.Rounded, size = TangemBadgeSize.X6, From 14b3b93cf4684b9074b766269a7aecf73c1935fe Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 10 Mar 2026 17:14:20 +0500 Subject: [PATCH 15/60] Updated on 2026-08-14 --- .../TangemPullToRefreshContainer.kt | 147 +++++++++++++++++- ...BalanceExitUntilCollapsedScrollBehavior.kt | 38 +++-- .../model/intents/WalletClickIntents.kt | 28 ++-- .../presentation/wallet/ui/WalletScreen2.kt | 23 ++- .../ui/components/common/WalletBalance.kt | 5 +- .../components/common/WalletPagerIndicator.kt | 64 +++++--- .../ui/components/common/WalletTopBar.kt | 40 ++++- 7 files changed, 287 insertions(+), 58 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/containers/pullToRefresh/TangemPullToRefreshContainer.kt b/core/ui/src/main/java/com/tangem/core/ui/components/containers/pullToRefresh/TangemPullToRefreshContainer.kt index 19e1491cf4..13b1a4e914 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/containers/pullToRefresh/TangemPullToRefreshContainer.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/containers/pullToRefresh/TangemPullToRefreshContainer.kt @@ -1,20 +1,32 @@ package com.tangem.core.ui.components.containers.pullToRefresh import android.content.res.Configuration +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.* +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.PullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import kotlin.math.ln +/** + * A composable function that provides a pull-to-refresh container using Material3's PullToRefreshBox. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun TangemPullToRefreshContainer( @@ -45,6 +57,114 @@ fun TangemPullToRefreshContainer( } } +/** + * A composable function that provides a pull-to-refresh container that slides the content down + * to reveal a progress indicator, then slides it back up when refreshing completes. + * + * The indicator appears during the pull gesture (driven by drag distance) and remains visible + * while refreshing is in progress. + * + * @param config Pull-to-refresh configuration (isRefreshing, onRefresh). + * @param modifier Modifier applied to the outer container. + * @param indicatorOffset Additional offset for the indicator block position. + * @param content The content to display. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TangemPullToRefreshSlidingContainer( + config: PullToRefreshConfig, + modifier: Modifier = Modifier, + state: PullToRefreshState = rememberPullToRefreshState(), + indicatorOffset: Dp = 0.dp, + content: @Composable BoxScope.() -> Unit, +) { + val indicatorSize = 24.dp + val contentOffset = getPullToRefreshIndicatorOffset( + pullToRefreshConfig = config, + pullToRefreshState = state, + ) + PullToRefreshBox( + isRefreshing = config.isRefreshing, + onRefresh = { + config.onRefresh(PullToRefreshConfig.ShowRefreshState()) + }, + state = state, + modifier = modifier, + indicator = {}, + ) { + Box(modifier = Modifier.fillMaxSize()) { + // Content slides down + Box( + modifier = Modifier + .fillMaxSize() + .offset(y = contentOffset), + ) { + content() + } + // Indicator block slides in from above + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .height(contentOffset) + .offset(y = indicatorOffset), + ) { + if (contentOffset > 0.dp) { + if (config.isRefreshing) { + // Indeterminate spinner while refreshing + CircularProgressIndicator( + modifier = Modifier.size(indicatorSize), + color = TangemTheme.colors2.graphic.neutral.primary, + strokeWidth = 2.dp, + ) + } else { + // Determinate arc driven by pull fraction + CircularProgressIndicator( + progress = { state.distanceFraction.coerceAtLeast(0f).coerceIn(0f, 1f) }, + modifier = Modifier.size(indicatorSize), + color = TangemTheme.colors2.graphic.neutral.primary, + trackColor = Color.Transparent, + strokeWidth = 2.dp, + ) + } + } + } + } + } +} + +/** + * Calculates the vertical offset for the pull-to-refresh indicator + * based on the current pull state and refreshing status. + */ +@Composable +fun getPullToRefreshIndicatorOffset( + pullToRefreshConfig: PullToRefreshConfig?, + pullToRefreshState: PullToRefreshState, +): Dp { + val indicatorBlockHeight = 56.dp + val maxOverscroll = 24.dp + + val refreshingOffset by animateDpAsState( + targetValue = if (pullToRefreshConfig?.isRefreshing == true) indicatorBlockHeight else 0.dp, + animationSpec = tween(durationMillis = 300), + label = "SlidingContentOffset", + ) + + // Drag-driven offset with overscroll: linear up to indicatorBlockHeight, + // then dampened logarithmic curve beyond for a rubber-band effect + val fraction = pullToRefreshState.distanceFraction.coerceAtLeast(0f) + val dragOffset = if (fraction <= 1f) { + indicatorBlockHeight * fraction + } else { + val overscrollFraction = ln(1f + (fraction - 1f)) / ln(2f) // dampened curve + indicatorBlockHeight + maxOverscroll * overscrollFraction.coerceAtMost(1f) + } + + // Use the larger of the two so the transition from drag → refreshing is seamless + return maxOf(dragOffset, refreshingOffset) +} + // region Preview @Preview(showBackground = true, widthDp = 360, heightDp = 720) @Preview(showBackground = true, widthDp = 360, heightDp = 720, uiMode = Configuration.UI_MODE_NIGHT_YES) @@ -55,10 +175,31 @@ private fun TangemPullToRefreshContainer_Preview() { config = PullToRefreshConfig(isRefreshing = true, {}), ) { Box( - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() .background(TangemTheme.colors.background.secondary), ) } } } +// endregion + +// region Preview +@Preview(showBackground = true, widthDp = 360, heightDp = 720) +@Preview(showBackground = true, widthDp = 360, heightDp = 720, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemPullToRefreshSlidingContainer_Preview() { + TangemThemePreviewRedesign { + TangemPullToRefreshSlidingContainer( + config = PullToRefreshConfig(isRefreshing = true, {}), + indicatorOffset = 56.dp, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) + } + } +} // endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/collapsing/WalletBalanceExitUntilCollapsedScrollBehavior.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/collapsing/WalletBalanceExitUntilCollapsedScrollBehavior.kt index c3e18a6df6..166348b94b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/collapsing/WalletBalanceExitUntilCollapsedScrollBehavior.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/collapsing/WalletBalanceExitUntilCollapsedScrollBehavior.kt @@ -2,15 +2,17 @@ package com.tangem.core.ui.ds.topbar.collapsing import androidx.compose.animation.core.* import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.scrollable import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp @@ -121,20 +123,24 @@ private fun exitUntilCollapsedScrollBehavior( @Composable fun Modifier.snapToExitUntilCollapsed(behavior: TangemCollapsingAppBarBehavior): Modifier { - return draggable( - orientation = Orientation.Vertical, - state = rememberDraggableState { delta -> - behavior.state.heightOffset += delta - }, - onDragStopped = { velocity -> - settleAppBar( - state = behavior.state, - velocity = velocity, - flingAnimationSpec = behavior.flingAnimationSpec, - snapAnimationSpec = behavior.snapAnimationSpec, - ) - }, - ) + return nestedScroll(behavior.nestedScrollConnection) + .scrollable( + orientation = Orientation.Vertical, + state = behavior.state, + flingBehavior = remember(behavior) { + object : FlingBehavior { + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + val consumed = settleAppBar( + state = behavior.state, + velocity = initialVelocity, + flingAnimationSpec = behavior.flingAnimationSpec, + snapAnimationSpec = behavior.snapAnimationSpec, + ) + return initialVelocity - consumed.y + } + } + }, + ) } /** 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 8f03d6ae84..47c3f6be8a 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,6 +1,7 @@ package com.tangem.feature.wallet.child.wallet.model.intents import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.onramp.FetchHotCryptoUseCase @@ -15,6 +16,7 @@ import com.tangem.feature.wallet.presentation.wallet.domain.unwrap import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader 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.model.WalletUM import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetRefreshStateTransformer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async @@ -41,6 +43,7 @@ internal class WalletClickIntents @Inject constructor( private val onrampStatusFactory: OnrampStatusFactory, private val tangemPayIntents: TangemPayClickIntentsImplementor, private val yieldSupplyApyUpdateUseCase: YieldSupplyApyUpdateUseCase, + private val designFeatureToggles: DesignFeatureToggles, ) : BaseWalletClickIntents(), WalletCardClickIntents by walletCardClickIntentsImplementor, WalletWarningsClickIntents by warningsClickIntentsImplementer, @@ -86,17 +89,24 @@ internal class WalletClickIntents @Inject constructor( } fun onRefreshSwipe(showRefreshState: Boolean) { - when (stateController.getSelectedWallet()) { - is WalletState.MultiCurrency.Content -> { - refreshMultiCurrencyContent(showRefreshState) + if (designFeatureToggles.isRedesignEnabled) { + when (stateController.getSelectedWalletUM()) { + is WalletUM.Content -> refreshMultiCurrencyContent(showRefreshState) + is WalletUM.Locked -> Unit } - is WalletState.SingleCurrency.Content, - -> { - refreshSingleCurrencyContent(showRefreshState) + } else { + when (stateController.getSelectedWallet()) { + is WalletState.MultiCurrency.Content -> { + refreshMultiCurrencyContent(showRefreshState) + } + is WalletState.SingleCurrency.Content, + -> { + refreshSingleCurrencyContent(showRefreshState) + } + is WalletState.MultiCurrency.Locked, + is WalletState.SingleCurrency.Locked, + -> Unit } - is WalletState.MultiCurrency.Locked, - is WalletState.SingleCurrency.Locked, - -> Unit } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index 6e0ea2edc8..83c59d731f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment @@ -42,6 +43,7 @@ import com.tangem.core.ui.components.atoms.Hand import com.tangem.core.ui.components.atoms.handComposableComponentHeight import com.tangem.core.ui.components.background.northernlights.NorthernLightsBackground import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshSlidingContainer import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.components.rememberIsKeyboardVisible import com.tangem.core.ui.components.sheetscaffold.* @@ -130,6 +132,11 @@ private fun WalletContent2( val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } var walletBalance by remember { mutableStateOf(TextReference.EMPTY) } + var pullToRefreshConfig by remember { + mutableStateOf( + state.wallets2.getOrNull(state.selectedWalletIndex)?.pullToRefreshConfig, + ) + } BaseScaffoldWithMarkets( state = state, @@ -164,6 +171,8 @@ private fun WalletContent2( val canPagerScroll by remember { derivedStateOf { behavior.state.heightOffset == 0f } } + val pullToRefreshState = rememberPullToRefreshState() + Box( modifier = Modifier .fillMaxSize() @@ -180,6 +189,8 @@ private fun WalletContent2( WalletPagerIndicator( pagerState = walletsPagerState, + pullToRefreshState = pullToRefreshState, + pullToRefreshConfig = pullToRefreshConfig, behavior = behavior, ) @@ -199,6 +210,11 @@ private fun WalletContent2( walletBalance = (currentWallet.walletsBalanceUM as? WalletBalanceUM.Content)?.balanceInAppBar } } + LaunchedEffect(walletsPagerState.currentPage, currentWallet.pullToRefreshConfig) { + if (walletsPagerState.currentPage == currentWalletIndex) { + pullToRefreshConfig = currentWallet.pullToRefreshConfig + } + } val isShowMarketsHint by remember { derivedStateOf { @@ -211,8 +227,13 @@ private fun WalletContent2( val pageSlideAlpha by rememberPageAlpha(walletsPagerState, currentWalletIndex) - Box( + TangemPullToRefreshSlidingContainer( + state = pullToRefreshState, + config = currentWallet.pullToRefreshConfig, modifier = Modifier.alpha(pageSlideAlpha), + indicatorOffset = with(LocalDensity.current) { + behavior.state.partialHeightLimit.toDp() + }, ) { TangemCollapsingTopBar( state = behavior.state, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt index 0d4a5903ce..dd23a5debb 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Text @@ -75,7 +76,7 @@ internal fun WalletBalance( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxWidth() - .height(200.dp), + .padding(vertical = 58.dp), ) { Balance( walletBalanceUM = walletBalanceUM, @@ -147,7 +148,6 @@ private fun Balance(walletBalanceUM: WalletBalanceUM, isBalanceHidden: Boolean, text = "123456", style = TangemTheme.typography2.titleRegular44, radius = TangemTheme.dimens2.x25, - textSizeHeight = true, ) } } @@ -194,6 +194,7 @@ private fun WalletBalance_Preview(@PreviewParameter(WalletBalancePreviewProvider behavior = rememberTangemExitUntilCollapsedScrollBehavior(), buttons = WalletPreviewData.actionButtons, isBalanceHidden = false, + modifier = Modifier.background(TangemTheme.colors2.surface.level1), ) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletPagerIndicator.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletPagerIndicator.kt index d047533bb3..de6770b7e9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletPagerIndicator.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletPagerIndicator.kt @@ -1,49 +1,71 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.common +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.pulltorefresh.PullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.containers.pullToRefresh.getPullToRefreshIndicatorOffset import com.tangem.core.ui.ds.TangemPagerIndicator import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior private const val MIN_SCALE = 0.75f private const val MAX_SCALE = 1f +private const val WALLET_INDICATOR_OFFSET = 0.63f @Composable -internal fun WalletPagerIndicator(pagerState: PagerState, behavior: TangemCollapsingAppBarBehavior) { +internal fun WalletPagerIndicator( + pagerState: PagerState, + behavior: TangemCollapsingAppBarBehavior, + pullToRefreshConfig: PullToRefreshConfig?, + pullToRefreshState: PullToRefreshState, +) { val collapsedFraction = behavior.state.collapsedFraction val alpha = MAX_SCALE - collapsedFraction val scale = alpha.coerceIn(MIN_SCALE, MAX_SCALE) + val height = with(LocalDensity.current) { + behavior.state.heightOffsetLimit.toDp().unaryMinus() + } - Box( - modifier = Modifier - .graphicsLayer { - scaleY = scale - translationY = behavior.state.heightOffset - } - .fillMaxWidth() - .height( - with(LocalDensity.current) { - behavior.state.heightOffsetLimit.toDp().unaryMinus() - }, - ) - .alpha(alpha), + val contentOffset = getPullToRefreshIndicatorOffset( + pullToRefreshConfig = pullToRefreshConfig, + pullToRefreshState = pullToRefreshState, + ) + val padding = height * WALLET_INDICATOR_OFFSET + + AnimatedVisibility( + visible = pagerState.pageCount > 1, + enter = fadeIn(), + exit = fadeOut(), ) { - TangemPagerIndicator( - pagerState = pagerState, + Box( modifier = Modifier - .padding(top = 248.dp) - .scale(scaleY = 1f, scaleX = scale) - .fillMaxWidth(), - ) + .graphicsLayer { + scaleY = scale + translationY = behavior.state.heightOffset + contentOffset.toPx() + } + .fillMaxWidth() + .height(height) + .alpha(alpha), + ) { + TangemPagerIndicator( + pagerState = pagerState, + modifier = Modifier + .padding(top = padding) + .scale(scaleY = 1f, scaleX = scale) + .fillMaxWidth(), + ) + } } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt index 7dd4faf316..520a13c331 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt @@ -1,17 +1,24 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.common import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp 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.haze.hazeEffectTangem import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarActionContent import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior @@ -55,11 +62,32 @@ internal fun WalletTopBar( TangemTopBar( title = wrappedBalance, - startAction = TangemTopBarActionUM( - iconRes = R.drawable.ic_tangem_24, - isActionable = false, - ), - endActions = topBarConfig.endActions, + startContent = { + TangemTopBarActionContent( + TangemTopBarActionUM( + iconRes = R.drawable.ic_tangem_24, + isActionable = false, + ), + ) + }, + endContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x5), + modifier = Modifier + .clip(CircleShape) + .background( + lerp( + start = Color.Transparent, + stop = TangemTheme.colors2.button.backgroundSecondary, + fraction = behavior.state.collapsedFraction, + ), + ), + ) { + topBarConfig.endActions.forEach { action -> + TangemTopBarActionContent(action) + } + } + }, modifier = Modifier .statusBarsPadding() .testTag(MainScreenTestTags.TOP_BAR), @@ -134,7 +162,7 @@ private fun WalletTopBar_WithQrButton_Preview() { topBarConfig = WalletTopBarConfig( endActions = persistentListOf( TangemTopBarActionUM( - iconRes = com.tangem.core.ui.R.drawable.ic_qrcode_scaner_24, + iconRes = R.drawable.ic_qrcode_scaner_24, onClick = {}, ), TangemTopBarActionUM( From 857920b0935ae629c12d1e9ed52c1530c8a9dacd Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 10 Mar 2026 15:14:48 +0300 Subject: [PATCH 16/60] Updated on 2026-08-14 --- app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt | 2 ++ app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt | 2 ++ .../tangem/tests/actionButtons/MainScreenActionButtonsTest.kt | 2 ++ .../com/tangem/tests/send/warnings/StellarWarningsTest.kt | 4 ++++ 4 files changed, 10 insertions(+) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt index a1cc2319c9..624ea971b6 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -18,6 +18,7 @@ import com.tangem.tap.store 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 @@ -53,6 +54,7 @@ class FeedbackTest : BaseTestCase() { } } + @Ignore("TODO: [REDACTED_JIRA]") @AllureId("893") @DisplayName("Send feedback: failed transaction") @Test diff --git a/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt index 1b83533fcb..f0a394e72e 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt @@ -14,6 +14,7 @@ import com.tangem.tap.domain.sdk.mocks.content.* 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 @@ -85,6 +86,7 @@ class ScanCardTest : BaseTestCase() { } } + @Ignore("TODO: [REDACTED_JIRA]") @AllureId("870") @DisplayName("Scan: Card with Ed25519 curve") @Test diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt index 28fe6ef184..8069b5d735 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -20,6 +20,7 @@ import com.tangem.tap.domain.sdk.mocks.content.TwinsMockContent 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 @@ -460,6 +461,7 @@ class MainScreenActionButtonsTest : BaseTestCase() { } } + @Ignore("TODO: [REDACTED_JIRA]") @AllureId("4396") @DisplayName("Action buttons (main screen): click on buttons without data") @Test diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt index fb4483da57..3e7a6c9afd 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt @@ -17,6 +17,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import io.github.kakaocup.kakao.common.utilities.getResourceString import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Ignore import org.junit.Test @HiltAndroidTest @@ -32,6 +33,7 @@ class StellarWarningsTest : BaseTestCase() { getResourceString(R.string.send_notification_invalid_reserve_amount_title, reserveAmount) private val warningMessage = getResourceString(R.string.send_notification_invalid_reserve_amount_text) + @Ignore("TODO: [REDACTED_JIRA]") @AllureId("4287") @DisplayName("Warnings: check warning, when sending less than reserve") @Test @@ -85,6 +87,7 @@ class StellarWarningsTest : BaseTestCase() { } } + @Ignore("TODO: [REDACTED_JIRA]") @AllureId("4286") @DisplayName("Warnings: check warning when sending amount equal to reserve") @Test @@ -139,6 +142,7 @@ class StellarWarningsTest : BaseTestCase() { } } + @Ignore("TODO: [REDACTED_JIRA]") @AllureId("4288") @DisplayName("Warnings: check warning when sending greater than reserve") @Test From 5f4dfefc2e4ac33d0aa0431d0de189ca82847a8a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 10 Mar 2026 16:33:07 +0400 Subject: [PATCH 17/60] Updated on 2026-08-14 --- .../tap/di/domain/TokensDomainModule.kt | 12 -- .../tap/di/domain/TransactionDomainModule.kt | 6 +- .../network/ethereum/WcEthTxHelper.kt | 23 +++- .../supplier/SingleAccountListSupplier.kt | 6 + .../SingleAccountStatusListSupplier.kt | 5 + .../GetSingleCryptoCurrencyStatusUseCase.kt | 106 ------------------ domain/transaction/build.gradle.kts | 1 + .../CreateAndSendGaslessTransactionUseCase.kt | 41 ++++--- .../v2/feeselector/model/FeeSelectorLogic.kt | 13 +-- .../tangem/feature/swap/model/SwapModel.kt | 19 ++-- features/txhistory/impl/build.gradle.kts | 1 + .../txhistory/model/TxHistoryModel.kt | 30 ++--- .../intents/WalletContentClickIntents.kt | 6 +- .../WalletCurrencyActionsClickIntents.kt | 10 +- .../presentation/wallet/domain/UseCaseExt.kt | 36 ++---- features/yield-supply/impl/build.gradle.kts | 1 + .../active/model/YieldSupplyActiveModel.kt | 75 +++++++------ .../impl/entry/model/YieldSupplyEntryModel.kt | 33 +++--- .../impl/main/model/YieldSupplyModel.kt | 30 ++--- .../model/YieldSupplyStartEarningModel.kt | 47 ++++---- 20 files changed, 190 insertions(+), 311 deletions(-) delete mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetSingleCryptoCurrencyStatusUseCase.kt 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 8e58e90e09..65df536429 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 @@ -53,18 +53,6 @@ internal object TokensDomainModule { return DefaultTokensFeatureToggles(featureTogglesManager = featureTogglesManager) } - @Provides - @Singleton - fun provideGetCurrencyUseCase( - baseCurrencyStatusOperations: BaseCurrencyStatusOperations, - dispatchers: CoroutineDispatcherProvider, - ): GetSingleCryptoCurrencyStatusUseCase { - return GetSingleCryptoCurrencyStatusUseCase( - currencyStatusOperations = baseCurrencyStatusOperations, - dispatchers = dispatchers, - ) - } - @Provides @Singleton fun provideGetCurrencyWarningsUseCase( 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 52ecf3a396..a218b25669 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 @@ -1,13 +1,13 @@ package com.tangem.tap.di.domain import com.tangem.data.wallets.hot.TangemHotWalletSigner +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.notifications.repository.PushNotificationsRepository import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -344,13 +344,13 @@ internal object TransactionDomainModule { fun provideCreateAndSendGaslessTransactionUseCase( walletManagersFacade: WalletManagersFacade, gaslessTransactionRepository: GaslessTransactionRepository, - getSingCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + singleAccountListSupplier: SingleAccountListSupplier, cardSdkConfigRepository: CardSdkConfigRepository, tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory, ): CreateAndSendGaslessTransactionUseCase { return CreateAndSendGaslessTransactionUseCase( walletManagersFacade = walletManagersFacade, - getSingleCryptoCurrencyStatusUseCase = getSingCryptoCurrencyStatusUseCase, + singleAccountListSupplier = singleAccountListSupplier, gaslessTransactionRepository = gaslessTransactionRepository, cardSdkConfigRepository = cardSdkConfigRepository, getHotWalletSigner = tangemHotWalletSignerFactory::create, diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthTxHelper.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthTxHelper.kt index 3526fb2d8c..80da92856a 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthTxHelper.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthTxHelper.kt @@ -1,5 +1,6 @@ package com.tangem.data.walletconnect.network.ethereum +import arrow.core.getOrElse import com.domain.blockaid.models.transaction.CheckTransactionResult import com.domain.blockaid.models.transaction.SimulationResult import com.domain.blockaid.models.transaction.simultation.ApproveInfo @@ -17,16 +18,17 @@ import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toCoinId import com.tangem.common.extensions.hexToBytes import com.tangem.data.common.currency.getCoinId +import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCryptoCurrency +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase import com.tangem.domain.walletconnect.model.WcApprovedAmount import com.tangem.domain.walletconnect.model.WcEthTransactionParams import javax.inject.Inject internal class WcEthTxHelper @Inject constructor( - private val getSingleCryptoCurrency: GetSingleCryptoCurrencyStatusUseCase, + private val singleAccountListSupplier: SingleAccountListSupplier, private val ethSpecificFee: GetEthSpecificFeeUseCase, ) { @@ -34,10 +36,19 @@ internal class WcEthTxHelper @Inject constructor( val gasLimit = txParams.gas?.hexToBigInteger() ?: return null val gasPrice = txParams.gasPrice?.hexToBigInteger() val coinId = getCoinId(network, network.toBlockchain().toCoinId()) - val currency = getSingleCryptoCurrency.invokeMultiWalletSync(userWallet.walletId, coinId) - .map { it.currency } - .getOrNull() ?: return null - return ethSpecificFee(userWallet, currency, gasLimit, gasPrice) + + val currency = singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId) + .getCryptoCurrency(currencyId = coinId, network = network) + .getOrElse { + return null + } + + return ethSpecificFee( + userWallet = userWallet, + cryptoCurrency = currency, + gasLimit = gasLimit, + gasPrice = gasPrice, + ) .map { it.minimum } .getOrNull() } 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 index 91719dd554..1eb9756b6a 100644 --- 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 @@ -21,4 +21,10 @@ abstract class SingleAccountListSupplier( params = SingleAccountListProducer.Params(userWalletId = userWalletId), ) } + + suspend fun getSyncOrNull(userWalletId: UserWalletId): AccountList? { + return getSyncOrNull( + params = SingleAccountListProducer.Params(userWalletId = userWalletId), + ) + } } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusListSupplier.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusListSupplier.kt index 6177e2cde5..8e280e81df 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusListSupplier.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusListSupplier.kt @@ -20,4 +20,9 @@ abstract class SingleAccountStatusListSupplier( val params = SingleAccountStatusListProducer.Params(userWalletId) return this.invoke(params) } + + suspend fun getSyncOrNull(userWalletId: UserWalletId): AccountStatusList? { + val params = SingleAccountStatusListProducer.Params(userWalletId) + return this.getSyncOrNull(params) + } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetSingleCryptoCurrencyStatusUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetSingleCryptoCurrencyStatusUseCase.kt deleted file mode 100644 index 369303c3cb..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetSingleCryptoCurrencyStatusUseCase.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.tangem.domain.tokens - -import arrow.core.Either -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.tokens.error.CurrencyStatusError -import com.tangem.domain.tokens.error.mapper.mapToCurrencyError -import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations -import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.* - -/** - * Use case for fetching the status of a cryptocurrency associated with a user wallet. - * - */ -class GetSingleCryptoCurrencyStatusUseCase( - private val currencyStatusOperations: BaseCurrencyStatusOperations, - private val dispatchers: CoroutineDispatcherProvider, -) { - - /** - * Returns cryptocurrency status flow for Multi-Currency wallet - * - * @param userWalletId The unique identifier of the user's wallet. - * @param currencyId The unique identifier of the cryptocurrency. - * @param isSingleWalletWithTokens Indicates whether the user wallet contains only one token on card (old cards) - * @return A [Flow] emitting either a [CurrencyStatusError] or a [CryptoCurrencyStatus], indicating the result of the fetch operation. - */ - fun invokeMultiWallet( - userWalletId: UserWalletId, - currencyId: CryptoCurrency.ID, - isSingleWalletWithTokens: Boolean, - ): Flow> { - return flow { - emitAll( - getCurrencyStatus( - userWalletId = userWalletId, - currencyId = currencyId, - isSingleWalletWithTokens = isSingleWalletWithTokens, - ), - ) - }.flowOn(dispatchers.io) - } - - /** - * Returns cryptocurrency status flow for primary currency for Single-Currency wallet - * - * @param userWalletId The unique identifier of the user's wallet. - * @return A [Flow] emitting either a [CurrencyStatusError] or a [CryptoCurrencyStatus], indicating the result of the fetch operation. - */ - fun invokeSingleWallet(userWalletId: UserWalletId): Flow> { - return flow { - emitAll( - currencyStatusOperations.getPrimaryCurrencyStatusFlow(userWalletId).map { maybeCurrency -> - maybeCurrency.mapLeft(CurrenciesStatusesOperations.Error::mapToCurrencyError) - }, - ) - }.flowOn(dispatchers.io) - } - - /** - * Returns synchronously cryptocurrency status for Multi-Currency wallet - * - * @param userWalletId The unique identifier of the user's wallet. - * @param cryptoCurrencyId The unique identifier of the cryptocurrency. - * @param isSingleWalletWithTokens Indicates whether the user wallet contains only one token on card (old cards) - * @return A [Flow] emitting either a [CurrencyStatusError] or a [CryptoCurrencyStatus], indicating the result of the fetch operation. - */ - suspend fun invokeMultiWalletSync( - userWalletId: UserWalletId, - cryptoCurrencyId: CryptoCurrency.ID, - isSingleWalletWithTokens: Boolean = false, - ): Either { - return currencyStatusOperations.getCurrencyStatusSync(userWalletId, cryptoCurrencyId, isSingleWalletWithTokens) - .mapLeft { error -> error.mapToCurrencyError() } - } - - /** - * Returns synchronously cryptocurrency status for primary currency for Single-Currency wallet - * - * @param userWalletId The unique identifier of the user's wallet. - * @return A [Flow] emitting either a [CurrencyStatusError] or a [CryptoCurrencyStatus], indicating the result of the fetch operation. - */ - suspend fun invokeSingleWalletSync(userWalletId: UserWalletId): Either { - return currencyStatusOperations.getPrimaryCurrencyStatusSync(userWalletId) - .mapLeft { error -> error.mapToCurrencyError() } - } - - private suspend fun getCurrencyStatus( - userWalletId: UserWalletId, - currencyId: CryptoCurrency.ID, - isSingleWalletWithTokens: Boolean, - ): Flow> { - val currencyFlow = currencyStatusOperations.getCurrencyStatusFlow( - userWalletId = userWalletId, - currencyId = currencyId, - isSingleWalletWithTokens = isSingleWalletWithTokens, - ) - - return currencyFlow.map { maybeCurrency -> - maybeCurrency.mapLeft(CurrenciesStatusesOperations.Error::mapToCurrencyError) - } - } -} \ No newline at end of file diff --git a/domain/transaction/build.gradle.kts b/domain/transaction/build.gradle.kts index 9bf72aeddb..aae9c5368c 100644 --- a/domain/transaction/build.gradle.kts +++ b/domain/transaction/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(projects.libs.blockchainSdk) implementation(projects.libs.crypto) + implementation(projects.domain.account.status) implementation(projects.domain.models) implementation(projects.domain.legacy) implementation(projects.domain.walletManager) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt index 6966933ca2..90b0f2d731 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -1,6 +1,7 @@ package com.tangem.domain.transaction.usecase.gasless import arrow.core.Either +import arrow.core.getOrElse import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras @@ -16,13 +17,13 @@ import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldS import com.tangem.common.CompletionResult import com.tangem.common.extensions.toDecompressedPublicKey import com.tangem.common.extensions.toHexString +import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCryptoCurrency +import com.tangem.domain.account.supplier.SingleAccountListSupplier 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.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.models.Eip7702Authorization @@ -33,7 +34,7 @@ import java.math.BigInteger class CreateAndSendGaslessTransactionUseCase( private val walletManagersFacade: WalletManagersFacade, - private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val singleAccountListSupplier: SingleAccountListSupplier, private val gaslessTransactionRepository: GaslessTransactionRepository, private val cardSdkConfigRepository: CardSdkConfigRepository, private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner, @@ -74,18 +75,17 @@ class CreateAndSendGaslessTransactionUseCase( transactionData: TransactionData.Uncompiled, fee: TransactionFeeExtended, ): GaslessContext { - val tokenForFeeStatus = getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWallet.walletId, - fee.feeTokenId, - ).getOrNull() ?: error("Token for fee not found") + val currency = singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId) + .getCryptoCurrency(currencyId = fee.feeTokenId, network = null) + .getOrElse { error("Token for fee not found") } val walletManager = walletManagersFacade.getOrCreateWalletManager( userWallet.walletId, - tokenForFeeStatus.currency.network, - ) ?: error("WalletManager not found for network ${tokenForFeeStatus.currency.network.id}") + currency.network, + ) ?: error("WalletManager not found for network ${currency.network.id}") val gaslessDataProvider = walletManager as? EthereumGaslessDataProvider ?: error( - "WalletManager for network ${tokenForFeeStatus.currency.network.id} " + + "WalletManager for network ${currency.network.id} " + "does not support gasless transactions", ) @@ -94,16 +94,16 @@ class CreateAndSendGaslessTransactionUseCase( val gaslessTransactionData = createGaslessTransactionData( transactionData = transactionData, txFee = fee, - tokenFeeStatus = tokenForFeeStatus, + currency = currency, nonce = gaslessContractNonce, ) - val chainId = gaslessTransactionRepository.getChainIdForNetwork(tokenForFeeStatus.currency.network) + val chainId = gaslessTransactionRepository.getChainIdForNetwork(currency.network) return GaslessContext( walletManager = walletManager, gaslessDataProvider = gaslessDataProvider, - tokenForFeeStatus = tokenForFeeStatus, + currency = currency, gaslessTransactionData = gaslessTransactionData, chainId = chainId, ) @@ -189,7 +189,7 @@ class CreateAndSendGaslessTransactionUseCase( transactionData: TransactionData.Uncompiled, ): String { val txHash = gaslessTransactionRepository.signGaslessTransaction( - network = context.tokenForFeeStatus.currency.network, + network = context.currency.network, gaslessTransactionData = context.gaslessTransactionData, signature = signedData.eip712Signature, userAddress = transactionData.sourceAddress, @@ -244,11 +244,11 @@ class CreateAndSendGaslessTransactionUseCase( private suspend fun createGaslessTransactionData( transactionData: TransactionData.Uncompiled, txFee: TransactionFeeExtended, - tokenFeeStatus: CryptoCurrencyStatus, + currency: CryptoCurrency, nonce: BigInteger, ): GaslessTransactionData { val transaction = buildTransaction(transactionData) - val fee = buildFee(txFee, tokenFeeStatus) + val fee = buildFee(txFee, currency) return GaslessTransactionData( transaction = transaction, @@ -272,11 +272,8 @@ class CreateAndSendGaslessTransactionUseCase( ) } - private suspend fun buildFee( - txFee: TransactionFeeExtended, - tokenFeeStatus: CryptoCurrencyStatus, - ): GaslessTransactionData.Fee { - val tokenForFee = tokenFeeStatus.currency as? CryptoCurrency.Token + private suspend fun buildFee(txFee: TransactionFeeExtended, currency: CryptoCurrency): GaslessTransactionData.Fee { + val tokenForFee = currency as? CryptoCurrency.Token ?: error("Fee currency must be a token") val feeInTokenCurrency = txFee.transactionFee.normal as? Fee.Ethereum.TokenCurrency @@ -320,7 +317,7 @@ class CreateAndSendGaslessTransactionUseCase( private data class GaslessContext( val walletManager: WalletManager, val gaslessDataProvider: EthereumGaslessDataProvider, - val tokenForFeeStatus: CryptoCurrencyStatus, + val currency: CryptoCurrency, val gaslessTransactionData: GaslessTransactionData, val chainId: Int, ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt index ac73f11c28..836b3534d5 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt @@ -8,11 +8,12 @@ import arrow.core.raise.either import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase @@ -55,7 +56,7 @@ internal class FeeSelectorLogic @AssistedInject constructor( private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger, private val feeSelectorAlertFactory: FeeSelectorAlertFactory, private val analyticsEventHandler: AnalyticsEventHandler, - private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getUserWalletUseCase: GetUserWalletUseCase, private val getAvailableFeeTokensUseCase: GetAvailableFeeTokensUseCase, isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, @@ -294,11 +295,9 @@ internal class FeeSelectorLogic @AssistedInject constructor( private suspend fun getSelectedTokenStatus(tokenId: CryptoCurrency.ID): Either = either { if (params.feeCryptoCurrencyStatus.currency.id != tokenId) { - getSingleCryptoCurrencyStatusUseCase - .invokeMultiWalletSync( - userWalletId = params.userWalletId, - cryptoCurrencyId = tokenId, - ).getOrElse { + singleAccountStatusListSupplier.getSyncOrNull(params.userWalletId) + .getCryptoCurrencyStatus(currencyId = tokenId, network = null) + .getOrElse { raise(GetFeeError.DataError(IllegalStateException("No token found for id: $tokenId"))) } } else { 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 4c90fa3f34..1d1951f503 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 @@ -35,7 +35,9 @@ import com.tangem.core.ui.utils.InputNumberFormatter import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency @@ -69,7 +71,6 @@ import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayWithdrawUseCase import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended @@ -136,7 +137,6 @@ internal class SwapModel @Inject constructor( private val analyticsErrorEventHandler: AnalyticsErrorHandler, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase, - private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, @@ -153,6 +153,7 @@ internal class SwapModel @Inject constructor( router: AppRouter, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, @@ -386,10 +387,9 @@ internal class SwapModel @Inject constructor( } else { val fromStatus = getFromStatus() val toStatus = initialCurrencyTo?.let { currencyTo -> - getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWalletId = userWalletId, - cryptoCurrencyId = currencyTo.id, - ).getOrNull() + singleAccountStatusListSupplier.getSyncOrNull(params.userWalletId) + .getCryptoCurrencyStatus(currencyTo) + .getOrNull() } if (fromStatus == null) { @@ -2305,10 +2305,9 @@ internal class SwapModel @Inject constructor( depositAddress = tangemPayInput.depositAddress, ) } else { - getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWalletId = userWalletId, - cryptoCurrencyId = initialCurrencyFrom.id, - ).getOrNull() + singleAccountStatusListSupplier.getSyncOrNull(params.userWalletId) + .getCryptoCurrencyStatus(currency = initialCurrencyFrom) + .getOrNull() } } diff --git a/features/txhistory/impl/build.gradle.kts b/features/txhistory/impl/build.gradle.kts index c3b3021014..aae9596318 100644 --- a/features/txhistory/impl/build.gradle.kts +++ b/features/txhistory/impl/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(projects.domain.tokens.models) implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.account.status) /* AndroidX */ implementation(deps.androidx.activity.compose) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt index d587d9b46c..7476b496da 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt @@ -1,22 +1,19 @@ package com.tangem.features.txhistory.model import androidx.compose.runtime.Stable -import arrow.core.Either +import arrow.core.Option import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase -import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.txhistory.models.TxHistoryStateError import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase -import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.txhistory.component.TxHistoryComponent import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter import com.tangem.features.txhistory.entity.TxHistoryUM @@ -40,8 +37,7 @@ internal class TxHistoryModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, - private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, - private val getUserWalletUseCase: GetUserWalletUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val urlOpener: UrlOpener, private val txHistoryUpdateListener: TxHistoryUpdateListener, @@ -184,26 +180,20 @@ internal class TxHistoryModel @Inject constructor( } private fun subscribeOnCurrencyStatusUpdates() { - val userWallet: UserWallet = requireNotNull(getUserWalletUseCase(params.userWalletId).getOrNull()) { - "User wallet not found" - } - getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( - userWalletId = params.userWalletId, - currencyId = params.currency.id, - isSingleWalletWithTokens = userWallet is UserWallet.Cold && - userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), - ) + singleAccountStatusListSupplier(params.userWalletId) + .map { it.getCryptoCurrencyStatus(currency = params.currency) } .distinctUntilChanged() .onEach(::handlePendingTxsChanges) - .flowOn(dispatchers.main) + .flowOn(dispatchers.default) .launchIn(modelScope) } - private fun handlePendingTxsChanges(maybeCurrencyStatus: Either) { - maybeCurrencyStatus.onRight { status -> + private fun handlePendingTxsChanges(maybeCurrencyStatus: Option) { + maybeCurrencyStatus.onSome { status -> val pendingTxs = status.value.pendingTransactions .map(txHistoryItemConverter::convert) .toPersistentList() + _uiState.update { state -> if (state is TxHistoryUM.NotSupported) { state.copy(pendingTransactions = pendingTxs) 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 5c65edb11b..5c603a74b5 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 @@ -8,6 +8,7 @@ import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency @@ -18,7 +19,6 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.analytics.NFTAnalyticsEvent import com.tangem.domain.settings.ShouldShowMarketsTooltipUseCase import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.model.details.NavigationAction import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -94,7 +94,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( private val currencyActionsClickIntents: WalletCurrencyActionsClickIntentsImplementor, private val onrampStatusFactory: OnrampStatusFactory, private val getUserWalletUseCase: GetUserWalletUseCase, - private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val shouldShowMarketsTooltipUseCase: ShouldShowMarketsTooltipUseCase, @@ -269,7 +269,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( override fun onTransactionClick(txHash: String) { modelScope.launch(dispatchers.main) { - val currency = getSingleCryptoCurrencyStatusUseCase.unwrap( + val currency = singleAccountStatusListSupplier.unwrap( userWalletId = stateHolder.getSelectedWalletId(), ) ?.currency diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt index b57effd249..98811cfe6f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt @@ -22,6 +22,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap @@ -42,7 +43,10 @@ import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.promo.GetStoryContentUseCase import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.staking.model.StakingOption -import com.tangem.domain.tokens.* +import com.tangem.domain.tokens.IsCryptoCurrencyCoinCouldHideUseCase +import com.tangem.domain.tokens.NeedShowYieldSupplyDepositedWarningUseCase +import com.tangem.domain.tokens.SaveViewedTokenReceiveWarningUseCase +import com.tangem.domain.tokens.SaveViewedYieldSupplyWarningUseCase import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.analytics.TokenReceiveCopyActionSource import com.tangem.domain.tokens.model.analytics.TokenReceiveNewAnalyticsEvent @@ -124,7 +128,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val walletManagersFacade: WalletManagersFacade, private val isDemoCardUseCase: IsDemoCardUseCase, - private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getExploreUrlUseCase: GetExploreUrlUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getStoryContentUseCase: GetStoryContentUseCase, @@ -485,7 +489,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( val userWalletId = stateHolder.getSelectedWalletId() modelScope.launch(dispatchers.main) { - val currencyStatus = getSingleCryptoCurrencyStatusUseCase.unwrap(userWalletId) ?: return@launch + val currencyStatus = singleAccountStatusListSupplier.unwrap(userWalletId) ?: return@launch when (val addresses = currencyStatus.value.networkAddress) { is NetworkAddress.Selectable -> { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/UseCaseExt.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/UseCaseExt.kt index 58da9fca4d..4b0d406ee5 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/UseCaseExt.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/UseCaseExt.kt @@ -1,13 +1,11 @@ package com.tangem.feature.wallet.presentation.wallet.domain -import arrow.core.Either +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase -import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase -import kotlinx.coroutines.flow.* import timber.log.Timber internal fun GetSelectedWalletSyncUseCase.unwrap(): UserWallet? { @@ -20,29 +18,9 @@ internal fun GetSelectedWalletSyncUseCase.unwrap(): UserWallet? { ) } -internal suspend fun GetSingleCryptoCurrencyStatusUseCase.unwrap(userWalletId: UserWalletId): CryptoCurrencyStatus? { - return invokeSingleWallet(userWalletId) - .conflate() - .distinctUntilChanged() - .filter(Either::isRight) - .firstOrNull() - ?.fold( - ifLeft = { - Timber.e("Impossible to get primary currency status $it") - null - }, - ifRight = { it }, - ) -} - -internal suspend fun GetSingleCryptoCurrencyStatusUseCase.collectLatest( - userWalletId: UserWalletId, - onRight: suspend (CryptoCurrencyStatus) -> Unit, -) { - invokeSingleWallet(userWalletId = userWalletId) - .conflate() - .distinctUntilChanged() - .collectLatest { maybeStatus -> - maybeStatus.onRight { onRight(it) } - } +internal suspend fun SingleAccountStatusListSupplier.unwrap(userWalletId: UserWalletId): CryptoCurrencyStatus? { + return getSyncOrNull(params = SingleAccountStatusListProducer.Params(userWalletId)) + ?.mainAccount + ?.flattenCurrencies() + ?.firstOrNull() } \ No newline at end of file diff --git a/features/yield-supply/impl/build.gradle.kts b/features/yield-supply/impl/build.gradle.kts index f1b9082389..ada6f291fb 100644 --- a/features/yield-supply/impl/build.gradle.kts +++ b/features/yield-supply/impl/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.appCurrency.models) implementation(projects.domain.appCurrency) + implementation(projects.domain.account.status) implementation(projects.domain.wallets.models) implementation(projects.domain.wallets) implementation(projects.domain.tokens.models) diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt index 3c65576a24..8ddd253ffb 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt @@ -16,12 +16,13 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.yield.supply.usecase.* import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent @@ -55,7 +56,7 @@ internal class YieldSupplyActiveModel @Inject constructor( private val yieldSupplyGetMaxFeeUseCase: YieldSupplyGetMaxFeeUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, - private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val urlOpener: UrlOpener, private val appRouter: AppRouter, private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase, @@ -168,44 +169,44 @@ internal class YieldSupplyActiveModel @Inject constructor( ifRight = { wallet -> userWallet = wallet - getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( - userWalletId = params.userWalletId, - currencyId = cryptoCurrency.id, - isSingleWalletWithTokens = false, - ).onEach { maybeCryptoCurrency -> - maybeCryptoCurrency.fold( - ifRight = { cryptoCurrencyStatus -> - cryptoCurrencyStatusFlow.update { cryptoCurrencyStatus } + singleAccountStatusListSupplier(params.userWalletId) + .map { it.getCryptoCurrencyStatus(currency = cryptoCurrency) } + .distinctUntilChanged() + .onEach { maybeCryptoCurrency -> + maybeCryptoCurrency.fold( + ifSome = { cryptoCurrencyStatus -> + cryptoCurrencyStatusFlow.update { cryptoCurrencyStatus } - val protocolBalance = - cryptoCurrencyStatus.value.yieldSupplyStatus?.effectiveProtocolBalance - ?: yieldSupplyGetProtocolBalanceUseCase( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrency, - ).getOrNull() + val protocolBalance = + cryptoCurrencyStatus.value.yieldSupplyStatus?.effectiveProtocolBalance + ?: yieldSupplyGetProtocolBalanceUseCase( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ).getOrNull() - loadApy() - loadMinAmount() - loadFees() + loadApy() + loadMinAmount() + loadFees() - uiState.update { - it.copy( - availableBalance = stringReference( - protocolBalance.format { - crypto( - symbol = AAVEV3_PREFIX + cryptoCurrency.symbol, - decimals = cryptoCurrency.decimals, - ) - }, - ), - ) - } - }, - ifLeft = { - Timber.w(it.toString()) - }, - ) - }.flowOn(dispatchers.default) + uiState.update { + it.copy( + availableBalance = stringReference( + protocolBalance.format { + crypto( + symbol = AAVEV3_PREFIX + cryptoCurrency.symbol, + decimals = cryptoCurrency.decimals, + ) + }, + ), + ) + } + }, + ifEmpty = { + Timber.w("No currency status found: ${cryptoCurrency.id}") + }, + ) + } + .flowOn(dispatchers.default) .launchIn(modelScope) }, ifLeft = { error -> diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt index 90a25adf75..5314191db2 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt @@ -5,9 +5,10 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.model.details.NavigationAction import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent @@ -24,7 +25,7 @@ internal class YieldSupplyEntryModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase, - private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, ) : Model() { private val params = paramsContainer.require() @@ -39,22 +40,22 @@ internal class YieldSupplyEntryModel @Inject constructor( val userWalletId = params.userWalletId val cryptoCurrency = params.cryptoCurrency modelScope.launch(dispatchers.default) { - getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWalletId = userWalletId, - cryptoCurrencyId = cryptoCurrency.id, - ).onLeft { error -> - Timber.e("Failed to get CryptoCurrencyStatus: $error") - withContext(dispatchers.mainImmediate) { - router.pop() - } - }.onRight { cryptoCurrencyStatus -> - withContext(dispatchers.mainImmediate) { - val route = getInitialRoute(cryptoCurrencyStatus) - if (route != null) { - router.replaceCurrent(route) + singleAccountStatusListSupplier.getSyncOrNull(userWalletId) + .getCryptoCurrencyStatus(currency = cryptoCurrency) + .onNone { + Timber.e("Failed to get CryptoCurrencyStatus: ${cryptoCurrency.id}") + withContext(dispatchers.mainImmediate) { + router.pop() + } + } + .onSome { cryptoCurrencyStatus -> + withContext(dispatchers.mainImmediate) { + val route = getInitialRoute(cryptoCurrencyStatus) + if (route != null) { + router.replaceCurrent(route) + } } } - } } } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index 381a2c9fab..ac026fed7c 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -11,6 +11,8 @@ 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.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.StatusSource @@ -21,7 +23,6 @@ import com.tangem.domain.models.currency.shouldShowNotSuppliedNotification import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.yield.supply.YieldSupplyStatus import com.tangem.domain.networks.single.SingleNetworkStatusFetcher -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import com.tangem.domain.yield.supply.usecase.* @@ -35,9 +36,9 @@ import com.tangem.utils.transformer.update import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import kotlin.properties.Delegates -import java.util.concurrent.atomic.AtomicBoolean @Suppress("LongParameterList", "LargeClass") @ModelScoped @@ -48,7 +49,7 @@ internal class YieldSupplyModel @Inject constructor( private val appRouter: AppRouter, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, - private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, private val yieldSupplyIsAvailableUseCase: YieldSupplyIsAvailableUseCase, @@ -109,32 +110,33 @@ internal class YieldSupplyModel @Inject constructor( private fun subscribeOnCurrencyStatusUpdates() { combine( - getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( - userWalletId = params.userWalletId, - currencyId = cryptoCurrency.id, - isSingleWalletWithTokens = false, - ), - yieldSupplyEnterStatusFlowUseCase( + flow = singleAccountStatusListSupplier(params.userWalletId).map { + it.getCryptoCurrencyStatus(currency = cryptoCurrency) + }, + flow2 = yieldSupplyEnterStatusFlowUseCase( userWalletId = params.userWalletId, cryptoCurrency = cryptoCurrency, ), ) { maybeCryptoCurrency, _ -> maybeCryptoCurrency - }.flowOn(dispatchers.io) + } + .flowOn(dispatchers.io) + .distinctUntilChanged() .onEach { maybeCryptoCurrency -> maybeCryptoCurrency.fold( - ifRight = { cryptoCurrencyStatus -> + ifSome = { cryptoCurrencyStatus -> latestCryptoCurrencyStatus = cryptoCurrencyStatus if (isFirstCryptoCurrencyStatusEmission.compareAndSet(true, false)) { sendInfoAboutProtocolStatus(cryptoCurrencyStatus) } onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus) }, - ifLeft = { - Timber.w(it.toString()) + ifEmpty = { + Timber.w("Unable to get crypto currency status: ${cryptoCurrency.id}") }, ) - }.launchIn(modelScope) + } + .launchIn(modelScope) } private suspend fun loadTokenStatus() { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index 9c8635a26b..f66d505e7f 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -13,13 +13,14 @@ import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIco import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.datasource.local.appsflyer.AppsFlyerStore +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus 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.models.wallet.isHotWallet import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -55,7 +56,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor( paramsContainer: ParamsContainer, private val analytics: AnalyticsEventHandler, private val getUserWalletUseCase: GetUserWalletUseCase, - private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val sendTransactionUseCase: SendTransactionUseCase, private val yieldSupplyStartEarningUseCase: YieldSupplyStartEarningUseCase, @@ -346,27 +347,27 @@ internal class YieldSupplyStartEarningModel @Inject constructor( } private fun getCurrenciesStatusUpdates() { - getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( - userWalletId = userWalletId, - currencyId = cryptoCurrency.id, - isSingleWalletWithTokens = false, - ).onEach { maybeCryptoCurrency -> - maybeCryptoCurrency.fold( - ifRight = { cryptoCurrencyStatus -> - onDataLoaded( - currencyStatus = cryptoCurrencyStatus, - feeCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = cryptoCurrencyStatus, - ).getOrNull() ?: cryptoCurrencyStatus, - ) - }, - ifLeft = { - Timber.w(it.toString()) - showAlertError() - }, - ) - }.launchIn(modelScope) + singleAccountStatusListSupplier(params.userWalletId) + .map { it.getCryptoCurrencyStatus(currency = cryptoCurrency) } + .distinctUntilChanged() + .onEach { maybeCryptoCurrency -> + maybeCryptoCurrency.fold( + ifSome = { cryptoCurrencyStatus -> + onDataLoaded( + currencyStatus = cryptoCurrencyStatus, + feeCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull() ?: cryptoCurrencyStatus, + ) + }, + ifEmpty = { + Timber.w("Unable to get crypto currency status: ${cryptoCurrency.id}") + showAlertError() + }, + ) + } + .launchIn(modelScope) } private fun onDataLoaded(currencyStatus: CryptoCurrencyStatus, feeCurrencyStatus: CryptoCurrencyStatus) { From 8411911466def0eac29b33d5385f35d30dff53cf Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 11 Mar 2026 15:16:56 +0500 Subject: [PATCH 18/60] Updated on 2026-08-14 --- .../bottomsheets/TangemBottomSheet.kt | 440 ++++++++++++++++++ .../internal/InternalBottomSheet.kt | 88 ++++ .../ModalBottomSheetWithBackHandling.kt | 47 +- .../bottomsheets/sheet/TangemBottomSheet.kt | 2 +- ...TangemBottomSheetDraggableHeaderLegacy.kt} | 20 +- .../TangemBottomSheetScaffold.kt | 227 ++++----- .../tangem/core/ui/res/TangemColorPalette.kt | 3 + .../com/tangem/core/ui/res/TangemColors2.kt | 26 ++ .../tangem/core/ui/res/TangemThemeRedesign.kt | 14 + .../storybook/page/badge/TangemBadgeStory.kt | 4 +- .../ui/OrganizeTokensContent.kt | 33 +- .../presentation/wallet/ui/WalletScreen.kt | 117 +++-- .../presentation/wallet/ui/WalletScreen2.kt | 141 ++++-- 13 files changed, 858 insertions(+), 304 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/InternalBottomSheet.kt rename core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/{TangemBottomSheetDraggableHeader.kt => TangemBottomSheetDraggableHeaderLegacy.kt} (61%) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt new file mode 100644 index 0000000000..2eceba7401 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt @@ -0,0 +1,440 @@ +package com.tangem.core.ui.components.bottomsheets + +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.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.material3.SheetValue.Expanded +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import 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.bottomsheets.TangemBottomSheetType.Default +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType.Modal +import com.tangem.core.ui.components.bottomsheets.internal.InternalBottomSheet +import com.tangem.core.ui.components.bottomsheets.internal.collapse +import com.tangem.core.ui.components.bottomsheets.modal.MODAL_SHEET_MAX_HEIGHT +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetDraggableHeader +import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible +import com.tangem.core.ui.res.LocalWindowSize +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.utils.WindowInsetsZero + +/** + * Type of [TangemBottomSheet] that defines its behavior and appearance. + * - [Default]: Standard bottom sheet with a draggable header + * - [Modal]: Modal bottom sheet without a draggable header + */ +enum class TangemBottomSheetType { + Default, Modal; + + fun getDragHandle(): (@Composable (() -> Unit))? = when (this) { + Default -> { + { TangemBottomSheetDraggableHeader() } + } + Modal -> null + } +} + +/** + * Modal bottom sheet with [content] and optional [title] and [footer]. + * + * @param config Configuration for the bottom sheet, including visibility and content data. + * @param type Type of the bottom sheet that defines its behavior and appearance. + * @param containerColor Background color of the bottom sheet container. + * @param skipPartiallyExpanded Whether to skip the partially expanded state when dragging the sheet. + * @param onBack Optional callback for handling back press when the sheet is visible. + * @param title Optional composable for rendering the title section of the sheet, receiving the content + * model as a parameter. + * @param content Composable for rendering the main content of the sheet, receiving the content model + * as a parameter. + * @param footer Optional composable for rendering the footer section of the sheet, receiving the content + * model as a parameter. + * + * [Show in Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8454-23749&m=dev) + */ +@Composable +inline fun TangemBottomSheet( + config: TangemBottomSheetConfig, + type: TangemBottomSheetType = Default, + containerColor: Color = TangemTheme.colors2.surface.level2, + skipPartiallyExpanded: Boolean = true, + noinline onBack: (() -> Unit)? = null, + crossinline title: @Composable BoxScope.(T) -> Unit = {}, + crossinline content: @Composable (T) -> Unit, + noinline footer: @Composable (BoxScope.(T) -> Unit)? = null, +) { + val isAlwaysVisible = LocalBottomSheetAlwaysVisible.current + + if (isAlwaysVisible) { + PreviewModalBottomSheetWithFooter( + config = config, + containerColor = containerColor, + type = type, + title = title, + content = content, + footer = footer, + skipPartiallyExpanded = skipPartiallyExpanded, + ) + } else { + DefaultModalBottomSheetWithFooter( + config = config, + containerColor = containerColor, + type = type, + title = title, + content = content, + footer = footer, + onBack = onBack, + skipPartiallyExpanded = skipPartiallyExpanded, + ) + } +} + +@Suppress("LongParameterList") +@Composable +@OptIn(ExperimentalMaterial3Api::class) +inline fun DefaultModalBottomSheetWithFooter( + config: TangemBottomSheetConfig, + containerColor: Color, + type: TangemBottomSheetType, + skipPartiallyExpanded: Boolean = true, + noinline onBack: (() -> Unit)? = null, + crossinline title: @Composable BoxScope.(T) -> Unit, + crossinline content: @Composable (T) -> Unit, + noinline footer: @Composable (BoxScope.(T) -> Unit)?, +) { + var isVisible by remember { mutableStateOf(value = config.isShown) } + + val sheetState = if (config.dismissOnClickOutside == null) { + rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded) + } else { + rememberModalBottomSheetState( + skipPartiallyExpanded = skipPartiallyExpanded, + confirmValueChange = { sheetValue -> + if (config.dismissOnClickOutside().not()) { + // Ignore transitions to hidden (prevents dismiss on outside click/back press) + sheetValue != SheetValue.Hidden + } else { + true + } + }, + ) + } + + if (isVisible && config.content is T) { + BasicBottomSheet( + config = config, + sheetState = sheetState, + containerColor = containerColor, + type = type, + title = title, + onBack = onBack, + content = content, + footer = footer, + ) + } + + LaunchedEffect(key1 = config.isShown) { + if (config.isShown) { + isVisible = true + } else { + sheetState.collapse { isVisible = false } + } + } +} + +@Suppress("LongParameterList") +@Composable +@OptIn(ExperimentalMaterial3Api::class) +inline fun PreviewModalBottomSheetWithFooter( + config: TangemBottomSheetConfig, + containerColor: Color, + type: TangemBottomSheetType, + skipPartiallyExpanded: Boolean = true, + crossinline title: @Composable BoxScope.(T) -> Unit, + crossinline content: @Composable (T) -> Unit, + noinline footer: @Composable (BoxScope.(T) -> Unit)?, +) { + BasicBottomSheet( + config = config, + sheetState = SheetState( + skipPartiallyExpanded = skipPartiallyExpanded, + initialValue = Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ), + onBack = null, + containerColor = containerColor, + type = type, + title = title, + content = content, + footer = footer, + ) +} + +@Suppress("LongParameterList", "LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +inline fun BasicBottomSheet( + config: TangemBottomSheetConfig, + sheetState: SheetState, + containerColor: Color, + type: TangemBottomSheetType, + modifier: Modifier = Modifier, + noinline onBack: (() -> Unit)? = null, + crossinline title: @Composable BoxScope.(T) -> Unit, + crossinline content: @Composable (T) -> Unit, + noinline footer: @Composable (BoxScope.(T) -> Unit)?, +) { + val model = config.content as? T ?: return + val windowSize = LocalWindowSize.current + + val bsContent: @Composable ColumnScope.() -> Unit = { + val maxHeight = when (type) { + Default -> Dp.Unspecified + Modal -> windowSize.height * MODAL_SHEET_MAX_HEIGHT + } + + val buttonHeight by animateDpAsState( + if (footer != null) { + 80.dp + } else { + 0.dp + }, + ) + + val contentModifier = when (type) { + Default -> Modifier.clip( + RoundedCornerShape( + topStart = TangemTheme.dimens2.x8, + topEnd = TangemTheme.dimens2.x8, + ), + ) + Modal -> Modifier + .padding( + start = TangemTheme.dimens2.x2, + end = TangemTheme.dimens2.x2, + bottom = TangemTheme.dimens2.x2, + ) + .clip(RoundedCornerShape(TangemTheme.dimens2.x8)) + } + + Column( + modifier = contentModifier + .background(containerColor) + .heightIn(max = maxHeight), + ) { + Box(modifier = Modifier.fillMaxWidth()) { + title(model) + } + Box(modifier = Modifier.fillMaxWidth()) { + content(model) + if (footer != null) { + BottomFade( + modifier = Modifier.align(Alignment.BottomCenter), + gradientBrush = Brush.verticalGradient( + listOf( + TangemTheme.colors2.shadow.fadeMin, + TangemTheme.colors2.shadow.fadeMax, + ), + ), + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(buttonHeight) + .align(Alignment.BottomCenter), + ) { + if (footer != null) { + footer(model) + } + } + } + } + } + + InternalBottomSheet( + modifier = modifier.statusBarsPadding(), + onDismissRequest = config.onDismissRequest, + sheetState = sheetState, + containerColor = Color.Transparent, + contentWindowInsets = { WindowInsetsZero }, + onBack = onBack, + dragHandle = type.getDragHandle(), + content = bsContent, + scrimColor = TangemTheme.colors2.overlay.overlaySecondary, + ) +} + +// region Preview +@Suppress("LongMethod") +@Composable +@Preview(showBackground = true, widthDp = 360, heightDp = 800) +@Preview(showBackground = true, widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemModalBottomSheetWithFooter_Preview( + @PreviewParameter(TangemBottomSheetPreviewProvider::class) params: TangemBottomSheetType, +) { + TangemThemePreviewRedesign { + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + type = params, + title = { TangemModalBottomSheetTitle(endIconRes = R.drawable.ic_close_24, onEndClick = {}) }, + content = { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(100)) + .background(TangemTheme.colors.icon.informative.copy(alpha = 0.1f)) + .padding(12.dp), + painter = rememberVectorPainter( + ImageVector.vectorResource(R.drawable.ic_alert_24), + ), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + SpacerH24() + Text( + text = "Unsuported networks", + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + SpacerH8() + Text( + text = "Tangem does not currently support a required network by React App.", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + SpacerH(48.dp) + Text( + text = "Long text to show scrollable content and bottom fade." + + "\nLorem ipsum dolor sit amet, consectetur adipiscing elit. In imperdiet metus non leo " + + "ultricies pulvinar. Pellentesque sed condimentum odio. Sed venenatis ac felis non " + + "consequat. Nunc erat dolor, maximus nec mattis a, tempus at eros. Duis sit amet neque " + + "dui. Donec consectetur nisl id dui convallis, in posuere dolor eleifend. Pellentesque " + + "habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. " + + "Pellentesque consequat scelerisque justo quis tristique. Mauris laoreet venenatis " + + "pharetra. Morbi sed faucibus leo. Praesent elementum pretium posuere. Morbi et felis a " + + "turpis pellentesque rhoncus.", + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + } + }, + footer = { + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + text = "Go it", + onClick = {}, + ) + }, + ) + } +} + +@Suppress("LongMethod") +@Composable +@Preview(showBackground = true, widthDp = 360, heightDp = 800) +@Preview(showBackground = true, widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemModalBottomSheetWithFooter_Preview2( + @PreviewParameter(TangemBottomSheetPreviewProvider::class) params: TangemBottomSheetType, +) { + TangemThemePreviewRedesign { + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + type = params, + title = { TangemModalBottomSheetTitle(endIconRes = R.drawable.ic_close_24, onEndClick = {}) }, + content = { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(100)) + .background(TangemTheme.colors.icon.informative.copy(alpha = 0.1f)) + .padding(12.dp), + painter = rememberVectorPainter( + ImageVector.vectorResource(R.drawable.ic_alert_24), + ), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + SpacerH24() + Text( + text = "Unsuported networks", + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + SpacerH8() + Text( + text = "Tangem does not currently support a required network by React App.", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + SpacerH(48.dp) + Text( + text = "Long text to show scrollable content and bottom fade." + + "\nLorem ipsum dolor sit amet, consectetur adipiscing elit. In imperdiet metus non leo " + + "ultricies pulvinar. Pellentesque sed condimentum odio. Sed venenatis ac felis non " + + "consequat. Nunc erat dolor, maximus nec mattis a, tempus at eros. Duis sit amet neque " + + "dui. Donec consectetur nisl id dui convallis, in posuere dolor eleifend. Pellentesque " + + "habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. " + + "Pellentesque consequat scelerisque justo quis tristique. Mauris laoreet venenatis " + + "pharetra. Morbi sed faucibus leo. Praesent elementum pretium posuere. Morbi et felis a " + + "turpis pellentesque rhoncus.", + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + } + }, + ) + } +} + +private class TangemBottomSheetPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + Default, + Modal, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/InternalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/InternalBottomSheet.kt new file mode 100644 index 0000000000..6d311677d4 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/InternalBottomSheet.kt @@ -0,0 +1,88 @@ +package com.tangem.core.ui.components.bottomsheets.internal + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHost +import com.tangem.core.ui.res.LocalHazeState +import com.tangem.core.ui.res.LocalTopSnackbarHostState +import com.tangem.core.ui.res.TangemTheme +import dev.chrisbanes.haze.rememberHazeState +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InternalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, + sheetState: SheetState = rememberModalBottomSheetState(), + sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, + shape: Shape = BottomSheetDefaults.ExpandedShape, + containerColor: Color = BottomSheetDefaults.ContainerColor, + contentColor: Color = contentColorFor(containerColor), + tonalElevation: Dp = 0.dp, + scrimColor: Color = BottomSheetDefaults.ScrimColor, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, + content: @Composable ColumnScope.() -> Unit, +) { + val topSnackbarHostState = LocalTopSnackbarHostState.current + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + modifier = modifier, + sheetState = sheetState, + sheetMaxWidth = sheetMaxWidth, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + tonalElevation = tonalElevation, + scrimColor = scrimColor, + dragHandle = dragHandle, + contentWindowInsets = contentWindowInsets, + properties = ModalBottomSheetProperties( + shouldDismissOnBackPress = onBack == null, + ), + content = { + Box { + val hazeState = rememberHazeState() + + Column(Modifier.hazeSourceTangem(hazeState)) { + content() + } + + CompositionLocalProvider(LocalHazeState provides hazeState) { + TangemTopSnackbarHost( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(top = TangemTheme.dimens2.x6), + hostState = topSnackbarHostState, + ) + } + } + + BackHandler(enabled = onBack != null && sheetState.targetValue != SheetValue.Hidden) { + onBack?.invoke() + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +suspend fun SheetState.collapse(onCollapsed: () -> Unit) { + coroutineScope { + launch { hide() }.invokeOnCompletion { onCollapsed() } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt index a62e171f5f..b4a3b4c092 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt @@ -1,29 +1,15 @@ package com.tangem.core.ui.components.bottomsheets.internal import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.padding import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.input.key.* import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.haze.hazeSourceTangem -import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHost -import com.tangem.core.ui.res.LocalHazeState -import com.tangem.core.ui.res.LocalRedesignEnabled -import com.tangem.core.ui.res.LocalTopSnackbarHostState -import dev.chrisbanes.haze.rememberHazeState -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -42,9 +28,6 @@ fun ModalBottomSheetWithBackHandling( contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, content: @Composable ColumnScope.() -> Unit, ) { - val topSnackbarHostState = LocalTopSnackbarHostState.current - val isRedesignEnabled = LocalRedesignEnabled.current - ModalBottomSheet( onDismissRequest = onDismissRequest, modifier = modifier, @@ -61,38 +44,10 @@ fun ModalBottomSheetWithBackHandling( shouldDismissOnBackPress = onBack == null, ), content = { - if (isRedesignEnabled) { - Box { - val hazeState = rememberHazeState() - - Column(Modifier.hazeSourceTangem(hazeState, zIndex = -1f)) { - content() - } - - CompositionLocalProvider(LocalHazeState provides hazeState) { - TangemTopSnackbarHost( - modifier = Modifier - .align(Alignment.TopCenter) - .padding(horizontal = 16.dp) - .padding(top = 24.dp), - hostState = topSnackbarHostState, - ) - } - } - } else { - content() - } - + content() BackHandler(enabled = onBack != null && sheetState.targetValue != SheetValue.Hidden) { onBack?.invoke() } }, ) -} - -@OptIn(ExperimentalMaterial3Api::class) -suspend fun SheetState.collapse(onCollapsed: () -> Unit) { - coroutineScope { - launch { hide() }.invokeOnCompletion { onCollapsed() } - } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt index d609afe29a..bf321e0650 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt @@ -188,7 +188,7 @@ inline fun BasicBottomSheet( containerColor = containerColor, shape = TangemTheme.shapes.bottomSheetLarge, contentWindowInsets = { WindowInsetsZero }, - dragHandle = { TangemBottomSheetDraggableHeader(color = containerColor) }, + dragHandle = { TangemBottomSheetDraggableHeaderLegacy(color = containerColor) }, onBack = onBack, content = bsContent, scrimColor = TangemTheme.colors.overlay.secondary, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeader.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt similarity index 61% rename from core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeader.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt index 66b0ae6033..6a03c12c26 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeader.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -12,7 +13,7 @@ import androidx.compose.ui.graphics.Color import com.tangem.core.ui.res.TangemTheme @Composable -fun TangemBottomSheetDraggableHeader(color: Color = TangemTheme.colors.background.primary) { +fun TangemBottomSheetDraggableHeaderLegacy(color: Color = TangemTheme.colors.background.primary) { Surface( modifier = Modifier.height(TangemTheme.dimens.size20), color = color, @@ -30,4 +31,21 @@ fun TangemBottomSheetDraggableHeader(color: Color = TangemTheme.colors.backgroun ), ) } +} + +@Composable +fun TangemBottomSheetDraggableHeader() { + Box( + modifier = Modifier + .height(TangemTheme.dimens2.x3) + .padding(vertical = TangemTheme.dimens2.x1) + .size( + width = TangemTheme.dimens2.x10, + height = TangemTheme.dimens2.x1, + ) + .background( + color = TangemTheme.colors2.graphic.neutral.primaryInverted, + shape = RoundedCornerShape(TangemTheme.dimens2.x0_5), + ), + ) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt index 7c07f422bc..4826155d48 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt @@ -2,57 +2,34 @@ package com.tangem.core.ui.components.sheetscaffold -import android.graphics.Bitmap -import android.graphics.BlurMaskFilter -import android.renderscript.Allocation -import android.renderscript.Element -import android.renderscript.RenderScript -import android.renderscript.ScriptIntrinsicBlur import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.anchoredDraggable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.geometry.center -import androidx.compose.ui.graphics.* -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.clipPath -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.onPlaced -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.* +import androidx.compose.ui.unit.Dp import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastMap import androidx.compose.ui.util.fastMaxOfOrNull -import androidx.compose.ui.zIndex -import androidx.core.graphics.withSave -import androidx.core.graphics.withTranslation import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue.* -import com.tangem.core.ui.extensions.softLayerShadow +import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.utils.toPx import kotlinx.coroutines.launch -import kotlin.math.abs -import kotlin.math.pow import kotlin.math.roundToInt /** @@ -63,25 +40,17 @@ import kotlin.math.roundToInt * viewing and interacting with both regions. They are commonly used to keep a feature or secondary * content visible on screen when content in main UI region is frequently scrolled or panned. * - * ![Bottom sheet - * image](https://developer.android.com/images/reference/androidx/compose/material3/bottom_sheet.png) + * ![Bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material3/bottom_sheet.png) * * This component provides API to put together several material components to construct your screen, * by ensuring proper layout strategy for them and collecting necessary data so these components * will work together correctly. * - * @param sheetContent the content of the bottom sheet + * @param sheetPeekHeight the height of the bottom sheet when it is collapsed * @param modifier the [Modifier] to be applied to this scaffold * @param scaffoldState the state of the bottom sheet scaffold - * @param sheetPeekHeight the height of the bottom sheet when it is collapsed - * @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take. Pass in - * [Dp.Unspecified] for a sheet that spans the entire screen width. - * @param sheetShape the shape of the bottom sheet - * @param sheetContainerColor the background color of the bottom sheet - * @param sheetSwipeEnabled whether the sheet swiping is enabled and should react to the user's - * input - * @param topBar top app bar of the screen, typically a [SmallTopAppBar] - * @param snackbarHost component to host [Snackbar]s that are pushed to be shown via + * @param topBar top app bar of the screen. + * @param snackbarHost component to host [TangemTopSnackbar]s that are pushed to be shown via * [SnackbarHostState.showSnackbar], typically a [SnackbarHost] * @param containerColor the color used for the background of this scaffold. Use [Color.Transparent] * to have no color. @@ -95,18 +64,14 @@ import kotlin.math.roundToInt */ @Composable fun TangemBottomSheetScaffold( - sheetContent: @Composable ColumnScope.() -> Unit, + sheetPeekHeight: Dp, modifier: Modifier = Modifier, scaffoldState: TangemBottomSheetScaffoldState = rememberTangemBottomSheetScaffoldState(), - sheetPeekHeight: Dp, - sheetMaxWidth: Dp = 640.dp, - sheetShape: Shape = TangemTheme.shapes.bottomSheetLarge, - sheetContainerColor: Color = Color.White, - sheetSwipeEnabled: Boolean = true, topBar: @Composable (() -> Unit)? = null, snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, containerColor: Color = TangemTheme.colors.background.secondary, contentColor: Color = contentColorFor(containerColor), + bottomSheet: @Composable () -> Unit = {}, content: @Composable (PaddingValues) -> Unit, ) { BottomSheetScaffoldLayout( @@ -118,17 +83,7 @@ fun TangemBottomSheetScaffold( sheetState = scaffoldState.bottomSheetState, containerColor = containerColor, contentColor = contentColor, - bottomSheet = { - StandardBottomSheet( - state = scaffoldState.bottomSheetState, - peekHeight = sheetPeekHeight, - sheetMaxWidth = sheetMaxWidth, - sheetSwipeEnabled = sheetSwipeEnabled, - shape = sheetShape, - containerColor = sheetContainerColor, - content = sheetContent, - ) - }, + bottomSheet = bottomSheet, ) } @@ -183,35 +138,31 @@ fun rememberTangemStandardBottomSheetState( skipHiddenState = skipHiddenState, ) -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable -private fun StandardBottomSheet( +fun CustomBottomSheet( state: TangemSheetState, peekHeight: Dp, - sheetMaxWidth: Dp, - sheetSwipeEnabled: Boolean, - shape: Shape, - containerColor: Color, + sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, + sheetSwipeEnabled: Boolean = true, + modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, ) { val scope = rememberCoroutineScope() val orientation = Orientation.Vertical val peekHeightPx = with(LocalDensity.current) { peekHeight.toPx() } - val nestedScroll = - if (sheetSwipeEnabled) { - Modifier.nestedScroll( - remember(state.anchoredDraggableState) { - consumeSwipeWithinBottomSheetBoundsNestedScrollConnection( - sheetState = state, - orientation = orientation, - onFling = { scope.launch { state.settle(it) } }, - ) - }, - ) - } else { - Modifier - } + val nestedScroll = Modifier.conditionalCompose(sheetSwipeEnabled) { + nestedScroll( + remember(state.anchoredDraggableState) { + consumeSwipeWithinBottomSheetBoundsNestedScrollConnection( + sheetState = state, + orientation = orientation, + onFling = { scope.launch { state.settle(it) } }, + ) + }, + ) + } Column( modifier = Modifier @@ -219,61 +170,17 @@ private fun StandardBottomSheet( .fillMaxWidth() .requiredHeightIn(min = peekHeight) .then(nestedScroll) - .draggableAnchors( - state = state.anchoredDraggableState, + .bottomSheetDraggableAnchor( + state = state, orientation = orientation, - anchors = { sheetSize, constraints -> - val layoutHeight = constraints.maxHeight.toFloat() - val sheetHeight = sheetSize.height.toFloat() - - val newAnchors = DraggableAnchors { - if (!state.skipPartiallyExpanded) { - PartiallyExpanded at (layoutHeight - peekHeightPx) - } - if (sheetHeight != peekHeightPx) { - Expanded at maxOf(layoutHeight - sheetHeight, 0f) - } - if (!state.skipHiddenState) { - Hidden at layoutHeight - } - } - val newTarget = - when (val oldTarget = state.anchoredDraggableState.targetValue) { - Hidden -> if (newAnchors.hasPositionFor(Hidden)) Hidden else oldTarget - PartiallyExpanded -> - when { - newAnchors.hasPositionFor(PartiallyExpanded) -> PartiallyExpanded - newAnchors.hasPositionFor(Expanded) -> Expanded - newAnchors.hasPositionFor(Hidden) -> Hidden - else -> oldTarget - } - Expanded -> - when { - newAnchors.hasPositionFor(Expanded) -> Expanded - newAnchors.hasPositionFor(PartiallyExpanded) -> PartiallyExpanded - newAnchors.hasPositionFor(Hidden) -> Hidden - else -> oldTarget - } - } - newAnchors to newTarget - }, + peekHeightPx = peekHeightPx, ) .anchoredDraggable( state = state.anchoredDraggableState, orientation = orientation, enabled = sheetSwipeEnabled, ) - .softLayerShadow( - radius = 8.dp, - color = Color.Black.copy( - alpha = if (isSystemInDarkTheme()) .16f else .08f - ), - shape = shape, - offset = DpOffset(x = 0.dp, y = (-4).dp), - isAlphaContentClip = true - ) - .background(containerColor, shape) - .clip(shape), + .then(modifier), ) { content() } @@ -292,8 +199,7 @@ private fun BottomSheetScaffoldLayout( contentColor: Color, ) { Layout( - contents = - listOf<@Composable () -> Unit>( + contents = listOf( topBar ?: {}, { Surface( @@ -331,13 +237,12 @@ private fun BottomSheetScaffoldLayout( val snackbarWidth = snackbarPlaceables.fastMaxOfOrNull { it.width } ?: 0 val snackbarHeight = snackbarPlaceables.fastMaxOfOrNull { it.height } ?: 0 val snackbarOffsetX = (layoutWidth - snackbarWidth) / 2 - val snackbarOffsetY = - when (sheetState.currentValue) { - PartiallyExpanded -> sheetOffset().roundToInt() - snackbarHeight - Expanded, - Hidden, - -> layoutHeight - snackbarHeight - } + val snackbarOffsetY = when (sheetState.currentValue) { + PartiallyExpanded -> sheetOffset().roundToInt() - snackbarHeight + Expanded, + Hidden, + -> layoutHeight - snackbarHeight + } // Placement order is important for elevation bodyPlaceables.fastForEach { it.placeRelative(0, topBarHeight) } @@ -346,4 +251,50 @@ private fun BottomSheetScaffoldLayout( snackbarPlaceables.fastForEach { it.placeRelative(snackbarOffsetX, snackbarOffsetY) } } } +} + +private fun Modifier.bottomSheetDraggableAnchor( + state: TangemSheetState, + orientation: Orientation, + peekHeightPx: Float, +): Modifier { + return draggableAnchors( + state = state.anchoredDraggableState, + orientation = orientation, + anchors = { sheetSize, constraints -> + val layoutHeight = constraints.maxHeight.toFloat() + val sheetHeight = sheetSize.height.toFloat() + + val newAnchors = DraggableAnchors { + if (!state.skipPartiallyExpanded) { + PartiallyExpanded at (layoutHeight - peekHeightPx) + } + if (sheetHeight != peekHeightPx) { + Expanded at maxOf(layoutHeight - sheetHeight, 0f) + } + if (!state.skipHiddenState) { + Hidden at layoutHeight + } + } + val newTarget = + when (val oldTarget = state.anchoredDraggableState.targetValue) { + Hidden -> if (newAnchors.hasPositionFor(Hidden)) Hidden else oldTarget + PartiallyExpanded -> + when { + newAnchors.hasPositionFor(PartiallyExpanded) -> PartiallyExpanded + newAnchors.hasPositionFor(Expanded) -> Expanded + newAnchors.hasPositionFor(Hidden) -> Hidden + else -> oldTarget + } + Expanded -> + when { + newAnchors.hasPositionFor(Expanded) -> Expanded + newAnchors.hasPositionFor(PartiallyExpanded) -> PartiallyExpanded + newAnchors.hasPositionFor(Hidden) -> Hidden + else -> oldTarget + } + } + newAnchors to newTarget + }, + ) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt index 9b81ff94d3..5b4ef02f7d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt @@ -6,7 +6,9 @@ import androidx.compose.ui.graphics.Color object TangemColorPalette { // region Base val Black = Color(0xFF000000) + val BlackZero = Color(0x00000000) val White = Color(0xFFFFFFFF) + val WhiteZero = Color(0x00FFFFFF) // endregion Base // region Dark @@ -17,6 +19,7 @@ object TangemColorPalette { val Dark5 = Color(0xFF303030) val Dark6 = Color(0xFF1E1E1E) val Dark7 = Color(0xFF171717) + val Dark8 = Color(0xFF0F0F0F) // endregion Dark // region Dark Alpha diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt index 61f297696c..0cd61de19c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt @@ -23,6 +23,7 @@ class TangemColors2 internal constructor( val markers: Markers, val tabs: Tabs, val contextMenu: ContextMenu, + val shadow: Shadow, ) { @Stable @@ -637,6 +638,30 @@ class TangemColors2 internal constructor( } } + @Stable + class Shadow internal constructor( + min: Color, + max: Color, + fadeMin: Color, + fadeMax: Color, + ) { + var min by mutableStateOf(min) + private set + var max by mutableStateOf(max) + private set + var fadeMin by mutableStateOf(fadeMin) + private set + var fadeMax by mutableStateOf(fadeMax) + private set + + fun update(other: Shadow) { + min = other.min + max = other.max + fadeMin = other.fadeMin + fadeMax = other.fadeMax + } + } + fun update(other: TangemColors2) { text.update(other.text) graphic.update(other.graphic) @@ -651,5 +676,6 @@ class TangemColors2 internal constructor( markers.update(other.markers) tabs.update(other.tabs) contextMenu.update(other.contextMenu) + shadow.update(other.shadow) } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt index ad14b94dbf..619a08d5b8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt @@ -192,6 +192,12 @@ private fun lightThemeColors2(): TangemColors2 { val contextMenu = TangemColors2.ContextMenu( background = TangemColorPalette.Dark_05, ) + val shadow = TangemColors2.Shadow( + min = TangemColorPalette.BlackZero, + max = TangemColorPalette.Dark_20, + fadeMin = TangemColorPalette.WhiteZero, + fadeMax = TangemColorPalette.White, + ) return TangemColors2( text = text, graphic = graphic, @@ -206,6 +212,7 @@ private fun lightThemeColors2(): TangemColors2 { markers = markers, tabs = tabs, contextMenu = contextMenu, + shadow = shadow, ) } @@ -361,6 +368,12 @@ private fun darkThemeColors2(): TangemColors2 { val contextMenu = TangemColors2.ContextMenu( background = TangemColorPalette.Light_10, ) + val shadow = TangemColors2.Shadow( + min = TangemColorPalette.BlackZero, + max = TangemColorPalette.Dark8, + fadeMin = TangemColorPalette.BlackZero, + fadeMax = TangemColorPalette.Black, + ) return TangemColors2( text = text, graphic = graphic, @@ -375,5 +388,6 @@ private fun darkThemeColors2(): TangemColors2 { markers = markers, tabs = tabs, contextMenu = contextMenu, + shadow = shadow, ) } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt index 96f315a25c..c3c6cea2a1 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt @@ -193,6 +193,7 @@ private fun BadgeTypeRow( color = color, type = type, iconPosition = TangemBadgeIconPosition.Start, + onClick = {}, ) } Box( @@ -205,6 +206,7 @@ private fun BadgeTypeRow( shape = shape, color = color, type = type, + onClick = {}, ) } Box( @@ -217,7 +219,7 @@ private fun BadgeTypeRow( shape = shape, color = color, type = type, - iconPosition = TangemBadgeIconPosition.Start, + onClick = {}, ) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt index 1de1a10cab..120fd7a0b8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt @@ -15,8 +15,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -28,10 +26,10 @@ 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.BottomFade +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.haze.hazeEffectTangem import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.ds.button.TangemButton @@ -75,7 +73,6 @@ internal fun OrganizeTokensContent( onDismissRequest = onDismiss, content = TangemBottomSheetConfigContent.Empty, ), - addBottomInsets = false, containerColor = TangemTheme.colors2.surface.level2, title = { TangemTopBar( @@ -99,11 +96,14 @@ internal fun OrganizeTokensContent( }, ) }, + footer = { + BottomButtons(organizeTokensUM = organizeTokensUM) + }, content = { TokenList( organizeTokensUM = organizeTokensUM, dragAndDropIntents = dragAndDropIntents, - modifier = Modifier.hazeSourceTangem(hazeState), + modifier = Modifier.hazeSourceTangem(hazeState, zIndex = -1f), ) }, ) @@ -121,9 +121,7 @@ private fun TokenList( val hapticFeedback = LocalHapticFeedback.current val tokenList = organizeTokensUM.tokenList Box( - modifier = modifier - .fillMaxSize() - .background(TangemTheme.colors2.surface.level2), + modifier = modifier.background(TangemTheme.colors2.surface.level2), ) { val onDragEnd: (Int, Int) -> Unit = remember { { _, _ -> @@ -175,20 +173,11 @@ private fun TokenList( isBalanceHidden = organizeTokensUM.isBalanceHidden, ) } + + item { + SpacerH(TangemTheme.dimens2.x20) + } } - - BottomFade( - gradientBrush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - TangemTheme.colors2.surface.level2.copy(0.9f), - TangemTheme.colors2.surface.level2, - ), - ), - modifier = Modifier.align(Alignment.BottomCenter), - ) - - BottomButtons(organizeTokensUM = organizeTokensUM) } } 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 5eb943f097..4f12a59f17 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 @@ -11,6 +11,7 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -20,16 +21,17 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.geometry.* +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -40,19 +42,16 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.* 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.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.core.ui.components.bottomsheets.sheet.TangemBottomSheetDraggableHeaderLegacy import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer import com.tangem.core.ui.components.rememberIsKeyboardVisible @@ -61,6 +60,7 @@ import com.tangem.core.ui.components.snackbar.CopiedTextSnackbar import com.tangem.core.ui.components.snackbar.TangemSnackbar import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.extensions.softLayerShadow import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalWindowSize @@ -290,15 +290,8 @@ private inline fun BaseScaffoldWithMarkets( crossinline bottomSheetContent: @Composable () -> Unit, crossinline content: @Composable (PaddingValues) -> Unit, ) { - val bottomSheetState = rememberTangemStandardBottomSheetState() - val isKeyboardVisible by rememberIsKeyboardVisible() - val scaffoldState = rememberTangemBottomSheetScaffoldState( - bottomSheetState = bottomSheetState, - snackbarHostState = snackbarHostState, - ) - val density = LocalDensity.current val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(density = this).toDp() } val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density = this).toDp() } @@ -308,6 +301,12 @@ private inline fun BaseScaffoldWithMarkets( val coroutineScope = rememberCoroutineScope() val background = TangemTheme.colors.background.tertiary + val bottomSheetState = rememberTangemStandardBottomSheetState() + val scaffoldState = rememberTangemBottomSheetScaffoldState( + bottomSheetState = bottomSheetState, + snackbarHostState = snackbarHostState, + ) + val showMarketsHint by remember { derivedStateOf { // Show hint only when there are items in the list @@ -321,7 +320,7 @@ private inline fun BaseScaffoldWithMarkets( CompositionLocalProvider( LocalMainBottomSheetColor provides remember(background) { mutableStateOf(background) }, ) { - val backgroundColor = LocalMainBottomSheetColor.current + val backgroundColor by LocalMainBottomSheetColor.current var isSearchFieldFocused by remember { mutableStateOf(false) } val isNavBarVisible = remember { mutableStateOf(true) } @@ -343,43 +342,61 @@ private inline fun BaseScaffoldWithMarkets( .navigationBarsPadding(), ) }, - containerColor = TangemTheme.colors.background.secondary, - sheetContainerColor = backgroundColor.value, - scaffoldState = scaffoldState, sheetPeekHeight = peekHeight, - sheetShape = TangemTheme.shapes.bottomSheetLarge, - sheetContent = { - // hide bottom sheet when back pressed - BackHandler( - isKeyboardVisible.not() && - bottomSheetState.currentValue == TangemSheetValue.Expanded, - ) { - coroutineScope.launch { bottomSheetState.partialExpand() } - } - - Column( + containerColor = TangemTheme.colors.background.secondary, + scaffoldState = scaffoldState, + bottomSheet = { + CustomBottomSheet( + state = scaffoldState.bottomSheetState, + peekHeight = peekHeight, modifier = Modifier - // expand bottom sheet when clicked on the header - .clickable( - enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded, - indication = null, - interactionSource = null, + .softLayerShadow( + radius = 8.dp, + color = Color.Black.copy( + alpha = if (isSystemInDarkTheme()) .16f else .08f, + ), + shape = TangemTheme.shapes.bottomSheetLarge, + offset = DpOffset(x = 0.dp, y = (-4).dp), + isAlphaContentClip = true, + ) + .clip(TangemTheme.shapes.bottomSheetLarge) + .background(backgroundColor), + content = { + // hide bottom sheet when back pressed + BackHandler( + isKeyboardVisible.not() && + bottomSheetState.currentValue == TangemSheetValue.Expanded, ) { - coroutineScope.launch { bottomSheetState.expand() } + coroutineScope.launch { bottomSheetState.partialExpand() } } - .sizeIn(maxHeight = maxHeight - statusBarHeight), - ) { - Hand(Modifier.drawBehind { drawRect(backgroundColor.value) }) - Box( - modifier = Modifier - .onFocusChanged { - isSearchFieldFocused = it.isFocused - }, - ) { - bottomSheetContent() - } - } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + // expand bottom sheet when clicked on the header + .clickable( + enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded, + indication = null, + interactionSource = null, + ) { + coroutineScope.launch { bottomSheetState.expand() } + } + .sizeIn(maxHeight = maxHeight - statusBarHeight), + ) { + TangemBottomSheetDraggableHeaderLegacy(backgroundColor) + + Box( + modifier = Modifier + .onFocusChanged { + isSearchFieldFocused = it.isFocused + }, + ) { + bottomSheetContent() + } + } + }, + ) }, content = { paddingValues -> Box { @@ -428,7 +445,7 @@ private inline fun BaseScaffoldWithMarkets( Box( Modifier .align(Alignment.BottomCenter) - .background(backgroundColor.value) + .background(backgroundColor) .height(bottomBarHeight) .fillMaxWidth(), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index 83c59d731f..0d552f2757 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState @@ -25,8 +26,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusState import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput @@ -39,9 +42,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.arkivanov.decompose.ExperimentalDecomposeApi -import com.tangem.core.ui.components.atoms.Hand +import com.tangem.core.ui.components.BottomFade import com.tangem.core.ui.components.atoms.handComposableComponentHeight import com.tangem.core.ui.components.background.northernlights.NorthernLightsBackground +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetDraggableHeader import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshSlidingContainer import com.tangem.core.ui.components.haze.hazeSourceTangem @@ -295,25 +299,20 @@ private inline fun BaseScaffoldWithMarkets( crossinline bottomSheetContent: @Composable () -> Unit, crossinline content: @Composable (PaddingValues, TangemSheetState) -> Unit, ) { - val bottomSheetState = rememberTangemStandardBottomSheetState() - - val isKeyboardVisible by rememberIsKeyboardVisible() - - val scaffoldState = rememberTangemBottomSheetScaffoldState(bottomSheetState = bottomSheetState) - val density = LocalDensity.current val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(density = this).toDp() } - val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density = this).toDp() } - val peekHeight = bottomSheetHeaderHeightProvider() + handComposableComponentHeight + bottomBarHeight - val maxHeight = LocalWindowSize.current.height + val peekHeight = bottomSheetHeaderHeightProvider() + TangemTheme.dimens2.x3 + bottomBarHeight val coroutineScope = rememberCoroutineScope() val background = TangemTheme.colors2.surface.level3 + val bottomSheetState = rememberTangemStandardBottomSheetState() + val scaffoldState = rememberTangemBottomSheetScaffoldState(bottomSheetState = bottomSheetState) + CompositionLocalProvider( LocalMainBottomSheetColor provides remember(background) { mutableStateOf(background) }, ) { - val backgroundColor = LocalMainBottomSheetColor.current + val backgroundColor by LocalMainBottomSheetColor.current var isSearchFieldFocused by remember { mutableStateOf(false) } val isNavBarVisible = remember { mutableStateOf(true) } @@ -327,41 +326,18 @@ private inline fun BaseScaffoldWithMarkets( Box(modifier = modifier) { TangemBottomSheetScaffold( containerColor = Color.Unspecified, - sheetContainerColor = backgroundColor.value, scaffoldState = scaffoldState, sheetPeekHeight = peekHeight, - sheetShape = TangemTheme.shapes.bottomSheetLarge, - sheetContent = { - // hide bottom sheet when back pressed - BackHandler( - isKeyboardVisible.not() && - bottomSheetState.currentValue == TangemSheetValue.Expanded, + bottomSheet = { + BottomSheet( + bottomSheetState = bottomSheetState, + backgroundColor = backgroundColor, + peekHeight = peekHeight, + onFocusChange = { focusState -> + isSearchFieldFocused = focusState.isFocused + }, ) { - coroutineScope.launch { bottomSheetState.partialExpand() } - } - - Column( - modifier = Modifier - // expand bottom sheet when clicked on the header - .clickable( - enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded, - indication = null, - interactionSource = null, - ) { - coroutineScope.launch { bottomSheetState.expand() } - } - .sizeIn(maxHeight = maxHeight - statusBarHeight), - ) { - Hand(Modifier.drawBehind { drawRect(backgroundColor.value) }) - - Box( - modifier = Modifier - .onFocusChanged { - isSearchFieldFocused = it.isFocused - }, - ) { - bottomSheetContent() - } + bottomSheetContent() } }, content = { paddingValues -> @@ -385,7 +361,7 @@ private inline fun BaseScaffoldWithMarkets( Box( Modifier .align(Alignment.BottomCenter) - .background(backgroundColor.value) + .background(backgroundColor) .height(bottomBarHeight) .fillMaxWidth(), ) @@ -400,6 +376,81 @@ private inline fun BaseScaffoldWithMarkets( } } +@Composable +private fun BottomSheet( + bottomSheetState: TangemSheetState, + backgroundColor: Color, + peekHeight: Dp, + onFocusChange: (FocusState) -> Unit, + bottomSheetContent: @Composable () -> Unit, +) { + val isKeyboardVisible by rememberIsKeyboardVisible() + val coroutineScope = rememberCoroutineScope() + val density = LocalDensity.current + val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density = this).toDp() } + + val maxHeight = LocalWindowSize.current.height + val shape = RoundedCornerShape( + topStart = TangemTheme.dimens2.x8, + topEnd = TangemTheme.dimens2.x8, + ) + CustomBottomSheet( + state = bottomSheetState, + peekHeight = peekHeight, + content = { + // hide bottom sheet when back pressed + BackHandler( + isKeyboardVisible.not() && + bottomSheetState.currentValue == TangemSheetValue.Expanded, + ) { + coroutineScope.launch { bottomSheetState.partialExpand() } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + // expand bottom sheet when clicked on the header + .clickable( + enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded, + indication = null, + interactionSource = null, + ) { + coroutineScope.launch { bottomSheetState.expand() } + } + .sizeIn(maxHeight = maxHeight - statusBarHeight), + ) { + Box(modifier = Modifier.fillMaxWidth()) { + BottomFade( + gradientBrush = Brush.verticalGradient( + colors = listOf( + TangemTheme.colors2.shadow.min, + TangemTheme.colors2.shadow.max, + ), + ), + modifier = Modifier + .offset(y = TangemTheme.dimens2.x5.unaryMinus()), + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TangemBottomSheetDraggableHeader() + Box( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(backgroundColor) + .onFocusChanged(onFocusChange), + ) { + bottomSheetContent() + } + } + } + } + }, + ) +} + @Composable private fun BottomSheetScrim(color: Color, visible: Boolean, onDismissRequest: () -> Unit) { val alpha by animateFloatAsState( From 519e59d96b6db30d7c114782fbcd1b61a1c2436f Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 11 Mar 2026 15:04:32 +0100 Subject: [PATCH 19/60] Updated on 2026-08-14 --- .../tangem/core/ui/ds/badge/TangemBadge.kt | 4 +- .../ui/components/ContainerWithDivider.kt | 32 +++ .../feed/components/articles/ArticleCardV2.kt | 2 +- .../market/detailed/components/LinksBlock.kt | 247 ++++++++++++++++-- 4 files changed, 257 insertions(+), 28 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/ContainerWithDivider.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt index b8939058d7..e3332f834d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt @@ -87,8 +87,8 @@ fun TangemBadge( .heightIn(min = size.toHeightDp()) .clip(shape.toShape(size)) .getBackgroundColor(type = type, color = color, shape = shape.toShape(size)) - .padding(size.toPaddingDp(position = iconPosition)) - .clickableSingle(enabled = onClick != null, onClick = { onClick?.invoke() }), + .clickableSingle(enabled = onClick != null, onClick = { onClick?.invoke() }) + .padding(size.toPaddingDp(position = iconPosition)), ) { StartIcon( tangemIconUM = tangemIconUM, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/ContainerWithDivider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/ContainerWithDivider.kt new file mode 100644 index 0000000000..31b88692e3 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/ContainerWithDivider.kt @@ -0,0 +1,32 @@ +package com.tangem.features.feed.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +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.res.TangemTheme + +@Composable +internal fun ContainerWithDivider( + modifier: Modifier = Modifier, + showDivider: Boolean = false, + paddingValues: PaddingValues = PaddingValues(start = TangemTheme.dimens2.x3), + content: @Composable () -> Unit, +) { + Box(modifier = modifier) { + content() + if (showDivider) { + HorizontalDivider( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(paddingValues), + color = TangemTheme.colors2.graphic.neutral.quaternary, + thickness = 1.dp, + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleCardV2.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleCardV2.kt index d55d4773c2..56fa348c48 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleCardV2.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleCardV2.kt @@ -234,7 +234,7 @@ private fun TrendingArticleBackground( Color(0xFF7C16F1).copy(alpha = .8f), Color.Transparent, ), - center = Offset(w / 2f, 2.4f * h), + center = Offset(w / 2f, 2.6f * h), radius = radiusScale * 1.57f, ), ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt index 2d0693b4c4..778af344f2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt @@ -1,14 +1,7 @@ package com.tangem.features.feed.ui.market.detailed.components import android.content.res.Configuration -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -21,18 +14,35 @@ import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.block.information.InformationBlock import com.tangem.core.ui.components.buttons.chip.Chip import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.ds.badge.TangemBadge +import com.tangem.core.ui.ds.badge.TangemBadgeIconPosition +import com.tangem.core.ui.ds.badge.TangemBadgeShape +import com.tangem.core.ui.ds.badge.TangemBadgeSize +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.utils.PreviewShimmerContainer import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.components.ContainerWithDivider import com.tangem.features.feed.ui.market.detailed.state.LinksUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @Composable internal fun LinksBlock(state: LinksUM, modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + LinksBlockV2(state, modifier) + } else { + LinksBlockV1(state, modifier) + } +} + +@Composable +private fun LinksBlockV1(state: LinksUM, modifier: Modifier = Modifier) { InformationBlock( modifier = modifier, contentHorizontalPadding = 0.dp, @@ -47,22 +57,22 @@ internal fun LinksBlock(state: LinksUM, modifier: Modifier = Modifier) { }, content = { Column { - SubBlock( + SubBlockV1( title = stringResourceSafe(id = R.string.markets_token_details_official_links), links = state.officialLinks, onLinkClick = state.onLinkClick, ) - SubBlock( + SubBlockV1( title = stringResourceSafe(id = R.string.markets_token_details_social), links = state.social, onLinkClick = state.onLinkClick, ) - SubBlock( + SubBlockV1( title = stringResourceSafe(id = R.string.markets_token_details_repository), links = state.repository, onLinkClick = state.onLinkClick, ) - SubBlock( + SubBlockV1( title = stringResourceSafe(id = R.string.markets_token_details_blockchain_site), links = state.blockchainSite, onLinkClick = state.onLinkClick, @@ -73,9 +83,36 @@ internal fun LinksBlock(state: LinksUM, modifier: Modifier = Modifier) { ) } +@Composable +private fun LinksBlockV2(state: LinksUM, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + SubBlockV2( + title = stringResourceSafe(id = R.string.markets_token_details_official_links), + links = state.officialLinks, + onLinkClick = state.onLinkClick, + ) + SubBlockV2( + title = stringResourceSafe(id = R.string.markets_token_details_social), + links = state.social, + onLinkClick = state.onLinkClick, + ) + SubBlockV2( + title = stringResourceSafe(id = R.string.markets_token_details_repository), + links = state.repository, + onLinkClick = state.onLinkClick, + ) + SubBlockV2( + title = stringResourceSafe(id = R.string.markets_token_details_blockchain_site), + links = state.blockchainSite, + onLinkClick = state.onLinkClick, + lastBlock = true, + ) + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable -private fun SubBlock( +private fun SubBlockV1( links: ImmutableList, onLinkClick: (LinksUM.Link) -> Unit, modifier: Modifier = Modifier, @@ -115,8 +152,67 @@ private fun SubBlock( } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SubBlockV2( + title: String, + links: ImmutableList, + onLinkClick: (LinksUM.Link) -> Unit, + modifier: Modifier = Modifier, + lastBlock: Boolean = false, +) { + if (links.isEmpty()) return + + ContainerWithDivider( + modifier = modifier, + showDivider = !lastBlock, + ) { + Column( + modifier = Modifier.padding(vertical = TangemTheme.dimens2.x2), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + Text( + modifier = Modifier.padding(start = 10.dp, top = TangemTheme.dimens2.x4), + text = title, + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + FlowRow( + modifier = Modifier.padding(vertical = TangemTheme.dimens2.x2, horizontal = 3.dp), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + links.fastForEach { link -> + TangemBadge( + text = stringReference(link.title), + onClick = { onLinkClick(link) }, + iconPosition = TangemBadgeIconPosition.Start, + tangemIconUM = TangemIconUM.Icon( + iconRes = link.iconRes, + tintReference = { TangemTheme.colors2.markers.iconGray }, + ), + size = TangemBadgeSize.X9, + shape = TangemBadgeShape.Rounded, + ) + } + } + } + } +} + @Composable fun LinksBlockPlaceholder(modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + LinksBlockPlaceholderV2(modifier) + } else { + LinksBlockPlaceholderV1(modifier) + } +} + +@Composable +private fun LinksBlockPlaceholderV1(modifier: Modifier = Modifier) { InformationBlock( modifier = modifier, contentHorizontalPadding = 0.dp, @@ -128,22 +224,31 @@ fun LinksBlockPlaceholder(modifier: Modifier = Modifier) { }, content = { Column { - SubBlockPlaceholder() - SubBlockPlaceholder() - SubBlockPlaceholder(lastBlock = true) + SubBlockPlaceholderV1() + SubBlockPlaceholderV1() + SubBlockPlaceholderV1(lastBlock = true) } }, ) } @Composable -private fun SubBlockPlaceholder(modifier: Modifier = Modifier, lastBlock: Boolean = false) { +private fun LinksBlockPlaceholderV2(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + SubBlockPlaceholderV2() + SubBlockPlaceholderV2() + SubBlockPlaceholderV2(lastBlock = true) + } +} + +@Composable +private fun SubBlockPlaceholderV1(modifier: Modifier = Modifier, lastBlock: Boolean = false) { DividerContainer( modifier = modifier, showDivider = !lastBlock, ) { Column( - modifier = Modifier.padding(TangemTheme.dimens.spacing12), + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { TextShimmer( @@ -163,12 +268,42 @@ private fun SubBlockPlaceholder(modifier: Modifier = Modifier, lastBlock: Boolea } } +@Composable +private fun SubBlockPlaceholderV2(modifier: Modifier = Modifier, lastBlock: Boolean = false) { + ContainerWithDivider( + modifier = modifier, + showDivider = !lastBlock, + ) { + Column( + modifier = Modifier.padding(TangemTheme.dimens2.x2), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + TextShimmer( + modifier = Modifier.width(56.dp), + style = TangemTheme.typography2.bodySemibold16, + radius = TangemTheme.dimens2.x25, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + repeat(times = 3) { + ChipShimmer( + modifier = Modifier + .height(36.dp) + .weight(1f), + ) + } + } + } + } +} + @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun ContentPreview() { +private fun ContentPreviewV1() { TangemThemePreview { - LinksBlock( + LinksBlockV1( state = LinksUM( officialLinks = persistentListOf( LinksUM.Link( @@ -216,11 +351,73 @@ private fun ContentPreview() { @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun PlaceholderPreview() { - TangemThemePreview { - PreviewShimmerContainer( - shimmerContent = { LinksBlockPlaceholder() }, - actualContent = { ContentPreview() }, +private fun ContentPreviewV2() { + TangemThemePreviewRedesign { + LinksBlockV2( + state = LinksUM( + officialLinks = persistentListOf( + LinksUM.Link( + title = "Website", + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + LinksUM.Link( + title = "Website", + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + LinksUM.Link( + title = "Website", + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + ), + social = persistentListOf( + LinksUM.Link( + title = "Twitter", + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + LinksUM.Link( + title = "Facebook", + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + ), + repository = persistentListOf( + LinksUM.Link( + title = "Github", + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + ), + blockchainSite = persistentListOf(), + onLinkClick = {}, + ), ) } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PlaceholderPreviewV1() { + TangemThemePreview { + PreviewShimmerContainer( + shimmerContent = { LinksBlockPlaceholderV1() }, + actualContent = { ContentPreviewV1() }, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PlaceholderPreviewV2() { + TangemThemePreviewRedesign { + Column { + LinksBlockPlaceholderV2() + ContentPreviewV2() + } + } } \ No newline at end of file From fdee74fbf078f55afc3be3d191fb524bcc986474 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 11 Mar 2026 09:59:51 +0400 Subject: [PATCH 20/60] Updated on 2026-08-14 --- .../tap/di/domain/TokensDomainModule.kt | 20 -- .../AccountCryptoCurrencyStatusFinder.kt | 22 +- .../utils/CryptoCurrencyStatusOperations.kt | 20 ++ .../CryptoCurrencyStatusOperationsTest.kt | 224 ++++++++++++++++++ domain/tokens/detekt-baseline-debug.xml | 3 - .../BaseCurrenciesStatusesOperations.kt | 17 -- .../domain}/GetCurrencyWarningsUseCase.kt | 78 +++--- .../tokendetails/model/TokenDetailsModel.kt | 6 +- 8 files changed, 301 insertions(+), 89 deletions(-) delete mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrenciesStatusesOperations.kt rename {domain/tokens/src/main/kotlin/com/tangem/domain/tokens => features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain}/GetCurrencyWarningsUseCase.kt (83%) 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 65df536429..62da11c64c 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 @@ -53,26 +53,6 @@ internal object TokensDomainModule { return DefaultTokensFeatureToggles(featureTogglesManager = featureTogglesManager) } - @Provides - @Singleton - fun provideGetCurrencyWarningsUseCase( - walletManagersFacade: WalletManagersFacade, - currenciesRepository: CurrenciesRepository, - currencyChecksRepository: CurrencyChecksRepository, - dispatchers: CoroutineDispatcherProvider, - baseCurrencyStatusOperations: BaseCurrencyStatusOperations, - multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - ): GetCurrencyWarningsUseCase { - return GetCurrencyWarningsUseCase( - walletManagersFacade = walletManagersFacade, - currenciesRepository = currenciesRepository, - dispatchers = dispatchers, - currencyChecksRepository = currencyChecksRepository, - currencyStatusOperations = baseCurrencyStatusOperations, - multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - ) - } - @Provides @Singleton fun provideFetchCurrencyStatusUseCase( diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt index 22033e6c37..abe7c314d1 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt @@ -218,7 +218,27 @@ internal object AccountCryptoCurrencyStatusFinder { return AccountCryptoCurrencyStatus(account = accountCurrency.account, status = currencyStatus) } - private fun AccountStatusList.getExpectedAccountStatuses(networks: List): List { + internal fun AccountStatusList.getExpectedAccountStatuses(networkId: Network.ID): List { + val possibleAccountIndex = getAccountIndexOrNull( + rawNetworkId = networkId.rawId.value, + derivationPath = networkId.derivationPath, + ) + + return when (possibleAccountIndex) { + null -> accountStatuses + DerivationIndex.Main.value -> listOf(mainAccount) + // currency only in the account with specific derivation index or in the main account + else -> { + val account = accountStatuses.firstOrNull { account -> + val cryptoPortfolio = account as? AccountStatus.CryptoPortfolio ?: return@firstOrNull false + cryptoPortfolio.account.derivationIndex.value == possibleAccountIndex + } + listOfNotNull(account, mainAccount) + } + } + } + + internal fun AccountStatusList.getExpectedAccountStatuses(networks: List): List { val possibleAccountIndexes = networks.mapNotNull { getAccountIndexOrNull(it.rawId, it.derivationPath) } if (possibleAccountIndexes.isEmpty()) return accountStatuses diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperations.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperations.kt index b9e72590a1..d5381ae00d 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperations.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperations.kt @@ -4,6 +4,7 @@ import arrow.core.Option import arrow.core.toOption import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.utils.AccountCryptoCurrencyStatusFinder.getExpectedAccountStatuses import com.tangem.domain.account.status.utils.AccountCryptoCurrencyStatusOperations.getAccountCryptoCurrencyStatus import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency @@ -68,6 +69,25 @@ object CryptoCurrencyStatusOperations { return getAccountCryptoCurrencyStatus(currencyId = currencyId, network = network) .map(AccountCryptoCurrencyStatus::status) } + + fun AccountStatusList.getCoinStatus(currency: CryptoCurrency): Option { + return getCoinStatus(network = currency.network) + } + + fun AccountStatusList.getCoinStatus(network: Network): Option { + return getCoinStatus(networkId = network.id) + } + + fun AccountStatusList.getCoinStatus(networkId: Network.ID): Option { + return getExpectedAccountStatuses(networkId) + .flatMap { accountStatus -> + (accountStatus as? AccountStatus.CryptoPortfolio) + ?.flattenCurrencies().orEmpty() + .filter { it.currency is CryptoCurrency.Coin } + } + .firstOrNull { status -> status.currency.network.id == networkId } + .toOption() + } // endregion // region AccountStatus.CryptoPortfolio diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperationsTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperationsTest.kt index e79ed8bfbb..554a63f4ca 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperationsTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusOperationsTest.kt @@ -1,7 +1,9 @@ package com.tangem.domain.account.status.utils +import com.tangem.blockchain.common.Blockchain import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCoinStatus import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.core.utils.lceLoading import com.tangem.domain.models.TokensGroupType @@ -11,6 +13,7 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWalletId import com.tangem.test.core.assertNone @@ -286,6 +289,227 @@ class CryptoCurrencyStatusOperationsTest { } } + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetCoinStatusByCurrency { + + @Test + fun `returns None when no coin exists for the currency network`() { + // Arrange + val token = cryptoCurrencyFactory.createToken(Blockchain.Ethereum) + val tokenStatus = CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(token), + currencyStatuses = listOf(tokenStatus), + ) + // Act + val result = accountStatusList.getCoinStatus(token) + // Assert + assertNone(result) + } + + @Test + fun `returns Some when coin exists for the currency network`() { + // Arrange + val coinStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency), + currencyStatuses = listOf(coinStatus), + ) + // Act + val result = accountStatusList.getCoinStatus(currency) + // Assert + assertSome(result, coinStatus) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetCoinStatusByNetwork { + + @Test + fun `returns None when AccountStatusList has empty token list`() { + // Arrange + val accountStatusList = createAccountStatusList(currencies = emptyList()) + // Act + val result = accountStatusList.getCoinStatus(currency.network) + // Assert + assertNone(result) + } + + @Test + fun `returns None when only tokens exist for network`() { + // Arrange + val token = cryptoCurrencyFactory.createToken(Blockchain.Ethereum) + val tokenStatus = CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(token), + currencyStatuses = listOf(tokenStatus), + ) + // Act + val result = accountStatusList.getCoinStatus(currency.network) + // Assert + assertNone(result) + } + + @Test + fun `returns Some when coin exists for network`() { + // Arrange + val coinStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency), + currencyStatuses = listOf(coinStatus), + ) + // Act + val result = accountStatusList.getCoinStatus(currency.network) + // Assert + assertSome(result, coinStatus) + } + + @Test + fun `returns coin status when both coin and token exist for same network`() { + // Arrange + val token = cryptoCurrencyFactory.createToken(Blockchain.Ethereum) + val coinStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val tokenStatus = CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency, token), + currencyStatuses = listOf(coinStatus, tokenStatus), + ) + // Act + val result = accountStatusList.getCoinStatus(currency.network) + // Assert + assertSome(result, coinStatus) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetCoinStatusByNetworkId { + + @Test + fun `returns None when AccountStatusList has empty token list`() { + // Arrange + val accountStatusList = createAccountStatusList(currencies = emptyList()) + // Act + val result = accountStatusList.getCoinStatus(currency.network.id) + // Assert + assertNone(result) + } + + @Test + fun `returns None when network id does not match any currency`() { + // Arrange + val coinStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency), + currencyStatuses = listOf(coinStatus), + ) + val otherNetworkId = Network.ID(value = "bitcoin", derivationPath = Network.DerivationPath.None) + // Act + val result = accountStatusList.getCoinStatus(otherNetworkId) + // Assert + assertNone(result) + } + + @Test + fun `returns None when only tokens exist for network id`() { + // Arrange + val token = cryptoCurrencyFactory.createToken(Blockchain.Ethereum) + val tokenStatus = CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(token), + currencyStatuses = listOf(tokenStatus), + ) + // Act + val result = accountStatusList.getCoinStatus(token.network.id) + // Assert + assertNone(result) + } + + @Test + fun `returns Some when coin exists for network id`() { + // Arrange + val coinStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency), + currencyStatuses = listOf(coinStatus), + ) + // Act + val result = accountStatusList.getCoinStatus(currency.network.id) + // Assert + assertSome(result, coinStatus) + } + + @Test + fun `returns coin status when both coin and token exist for same network id`() { + // Arrange + val token = cryptoCurrencyFactory.createToken(Blockchain.Ethereum) + val coinStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + val tokenStatus = CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Loading, + ) + val accountStatusList = createAccountStatusList( + currencies = listOf(currency, token), + currencyStatuses = listOf(coinStatus, tokenStatus), + ) + // Act + val result = accountStatusList.getCoinStatus(currency.network.id) + // Assert + assertSome(result, coinStatus) + } + + @Test + fun `returns correct coin when multiple networks exist`() { + // Arrange + val currencies = cryptoCurrencyFactory.ethereumAndStellar + val currencyStatuses = currencies.map { + CryptoCurrencyStatus(currency = it, value = CryptoCurrencyStatus.Loading) + } + val accountStatusList = createAccountStatusList( + currencies = currencies, + currencyStatuses = currencyStatuses, + ) + val targetCurrency = currencies.last() + val expectedStatus = currencyStatuses.last() + // Act + val result = accountStatusList.getCoinStatus(targetCurrency.network.id) + // Assert + assertSome(result, expectedStatus) + } + } + private fun createAccountStatusList( currencies: List, currencyStatuses: List = emptyList(), diff --git a/domain/tokens/detekt-baseline-debug.xml b/domain/tokens/detekt-baseline-debug.xml index 352d25c614..3c148b5f78 100644 --- a/domain/tokens/detekt-baseline-debug.xml +++ b/domain/tokens/detekt-baseline-debug.xml @@ -11,8 +11,6 @@ MultilineLambdaItParameter:FetchCurrencyStatusUseCase.kt$FetchCurrencyStatusUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -> raise(IllegalStateException("$it")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } return@either } MultilineLambdaItParameter:GetBalanceNotEnoughForFeeWarningUseCase.kt$GetBalanceNotEnoughForFeeWarningUseCase${ it is CryptoCurrency.Token && it.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) && it.network.derivationPath == tokenStatus.currency.network.derivationPath } MultilineLambdaItParameter:GetCryptoCurrencyActionsUseCase.kt$GetCryptoCurrencyActionsUseCase${ TokenActionsState( walletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus, states = it.toList(), ) } - MultilineLambdaItParameter:GetCurrencyWarningsUseCase.kt$GetCurrencyWarningsUseCase${ if (isNeedToCreateAccountWithoutReserve(networkId = currencyStatus.currency.network.rawId)) { CryptoCurrencyWarning.TopUpWithoutReserve } else { CryptoCurrencyWarning.SomeNetworksNoAccount( amountToCreateAccount = it.amountToCreateAccount, amountCurrency = currencyStatus.currency, ) } } - MultilineLambdaItParameter:GetCurrencyWarningsUseCase.kt$GetCurrencyWarningsUseCase${ it is CryptoCurrency.Token && it.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) && it.network.derivationPath == tokenStatus.currency.network.derivationPath } MultilineLambdaItParameter:GetWalletTotalBalanceUseCase.kt$GetWalletTotalBalanceUseCase${ Timber.e("failed to load balances with error: $it") TotalFiatBalance.Failed } MultilineLambdaItParameter:PriceChangeCalculator.kt$PriceChangeCalculator${ val weight = it.value.fiatAmount.orZero().divide(balance, 2, RoundingMode.HALF_UP) val priceChange = it.value.priceChange.orZero() weight * priceChange } MultilineLambdaItParameter:WalletBalanceFetcher.kt$WalletBalanceFetcher${ val stakingId = stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it) if (stakingId.isLeft { it is StakingIdFactory.Error.UnableToGetAddress }) { Timber.e("Unable to get staking ID for user wallet $userWalletId and currency ${it.id}") } stakingId } @@ -20,7 +18,6 @@ NoNameShadowing:WalletBalanceFetcher.kt$WalletBalanceFetcher${ it is StakingIdFactory.Error.UnableToGetAddress } NullableBooleanCheck:GetCurrencyCheckUseCase.kt$GetCurrencyCheckUseCase$recipientAddress?.let { currencyChecksRepository.checkIfAccountFunded( userWalletId, network, recipientAddress, ) } ?: false SuspendFunWithFlowReturnType:BaseCurrencyStatusOperations.kt$BaseCurrencyStatusOperations$suspend - SuspendFunWithFlowReturnType:GetCurrencyWarningsUseCase.kt$GetCurrencyWarningsUseCase$suspend SuspendFunWithFlowReturnType:GetNetworkCoinStatusUseCase.kt$GetNetworkCoinStatusUseCase$suspend SuspendFunWithFlowReturnType:GetSingleCryptoCurrencyStatusUseCase.kt$GetSingleCryptoCurrencyStatusUseCase$suspend UnnecessaryAbstractClass:MultiWalletCryptoCurrenciesSupplier.kt$MultiWalletCryptoCurrenciesSupplier$MultiWalletCryptoCurrenciesSupplier diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrenciesStatusesOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrenciesStatusesOperations.kt deleted file mode 100644 index d3842665be..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrenciesStatusesOperations.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.domain.tokens.operations - -import com.tangem.domain.core.lce.LceFlow -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokens.error.TokenListError - -/** - * Base operations for working with currencies statuses - * -[REDACTED_AUTHOR] - */ -interface BaseCurrenciesStatusesOperations { - - /** Get [LceFlow] of currencies statuses by [userWalletId] */ - fun getCurrenciesStatuses(userWalletId: UserWalletId): LceFlow> -} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt similarity index 83% rename from domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt rename to features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt index 15315030c7..1c8370811a 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt @@ -1,17 +1,21 @@ -package com.tangem.domain.tokens +package com.tangem.feature.tokendetails.domain import com.tangem.blockchainsdk.utils.isNeedToCreateAccountWithoutReserve +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCoinStatus +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.model.CurrencyAmount import com.tangem.domain.tokens.model.FeePaidCurrency 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.tokens.operations.BaseCurrencyStatusOperations import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.models.AssetRequirementsCondition @@ -20,23 +24,22 @@ import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import java.math.BigDecimal +import javax.inject.Inject -@Suppress("LongParameterList") -class GetCurrencyWarningsUseCase( +@Suppress("LongParameterList", "SuspendFunWithFlowReturnType") +internal class GetCurrencyWarningsUseCase @Inject constructor( private val walletManagersFacade: WalletManagersFacade, private val currenciesRepository: CurrenciesRepository, private val dispatchers: CoroutineDispatcherProvider, private val currencyChecksRepository: CurrencyChecksRepository, - private val currencyStatusOperations: BaseCurrencyStatusOperations, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, ) { suspend operator fun invoke( userWalletId: UserWalletId, currencyStatus: CryptoCurrencyStatus, derivationPath: Network.DerivationPath, - isSingleWalletWithTokens: Boolean, ): Flow> { val currency = currencyStatus.currency @@ -44,10 +47,7 @@ class GetCurrencyWarningsUseCase( return combine( flow = getCoinRelatedWarnings( userWalletId = userWalletId, - networkId = currency.network.id, - currencyId = currency.id, - derivationPath = derivationPath, - isSingleWalletWithTokens = isSingleWalletWithTokens, + currency = currency, ), flow2 = flowOf(currencyChecksRepository.getRentInfoWarning(userWalletId, currencyStatus)), flow3 = flowOf(currencyChecksRepository.getExistentialDeposit(userWalletId, currency.network)), @@ -68,44 +68,34 @@ class GetCurrencyWarningsUseCase( }.flowOn(dispatchers.io) } - @Suppress("LongParameterList") private suspend fun getCoinRelatedWarnings( userWalletId: UserWalletId, - networkId: Network.ID, - currencyId: CryptoCurrency.ID, - derivationPath: Network.DerivationPath, - isSingleWalletWithTokens: Boolean, + currency: CryptoCurrency, ): Flow> { - val currencyFlow = currencyStatusOperations.getCurrencyStatusFlow( - userWalletId = userWalletId, - currencyId = currencyId, - isSingleWalletWithTokens = isSingleWalletWithTokens, - ) + return singleAccountStatusListSupplier(userWalletId) + .map { accountStatusList -> + val coin = accountStatusList.getCoinStatus(currency).getOrNull() + val token = accountStatusList.getCryptoCurrencyStatus(currency).getOrNull() - val networkFlow = if (isSingleWalletWithTokens) { - currencyStatusOperations.getNetworkCoinForSingleWalletWithTokenFlow(userWalletId, networkId) - } else { - currencyStatusOperations.getNetworkCoinFlow(userWalletId, networkId, derivationPath) - } + coin to token + } + .distinctUntilChanged() + .map { pair -> + val (coinStatus, tokenStatus) = pair - return combine( - currencyFlow.map { it.getOrNull() }, - networkFlow.map { it.getOrNull() }, - ) { tokenStatus, coinStatus -> - when { - tokenStatus != null && coinStatus != null -> { - buildList { - getUsedOutdatedDataWarning(tokenStatus)?.let(::add) + if (tokenStatus != null && coinStatus != null) { + listOfNotNull( + getUsedOutdatedDataWarning(tokenStatus), getFeeWarning( userWalletId = userWalletId, coinStatus = coinStatus, tokenStatus = tokenStatus, - )?.let(::add) - } + ), + ) + } else { + listOf(CryptoCurrencyWarning.SomeNetworksUnreachable) } - else -> listOf(CryptoCurrencyWarning.SomeNetworksUnreachable) } - } } private suspend fun getFeeWarning( @@ -161,10 +151,10 @@ class GetCurrencyWarningsUseCase( ) .orEmpty() - val token = tokens.find { - it is CryptoCurrency.Token && - it.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) && - it.network.derivationPath == tokenStatus.currency.network.derivationPath + val token = tokens.find { currency -> + currency is CryptoCurrency.Token && + currency.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) && + currency.network.derivationPath == tokenStatus.currency.network.derivationPath } return if (token != null) { @@ -193,12 +183,12 @@ class GetCurrencyWarningsUseCase( } private fun getNetworkNoAccountWarning(currencyStatus: CryptoCurrencyStatus): CryptoCurrencyWarning? { - return (currencyStatus.value as? CryptoCurrencyStatus.NoAccount)?.let { + return (currencyStatus.value as? CryptoCurrencyStatus.NoAccount)?.let { noAccountStatus -> if (isNeedToCreateAccountWithoutReserve(networkId = currencyStatus.currency.network.rawId)) { CryptoCurrencyWarning.TopUpWithoutReserve } else { CryptoCurrencyWarning.SomeNetworksNoAccount( - amountToCreateAccount = it.amountToCreateAccount, + amountToCreateAccount = noAccountStatus.amountToCreateAccount, amountCurrency = currencyStatus.currency, ) } 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 2f8b9f76ed..d897836afb 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 @@ -38,7 +38,6 @@ import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveNotification @@ -79,6 +78,7 @@ import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase import com.tangem.domain.yield.supply.models.YieldSupplyRewardBalance import com.tangem.domain.yield.supply.usecase.YieldSupplyGetRewardsBalanceUseCase import com.tangem.feature.tokendetails.deeplink.TokenDetailsDeepLinkActionListener +import com.tangem.feature.tokendetails.domain.GetCurrencyWarningsUseCase import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsCurrencyStatusAnalyticsSender import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsNotificationsAnalyticsSender @@ -326,12 +326,10 @@ internal class TokenDetailsModel @Inject constructor( private fun updateWarnings(cryptoCurrencyStatus: CryptoCurrencyStatus) { modelScope.launch(dispatchers.main) { - getCurrencyWarningsUseCase.invoke( + getCurrencyWarningsUseCase( userWalletId = userWalletId, currencyStatus = cryptoCurrencyStatus, derivationPath = cryptoCurrency.network.derivationPath, - isSingleWalletWithTokens = userWallet is UserWallet.Cold && - userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), ) .distinctUntilChanged() .onEach { warnings -> From bf3312ba9e94eed0844493be1313f13807672a69 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 11 Mar 2026 19:43:34 +0500 Subject: [PATCH 21/60] Updated on 2026-08-14 --- .../tangem/core/ui/ds/TangemPagerIndicator.kt | 140 +++++++----------- 1 file changed, 52 insertions(+), 88 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/TangemPagerIndicator.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/TangemPagerIndicator.kt index 5528190513..ad4bc15824 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/TangemPagerIndicator.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/TangemPagerIndicator.kt @@ -1,5 +1,6 @@ package com.tangem.core.ui.ds +import android.content.res.Configuration import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateDpAsState @@ -8,6 +9,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -16,10 +18,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import kotlinx.coroutines.Job @@ -34,30 +39,31 @@ private const val MIN_HIDDEN_FOR_SMALL_DOT = 2 private const val MIN_DISTANCE_FOR_SMALL_DOT = 3 private const val MIN_DISTANCE_FOR_HINT_DOT = 2 -private val SPACING = 4.dp -private val CURRENT_DOT_SIZE = DpSize(16.dp, 8.dp) +private val SPACING = 8.dp private val NORMAL_DOT_SIZE = DpSize(8.dp, 8.dp) private val HINT_DOT_SIZE = DpSize(6.dp, 6.dp) private val SMALL_DOT_SIZE = DpSize(4.dp, 4.dp) /** - * // TODO Cleanup and document this code, it's quite complex and has some "magic numbers" that need explanation. - * * A pager indicator that adapts to the number of pages and the current page index. * + * [Figma]("https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8452-16489&m=dev") + * * For 5 or fewer pages, it shows all dots with the current page highlighted. * For more than 5 pages, it shows a sliding window of 5 dots with size and opacity indicating position. * - * @param pagerState state of the pager to observe - * @param activeIndicatorColor color for the active page indicator + * @param pagerState state of the pager to observe + * @param modifier modifier for styling + * @param hasBackground whether to show a background behind the indicators + * @param activeIndicatorColor color for the active page indicator * @param inactiveIndicatorColor color for the inactive page indicators - * @param modifier modifier for styling */ @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun TangemPagerIndicator( pagerState: PagerState, modifier: Modifier = Modifier, + hasBackground: Boolean = false, activeIndicatorColor: Color = TangemTheme.colors2.graphic.neutral.primary, inactiveIndicatorColor: Color = TangemTheme.colors2.graphic.neutral.tertiary, ) { @@ -124,7 +130,14 @@ fun TangemPagerIndicator( val visibleIndices = (displayLower until displayUpper).toList() Box( - modifier = modifier, + modifier = modifier + .conditionalCompose(hasBackground) { + background( + color = TangemTheme.colors2.tabs.backgroundSecondary, + shape = CircleShape, + ) + } + .padding(TangemTheme.dimens2.x3), contentAlignment = Alignment.Center, ) { Row( @@ -173,9 +186,6 @@ private fun getWindowBounds(totalPages: Int, currentIndex: Int): Pair } private fun getDotSize(index: Int, currentIndex: Int, totalPages: Int): DpSize { - if (index == currentIndex) { - return CURRENT_DOT_SIZE - } if (totalPages <= MAX_VISIBLE_DOTS) { return NORMAL_DOT_SIZE } @@ -276,95 +286,49 @@ private fun Dot( val shape = RoundedCornerShape(animatedHeight / 2) - Box( - modifier = modifier - .width(animatedWidth) - .height(animatedHeight) - .background(animatedColor, shape), - ) + Box(modifier.size(8.dp)) { + Box( + modifier = Modifier + .align(Alignment.Center) + .width(animatedWidth) + .height(animatedHeight) + .background(animatedColor, shape), + ) + } } -@Preview(showBackground = true) +// region Preview @Composable -private fun PagerIndicatorPreview() { +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemPagerIndicator_Preview(@PreviewParameter(TangemPagerIndicatorPreviewProvider::class) params: Int) { TangemThemePreviewRedesign { Column( Modifier - .background(TangemTheme.colors.background.primary) + .background(TangemTheme.colors2.surface.level1) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - listOf(0, 1, 2, 3, 4).forEach { page -> - TangemPagerIndicator(rememberPagerState(page) { 5 }) + repeat(params) { index -> + TangemPagerIndicator( + pagerState = rememberPagerState(index) { params }, + hasBackground = index % 2 == 0, + ) } } } } -@Preview(showBackground = true) -@Composable -private fun PagerIndicator6ItemsPreview() { - TangemThemePreviewRedesign { - Column( - Modifier - .background(TangemTheme.colors.background.primary) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - listOf(0, 1, 2, 3, 4, 5).forEach { page -> - TangemPagerIndicator(rememberPagerState(page) { 6 }) - } - } - } +private class TangemPagerIndicatorPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + 1, + 2, + 3, + 5, + 6, + 7, + 10, + ) } - -@Preview(showBackground = true) -@Composable -private fun PagerIndicator7ItemsPreview() { - TangemThemePreviewRedesign { - Column( - Modifier - .background(TangemTheme.colors.background.primary) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - listOf(0, 1, 2, 3, 4, 5, 6).forEach { page -> - TangemPagerIndicator(rememberPagerState(page) { 7 }) - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun PagerIndicator10ItemsPreview() { - TangemThemePreviewRedesign { - Column( - Modifier - .background(TangemTheme.colors.background.primary) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9).forEach { page -> - TangemPagerIndicator(rememberPagerState(page) { 10 }) - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun PagerIndicatorSmallCountsPreview() { - TangemThemePreviewRedesign { - Column( - Modifier - .background(TangemTheme.colors.background.primary) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - TangemPagerIndicator(rememberPagerState(0) { 1 }) - TangemPagerIndicator(rememberPagerState(1) { 2 }) - TangemPagerIndicator(rememberPagerState(1) { 3 }) - } - } -} \ No newline at end of file +// endregion \ No newline at end of file From d31d3f8a9b272254f05469e42d29d5281658be9f Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Mar 2026 10:41:39 +0400 Subject: [PATCH 22/60] Updated on 2026-08-14 --- .../tap/di/domain/TransactionDomainModule.kt | 33 +++++--------- .../GetMultiCryptoCurrencyStatusUseCase.kt | 26 ----------- .../gasless/EstimateFeeForGaslessTxUseCase.kt | 45 +++++++++---------- .../gasless/EstimateFeeForTokenUseCase.kt | 27 ++++------- .../gasless/GetAvailableFeeTokensUseCase.kt | 36 +++++---------- .../gasless/GetFeeForGaslessUseCase.kt | 43 ++++++++---------- .../usecase/gasless/GetFeeForTokenUseCase.kt | 34 ++++++-------- .../swap/domain/di/SwapDomainModule.kt | 10 ----- 8 files changed, 82 insertions(+), 172 deletions(-) delete mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetMultiCryptoCurrencyStatusUseCase.kt 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 a218b25669..16a63b51aa 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 @@ -1,16 +1,15 @@ package com.tangem.tap.di.domain import com.tangem.data.wallets.hot.TangemHotWalletSigner +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.notifications.repository.PushNotificationsRepository -import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.transaction.GaslessTransactionRepository @@ -279,14 +278,12 @@ internal object TransactionDomainModule { @Singleton fun provideGetAvailableFeeTokensUseCase( gaslessTransactionRepository: GaslessTransactionRepository, - currenciesRepository: CurrenciesRepository, - getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currencyChecksRepository: CurrencyChecksRepository, ): GetAvailableFeeTokensUseCase { return GetAvailableFeeTokensUseCase( + singleAccountStatusListSupplier = singleAccountStatusListSupplier, gaslessTransactionRepository = gaslessTransactionRepository, - currenciesRepository = currenciesRepository, - getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, currencyChecksRepository = currencyChecksRepository, ) } @@ -296,17 +293,15 @@ internal object TransactionDomainModule { fun provideGetFeeForGaslessUseCase( walletManagersFacade: WalletManagersFacade, gaslessTransactionRepository: GaslessTransactionRepository, - currenciesRepository: CurrenciesRepository, getFeeUseCase: GetFeeUseCase, - getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currencyChecksRepository: CurrencyChecksRepository, ): GetFeeForGaslessUseCase { return GetFeeForGaslessUseCase( walletManagersFacade = walletManagersFacade, demoConfig = DemoConfig, gaslessTransactionRepository = gaslessTransactionRepository, - currenciesRepository = currenciesRepository, - getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, getFeeUseCase = getFeeUseCase, currencyChecksRepository = currencyChecksRepository, ) @@ -317,16 +312,14 @@ internal object TransactionDomainModule { fun provideGetFeeForTokenUseCase( walletManagersFacade: WalletManagersFacade, gaslessTransactionRepository: GaslessTransactionRepository, - currenciesRepository: CurrenciesRepository, - getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currencyChecksRepository: CurrencyChecksRepository, ): GetFeeForTokenUseCase { return GetFeeForTokenUseCase( gaslessTransactionRepository = gaslessTransactionRepository, walletManagersFacade = walletManagersFacade, demoConfig = DemoConfig, - currenciesRepository = currenciesRepository, - getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, currencyChecksRepository = currencyChecksRepository, ) } @@ -362,16 +355,14 @@ internal object TransactionDomainModule { fun provideEstimateFeeForTokenUseCase( walletManagersFacade: WalletManagersFacade, gaslessTransactionRepository: GaslessTransactionRepository, - currenciesRepository: CurrenciesRepository, - getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currencyChecksRepository: CurrencyChecksRepository, ): EstimateFeeForTokenUseCase { return EstimateFeeForTokenUseCase( gaslessTransactionRepository = gaslessTransactionRepository, walletManagersFacade = walletManagersFacade, demoConfig = DemoConfig, - currenciesRepository = currenciesRepository, - getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, currencyChecksRepository = currencyChecksRepository, ) } @@ -381,8 +372,7 @@ internal object TransactionDomainModule { fun provideEstimateFeeForGaslessTxUseCase( walletManagersFacade: WalletManagersFacade, gaslessTransactionRepository: GaslessTransactionRepository, - currenciesRepository: CurrenciesRepository, - getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, estimateFeeUseCase: EstimateFeeUseCase, currencyChecksRepository: CurrencyChecksRepository, ): EstimateFeeForGaslessTxUseCase { @@ -390,8 +380,7 @@ internal object TransactionDomainModule { gaslessTransactionRepository = gaslessTransactionRepository, walletManagersFacade = walletManagersFacade, demoConfig = DemoConfig, - currenciesRepository = currenciesRepository, - getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, estimateFeeUseCase = estimateFeeUseCase, currencyChecksRepository = currencyChecksRepository, ) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetMultiCryptoCurrencyStatusUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetMultiCryptoCurrencyStatusUseCase.kt deleted file mode 100644 index 63b74c9631..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetMultiCryptoCurrencyStatusUseCase.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.tangem.domain.tokens - -import arrow.core.Either -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokens.error.CurrencyStatusError -import com.tangem.domain.tokens.error.TokenListError -import com.tangem.domain.tokens.error.mapper.mapToTokenListError -import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations -import kotlinx.coroutines.flow.Flow - -class GetMultiCryptoCurrencyStatusUseCase( - private val currencyStatusOperations: BaseCurrencyStatusOperations, -) { - - /** - * Returns synchronously list of cryptocurrency statuses for Multi-Currency wallet - * - * @param userWalletId The unique identifier of the user's wallet. - * @return A [Flow] emitting either a [CurrencyStatusError] or a [CryptoCurrencyStatus], indicating the result of the fetch operation. - */ - suspend fun invokeMultiWalletSync(userWalletId: UserWalletId): Either> { - return currencyStatusOperations.getCurrenciesStatusesSync(userWalletId) - .mapLeft { error -> error.mapToTokenListError() } - } -} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt index b43a368305..260a0dd6f1 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt @@ -1,19 +1,21 @@ package com.tangem.domain.transaction.usecase.gasless import arrow.core.Either +import arrow.core.getOrElse import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCoinStatus import com.tangem.domain.demo.models.DemoConfig 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.tokens.GetMultiCryptoCurrencyStatusUseCase -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError @@ -29,8 +31,7 @@ class EstimateFeeForGaslessTxUseCase( private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, private val gaslessTransactionRepository: GaslessTransactionRepository, - private val currenciesRepository: CurrenciesRepository, - private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val estimateFeeUseCase: EstimateFeeUseCase, private val currencyChecksRepository: CurrencyChecksRepository, ) { @@ -50,11 +51,13 @@ class EstimateFeeForGaslessTxUseCase( catch( block = { val network = sendingTokenCurrencyStatus.currency.network - val nativeCurrency = currenciesRepository.getNetworkCoin( - userWalletId = userWallet.walletId, - networkId = network.id, - derivationPath = network.derivationPath, - ) + + val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull(userWallet.walletId) + ?: raiseIllegalStateError("AccountStatusList is null for ${userWallet.walletId}") + + val nativeCurrencyStatus = accountStatusList.getCoinStatus(network).getOrElse { + raiseIllegalStateError("No native currency found: ${network.id}") + } if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) { estimateFeeUseCase.invoke( @@ -66,7 +69,7 @@ class EstimateFeeForGaslessTxUseCase( ifRight = { fee -> return@either TransactionFeeExtended( transactionFee = fee, - feeTokenId = nativeCurrency.id, + feeTokenId = nativeCurrencyStatus.currency.id, ) }, ) @@ -81,9 +84,9 @@ class EstimateFeeForGaslessTxUseCase( ).bind() selectFeePaymentStrategy( - userWallet = userWallet, + accountStatusList = accountStatusList, walletManager = walletManager, - nativeCurrency = nativeCurrency, + nativeCurrencyStatus = nativeCurrencyStatus, network = network, initialFee = initialFee, ) @@ -110,25 +113,17 @@ class EstimateFeeForGaslessTxUseCase( } private suspend fun Raise.selectFeePaymentStrategy( - userWallet: UserWallet, + accountStatusList: AccountStatusList, walletManager: EthereumWalletManager, - nativeCurrency: CryptoCurrency, + nativeCurrencyStatus: CryptoCurrencyStatus, network: Network, initialFee: TransactionFee, ): TransactionFeeExtended { val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError) - val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWallet.walletId, - ).getOrNull() ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") - - val networkCurrenciesStatuses = userCurrenciesStatuses.filter { - it.currency.network.id == network.id - } - - val nativeCurrencyStatus = networkCurrenciesStatuses.find { - it.currency.id == nativeCurrency.id - } ?: raiseIllegalStateError("native currency not found for network ${network.id}") + val networkCurrenciesStatuses = accountStatusList + .flattenCurrencies() + .filter { it.currency.network.id == network.id } val nativeBalance = nativeCurrencyStatus.value.amount ?: BigDecimal.ZERO return if (nativeBalance >= feeValue) { diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt index 567aa6a486..c311cce1aa 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt @@ -1,17 +1,18 @@ package com.tangem.domain.transaction.usecase.gasless import arrow.core.Either +import arrow.core.getOrElse import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCoinStatus import com.tangem.domain.demo.models.DemoConfig 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.tokens.GetMultiCryptoCurrencyStatusUseCase -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError @@ -25,8 +26,7 @@ class EstimateFeeForTokenUseCase( private val gaslessTransactionRepository: GaslessTransactionRepository, private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, - private val currenciesRepository: CurrenciesRepository, - private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val currencyChecksRepository: CurrencyChecksRepository, ) { @@ -61,21 +61,12 @@ class EstimateFeeForTokenUseCase( error = "only Fee.Ethereum supported, but was different", ) - val nativeCurrency = currenciesRepository.getNetworkCoin( - userWalletId = userWallet.walletId, - networkId = token.network.id, - derivationPath = token.network.derivationPath, - ) + val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull(userWallet.walletId) + ?: raiseIllegalStateError("AccountStatusList is null for ${userWallet.walletId}") - val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWallet.walletId, - ).getOrNull()?.filter { - it.currency.network.id == token.network.id - } ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") - - val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find { - it.currency.id == nativeCurrency.id - } ?: raiseIllegalStateError("native currency not found for network ${token.network.id}") + val nativeCurrencyStatus = accountStatusList.getCoinStatus(token.network).getOrElse { + raiseIllegalStateError("No native currency found: ${token.network.id}") + } val walletManager = prepareWalletManager(userWallet, token.network) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index 0d2c825756..ecac79ad59 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -1,24 +1,23 @@ package com.tangem.domain.transaction.usecase.gasless import arrow.core.Either -import arrow.core.raise.Raise +import arrow.core.getOrElse import arrow.core.raise.catch import arrow.core.raise.either +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCoinStatus 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.tokens.GetMultiCryptoCurrencyStatusUseCase -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.raiseIllegalStateError class GetAvailableFeeTokensUseCase( + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val gaslessTransactionRepository: GaslessTransactionRepository, - private val currenciesRepository: CurrenciesRepository, - private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, private val currencyChecksRepository: CurrencyChecksRepository, ) { @@ -34,12 +33,14 @@ class GetAvailableFeeTokensUseCase( return either { catch( block = { - val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWallet.walletId, - ).getOrNull() - ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull(userWallet.walletId) + ?: raiseIllegalStateError("AccountStatusList is null for ${userWallet.walletId}") - val nativeCurrencyStatus = getNativeCurrencyStatus(userWallet, network, userCurrenciesStatuses) + val userCurrenciesStatuses = accountStatusList.flattenCurrencies() + + val nativeCurrencyStatus = accountStatusList.getCoinStatus(network).getOrElse { + raiseIllegalStateError("No native currency found: ${network.id}") + } if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) { return@either listOf(nativeCurrencyStatus) @@ -58,21 +59,6 @@ class GetAvailableFeeTokensUseCase( } } - private suspend fun Raise.getNativeCurrencyStatus( - userWallet: UserWallet, - network: Network, - userCurrenciesStatuses: List, - ): CryptoCurrencyStatus { - val nativeCurrency = currenciesRepository.getNetworkCoin( - userWalletId = userWallet.walletId, - networkId = network.id, - derivationPath = network.derivationPath, - ) - return userCurrenciesStatuses.find { - it.currency.id == nativeCurrency.id - } ?: raiseIllegalStateError("no native currency found") - } - private suspend fun getGaslessTokens( network: Network, userCurrenciesStatuses: List, diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt index 8400491228..c6bf7fc229 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt @@ -9,13 +9,14 @@ import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCoinStatus import com.tangem.domain.demo.models.DemoConfig 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.tokens.GetMultiCryptoCurrencyStatusUseCase -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError @@ -31,8 +32,7 @@ class GetFeeForGaslessUseCase( private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, private val gaslessTransactionRepository: GaslessTransactionRepository, - private val currenciesRepository: CurrenciesRepository, - private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getFeeUseCase: GetFeeUseCase, private val currencyChecksRepository: CurrencyChecksRepository, ) { @@ -51,11 +51,12 @@ class GetFeeForGaslessUseCase( return either { catch( block = { - val nativeCurrency = currenciesRepository.getNetworkCoin( - userWalletId = userWallet.walletId, - networkId = network.id, - derivationPath = network.derivationPath, - ) + val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull(userWallet.walletId) + ?: raiseIllegalStateError("AccountStatusList is null for ${userWallet.walletId}") + + val nativeCurrencyStatus = accountStatusList.getCoinStatus(network).getOrElse { + raiseIllegalStateError("No native currency found: ${network.id}") + } if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) { getFeeUseCase.invoke(userWallet, network, transactionData).fold( @@ -63,7 +64,7 @@ class GetFeeForGaslessUseCase( ifRight = { fee -> return@either TransactionFeeExtended( transactionFee = fee, - feeTokenId = nativeCurrency.id, + feeTokenId = nativeCurrencyStatus.currency.id, ) }, ) @@ -79,9 +80,9 @@ class GetFeeForGaslessUseCase( ).bind() selectFeePaymentStrategy( - userWallet = userWallet, + accountStatusList = accountStatusList, walletManager = walletManager, - nativeCurrency = nativeCurrency, + nativeCurrencyStatus = nativeCurrencyStatus, network = network, initialFee = initialFee, ) @@ -108,25 +109,17 @@ class GetFeeForGaslessUseCase( } private suspend fun Raise.selectFeePaymentStrategy( - userWallet: UserWallet, + accountStatusList: AccountStatusList, walletManager: EthereumWalletManager, - nativeCurrency: CryptoCurrency, + nativeCurrencyStatus: CryptoCurrencyStatus, network: Network, initialFee: TransactionFee, ): TransactionFeeExtended { val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError) - val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWallet.walletId, - ).getOrNull() ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") - - val networkCurrenciesStatuses = userCurrenciesStatuses.filter { - it.currency.network.id == network.id - } - - val nativeCurrencyStatus = networkCurrenciesStatuses.find { - it.currency.id == nativeCurrency.id - } ?: raiseIllegalStateError("native currency not found for network ${network.id}") + val networkCurrenciesStatuses = accountStatusList + .flattenCurrencies() + .filter { it.currency.network.id == network.id } val nativeBalance = nativeCurrencyStatus.value.amount ?: BigDecimal.ZERO val nativeCoinSelectedResult = diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt index f20c162b52..1e96c27a19 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt @@ -1,18 +1,20 @@ package com.tangem.domain.transaction.usecase.gasless import arrow.core.Either +import arrow.core.getOrElse import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCoinStatus +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus 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.tokens.GetMultiCryptoCurrencyStatusUseCase -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError @@ -25,8 +27,7 @@ class GetFeeForTokenUseCase( private val gaslessTransactionRepository: GaslessTransactionRepository, private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, - private val currenciesRepository: CurrenciesRepository, - private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val currencyChecksRepository: CurrencyChecksRepository, ) { @@ -62,25 +63,16 @@ class GetFeeForTokenUseCase( error = "only Fee.Ethereum supported, but was different", ) - val nativeCurrency = currenciesRepository.getNetworkCoin( - userWalletId = userWallet.walletId, - networkId = token.network.id, - derivationPath = token.network.derivationPath, - ) + val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull(userWallet.walletId) + ?: raiseIllegalStateError("AccountStatusList is null for ${userWallet.walletId}") - val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWallet.walletId, - ).getOrNull()?.filter { - it.currency.network.id == token.network.id - } ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + val nativeCurrencyStatus = accountStatusList.getCoinStatus(token.network).getOrElse { + raiseIllegalStateError("Native currency not found for network ${token.network.id}") + } - val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find { - it.currency.id == nativeCurrency.id - } ?: raiseIllegalStateError("native currency not found for network ${token.network.id}") - - val tokenCurrencyStatus = userCurrenciesStatusesByNetwork.find { - it.currency.id == token.id - } ?: raiseIllegalStateError("token currency not found for network ${token.network.id}") + val tokenCurrencyStatus = accountStatusList.getCryptoCurrencyStatus(currency = token).getOrElse { + raiseIllegalStateError("Token currency not found for network ${token.network.id}") + } tokenFeeCalculator.calculateTokenFee( walletManager = walletManager, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt index 7a7e89069d..ab15afbae5 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -1,7 +1,5 @@ package com.tangem.feature.swap.domain.di -import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase -import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations import com.tangem.feature.swap.domain.* import dagger.Module import dagger.Provides @@ -24,14 +22,6 @@ internal class SwapDomainModule { return factory } - @Provides - @Singleton - fun providesGetCryptoCurrencyStatusUseCase( - currencyStatusOperations: BaseCurrencyStatusOperations, - ): GetMultiCryptoCurrencyStatusUseCase { - return GetMultiCryptoCurrencyStatusUseCase(currencyStatusOperations) - } - @Provides @Singleton fun provideInitialToCurrencyResolver( From d4b7aa85e144099f67ddedb3a159ba0e7c52a4d4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Mar 2026 07:42:59 +0100 Subject: [PATCH 23/60] Updated on 2026-08-14 --- .../main/res/drawable/ic_chewron_down_20.xml | 9 + .../main/res/drawable/ic_staking_new_16.xml | 9 + .../ShortArticleToArticleConfigUMConverter.kt | 5 +- .../UpdateTrendingNewsStateTransformer.kt | 3 +- .../details/MarketsTokenDetailsModel.kt | 2 +- .../features/feed/ui/earn/EarnContent.kt | 360 +++++++++++++----- .../ui/earn/components/CardFilterBlock.kt | 30 ++ .../earn/components/EarnFilterBottomSheet.kt | 55 +++ .../EarnFilterByNetworkBottomSheet.kt | 202 +++++++++- .../components/EarnFilterByTypeBottomSheet.kt | 90 ++++- .../feed/ui/earn/components/EarnListItem.kt | 201 +++++++++- .../ui/earn/components/EarnListPlaceholder.kt | 98 +++-- .../feed/ui/earn/components/FilterButtons.kt | 115 ++++++ .../feed/ui/earn/components/MostlyUsedCard.kt | 2 +- .../earn/components/MostlyUsedPlaceholder.kt | 73 +++- .../feed/ui/feed/components/BlockHeader.kt | 51 ++- .../feed/ui/feed/components/EarnBlock.kt | 101 ++++- .../presentation/wallet/ui/WalletScreen2.kt | 2 +- 18 files changed, 1225 insertions(+), 183 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_chewron_down_20.xml create mode 100644 core/ui/src/main/res/drawable/ic_staking_new_16.xml create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/CardFilterBlock.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/FilterButtons.kt diff --git a/core/ui/src/main/res/drawable/ic_chewron_down_20.xml b/core/ui/src/main/res/drawable/ic_chewron_down_20.xml new file mode 100644 index 0000000000..37be4f7583 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_chewron_down_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_staking_new_16.xml b/core/ui/src/main/res/drawable/ic_staking_new_16.xml new file mode 100644 index 0000000000..99ee618856 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_staking_new_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt index 204c185109..b565dd823c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt @@ -8,7 +8,6 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.news.ShortArticle import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM import com.tangem.features.feed.ui.utils.mapFormattedDate -import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet @@ -16,7 +15,7 @@ import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentSet internal class ShortArticleToArticleConfigUMConverter( - private val isTrending: Provider?, + private val isTrending: Boolean?, ) : Converter, ImmutableList> { override fun convert(value: List): ImmutableList { @@ -25,7 +24,7 @@ internal class ShortArticleToArticleConfigUMConverter( id = shortArticle.id, title = shortArticle.title, score = shortArticle.score, - isTrending = isTrending?.invoke() ?: shortArticle.isTrending, + isTrending = isTrending ?: shortArticle.isTrending, tags = buildArticleTags(shortArticle), createdAt = mapFormattedDate(shortArticle.createdAt), isViewed = shortArticle.viewed, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt index 33c625fb7f..3306ce27b9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt @@ -9,7 +9,6 @@ import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent import com.tangem.features.feed.ui.feed.state.FeedListUM import com.tangem.features.feed.ui.feed.state.NewsUM import com.tangem.features.feed.ui.feed.state.NewsUMState -import com.tangem.utils.Provider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import org.joda.time.DateTime @@ -92,6 +91,6 @@ internal class UpdateTrendingNewsStateTransformer( } private fun getShortArticleConfigConverter(isTrending: Boolean): ShortArticleToArticleConfigUMConverter { - return ShortArticleToArticleConfigUMConverter(isTrending = Provider { isTrending }) + return ShortArticleToArticleConfigUMConverter(isTrending = isTrending) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index 819d362a4a..a63fc181a1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -151,7 +151,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( ) private val shortArticleToArticleConfigUMConverter by lazy { - ShortArticleToArticleConfigUMConverter(isTrending = Provider { false }) + ShortArticleToArticleConfigUMConverter(isTrending = false) } private val descriptionConverter = DescriptionConverter( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt index 9ff14d6bc1..82c0a9cfb6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,25 +22,15 @@ 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.SmallButtonShimmer -import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.SpacerWMax -import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.components.* import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig -import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.extensions.* -import com.tangem.core.ui.res.LocalMainBottomSheetColor -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.feed.ui.earn.components.EarnItemPlaceholder -import com.tangem.features.feed.ui.earn.components.EarnListItem -import com.tangem.features.feed.ui.earn.components.MostlyUsedCard -import com.tangem.features.feed.ui.earn.components.MostlyUsedPlaceholder +import com.tangem.core.ui.res.* +import com.tangem.features.feed.ui.earn.components.* import com.tangem.features.feed.ui.earn.state.* import kotlinx.collections.immutable.persistentListOf @@ -51,6 +42,7 @@ internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) { val density = LocalDensity.current val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } val listState = rememberLazyListState() + val isRedesignEnabled = LocalRedesignEnabled.current if (state.bestOpportunities is EarnBestOpportunitiesUM.Content) { PaginationHandler( @@ -69,7 +61,9 @@ internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) { item(key = "mostly_used_header") { SectionHeader( title = stringResourceSafe(R.string.earn_mostly_used), - modifier = Modifier.padding(top = 16.dp), + modifier = Modifier.padding( + top = if (isRedesignEnabled) 10.dp else 16.dp, + ), ) } @@ -83,7 +77,9 @@ internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) { item(key = "best_opportunities_header") { SectionHeader( title = stringResourceSafe(R.string.earn_best_opportunities), - modifier = Modifier.padding(top = 20.dp), + modifier = Modifier.padding( + top = if (isRedesignEnabled) 32.dp else 20.dp, + ), ) } @@ -97,14 +93,17 @@ internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) { ) } - bestOpportunitiesItems( - state = state.bestOpportunities, - ) + if (isRedesignEnabled) { + bestOpportunitiesItemsV2(state = state.bestOpportunities) + } else { + bestOpportunitiesItemsV1(state = state.bestOpportunities) + } } } @Composable private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) { + val isRedesignEnabled = LocalRedesignEnabled.current AnimatedContent( targetState = state, contentKey = { it::class.java }, @@ -115,10 +114,7 @@ private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) { } is EarnListUM.Content -> { LazyRow( - contentPadding = PaddingValues( - horizontal = 16.dp, - vertical = 12.dp, - ), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { itemsIndexed( @@ -127,12 +123,7 @@ private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) { ) { index, item -> val cardModifier = Modifier.conditional( condition = index == FOURTH_ITEM_INDEX, - modifier = { - onFirstVisible( - minFractionVisible = 0.5f, - callback = onScroll, - ) - }, + modifier = { onFirstVisible(minFractionVisible = 0.5f, callback = onScroll) }, ) MostlyUsedCard( modifier = cardModifier, @@ -147,15 +138,24 @@ private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) { modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp) - .background( - color = TangemTheme.colors.background.action, - shape = TangemTheme.shapes.roundedCornersXMedium, + .conditionalCompose( + condition = isRedesignEnabled, + modifier = { + background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x4), + ) + }, + otherModifier = { + background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + }, ) .padding(vertical = 32.dp, horizontal = 12.dp), contentAlignment = Alignment.Center, - ) { - UnableToLoadData(onRetryClick = animatedState.onRetryClicked) - } + ) { UnableToLoadData(onRetryClick = animatedState.onRetryClicked) } } EarnListUM.Empty -> Unit // no need to handle } @@ -179,7 +179,7 @@ private fun BestOpportunitiesFilters( } } -private fun LazyListScope.bestOpportunitiesItems(state: EarnBestOpportunitiesUM) { +private fun LazyListScope.bestOpportunitiesItemsV1(state: EarnBestOpportunitiesUM) { when (state) { is EarnBestOpportunitiesUM.Loading -> { val lastIndex = PLACEHOLDER_ITEMS_COUNT - 1 @@ -187,7 +187,7 @@ private fun LazyListScope.bestOpportunitiesItems(state: EarnBestOpportunitiesUM) count = PLACEHOLDER_ITEMS_COUNT, key = { "placeholder_$it" }, ) { index -> - EarnItemPlaceholder( + EarnItemPlaceholderV1( modifier = Modifier .roundedShapeItemDecoration( currentIndex = index, @@ -249,56 +249,102 @@ private fun LazyListScope.bestOpportunitiesItems(state: EarnBestOpportunitiesUM) } } -@Composable -private fun FilterButtons( - earnFilterUM: EarnFilterUM, - onNetworkFilterClick: () -> Unit, - onTypeFilterClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier.padding(horizontal = 16.dp), - ) { - SecondarySmallButton( - config = SmallButtonConfig( - text = when (earnFilterUM.selectedNetworkFilter) { - is EarnFilterNetworkUM.AllNetworks -> TextReference.Res(R.string.earn_filter_all_networks) - is EarnFilterNetworkUM.MyNetworks -> TextReference.Res(R.string.earn_filter_my_networks) - is EarnFilterNetworkUM.Network -> TextReference.Str(earnFilterUM.selectedNetworkFilter.text) - }, - onClick = onNetworkFilterClick, - icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24), - isEnabled = earnFilterUM.isNetworkFilterEnabled, - ), - ) - - SpacerWMax() - - SecondarySmallButton( - config = SmallButtonConfig( - text = earnFilterUM.selectedTypeFilter.text, - onClick = onTypeFilterClick, - icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24), - isEnabled = earnFilterUM.isTypeFilterEnabled, - ), - ) +private fun LazyListScope.bestOpportunitiesItemsV2(state: EarnBestOpportunitiesUM) { + when (state) { + is EarnBestOpportunitiesUM.Loading -> { + val lastIndex = PLACEHOLDER_ITEMS_COUNT - 1 + items( + count = PLACEHOLDER_ITEMS_COUNT, + key = { "placeholder_$it" }, + ) { index -> + EarnItemPlaceholderV2( + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = lastIndex, + backgroundColor = TangemTheme.colors2.surface.level3, + ), + ) + } + } + is EarnBestOpportunitiesUM.Empty -> { + item(key = "best_opportunities_empty") { + SpacerH(12.dp) + BestOpportunitiesEmpty() // TODO in [REDACTED_TASK_KEY] + } + } + is EarnBestOpportunitiesUM.EmptyFiltered -> { + item(key = "best_opportunities_empty_filtered") { + SpacerH(12.dp) + BestOpportunitiesEmptyFiltered(onClearFilterClick = state.onClearFilterClick) // TODO in [REDACTED_TASK_KEY] + } + } + is EarnBestOpportunitiesUM.Content -> { + if (state.items.isNotEmpty()) { + val lastIndex = state.items.lastIndex + itemsIndexed( + items = state.items, + key = { _, item -> "${item.tokenName}-${item.network}" }, + ) { index, item -> + EarnListItem( + item = item, + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = lastIndex, + backgroundColor = TangemTheme.colors2.surface.level3, + ), + ) + } + } + } + is EarnBestOpportunitiesUM.Error -> { + item(key = "best_opportunities_error") { + SpacerH(12.dp) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ) + .padding(vertical = 142.dp, horizontal = 114.dp), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData(onRetryClick = state.onRetryClicked) + } + } + } } } @Composable private fun FilterButtonsShimmer(modifier: Modifier = Modifier) { - Row( - modifier = modifier.padding(horizontal = 16.dp), - ) { - SmallButtonShimmer( - modifier = Modifier.width(110.dp), - ) - - SpacerWMax() - - SmallButtonShimmer( - modifier = Modifier.width(90.dp), - ) + Row(modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { + if (LocalRedesignEnabled.current) { + RectangleShimmer( + modifier = Modifier + .width(130.dp) + .height(36.dp), + radius = TangemTheme.dimens2.x25, + ) + SpacerWMax() + RectangleShimmer( + modifier = Modifier + .width(106.dp) + .height(36.dp), + radius = TangemTheme.dimens2.x25, + ) + } else { + SmallButtonShimmer( + modifier = Modifier.width(110.dp), + ) + SpacerWMax() + SmallButtonShimmer( + modifier = Modifier.width(90.dp), + ) + } } } @@ -364,14 +410,25 @@ private fun BestOpportunitiesEmptyFiltered(onClearFilterClick: () -> Unit, modif @Composable private fun SectionHeader(title: String, modifier: Modifier = Modifier) { - Text( - modifier = modifier - .fillMaxWidth() - .padding(start = 20.dp, end = 16.dp), - text = title, - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - ) + if (LocalRedesignEnabled.current) { + Text( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = title, + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + ) + } else { + Text( + modifier = modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 16.dp), + text = title, + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + } } @Composable @@ -392,7 +449,7 @@ private fun PaginationHandler(listState: LazyListState, state: EarnBestOpportuni @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun EarnContentPreview() { +private fun EarnContentPreviewV1() { TangemThemePreview { val background = TangemTheme.colors.background.tertiary CompositionLocalProvider( @@ -434,7 +491,49 @@ private fun EarnContentPreview() { @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun EarnContentLoadingPreview() { +private fun EarnContentPreviewV2() { + TangemThemePreviewRedesign { + val background = TangemTheme.colors2.surface.level3 + CompositionLocalProvider( + LocalMainBottomSheetColor provides remember { mutableStateOf(background) }, + ) { + EarnContent( + state = previewEarnUM( + mostlyUsed = EarnListUM.Content( + items = persistentListOf( + previewEarnListItemUM(), + previewEarnListItemUM( + tokenName = "Cosmos", + symbol = "ATOM", + network = "Cosmos", + ), + ), + ), + bestOpportunities = EarnBestOpportunitiesUM.Content( + items = persistentListOf( + previewEarnListItemUM( + tokenName = "Cosmos Hub", + symbol = "ATOM", + network = "Cosmos network", + ), + previewEarnListItemUM( + tokenName = "Tether", + symbol = "USDT", + network = "Ethereum Network", + ), + ), + onLoadMore = {}, + ), + ), + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun EarnContentLoadingPreviewV1() { TangemThemePreview { val background = TangemTheme.colors.background.tertiary CompositionLocalProvider( @@ -453,7 +552,26 @@ private fun EarnContentLoadingPreview() { @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun EarnContentErrorPreview() { +private fun EarnContentLoadingPreviewV2() { + TangemThemePreviewRedesign { + val background = TangemTheme.colors2.surface.level3 + CompositionLocalProvider( + LocalMainBottomSheetColor provides remember { mutableStateOf(background) }, + ) { + EarnContent( + state = previewEarnUM( + mostlyUsed = EarnListUM.Error(onRetryClicked = {}), + bestOpportunities = EarnBestOpportunitiesUM.Loading, + ), + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun EarnContentErrorPreviewV1() { TangemThemePreview { val background = TangemTheme.colors.background.tertiary CompositionLocalProvider( @@ -481,7 +599,35 @@ private fun EarnContentErrorPreview() { @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun EarnContentEmptyPreview() { +private fun EarnContentErrorPreviewV2() { + TangemThemePreviewRedesign { + val background = TangemTheme.colors2.surface.level3 + CompositionLocalProvider( + LocalMainBottomSheetColor provides remember { mutableStateOf(background) }, + ) { + EarnContent( + state = previewEarnUM( + mostlyUsed = EarnListUM.Content( + items = persistentListOf( + previewEarnListItemUM(), + previewEarnListItemUM( + tokenName = "Cosmos", + symbol = "ATOM", + network = "Cosmos", + ), + ), + ), + bestOpportunities = EarnBestOpportunitiesUM.Error(onRetryClicked = {}), + ), + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun EarnContentEmptyPreviewV1() { TangemThemePreview { val background = TangemTheme.colors.background.tertiary CompositionLocalProvider( @@ -506,6 +652,34 @@ private fun EarnContentEmptyPreview() { } } +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun EarnContentEmptyPreviewV2() { + TangemThemePreviewRedesign { + val background = TangemTheme.colors2.surface.level3 + CompositionLocalProvider( + LocalMainBottomSheetColor provides remember { mutableStateOf(background) }, + ) { + EarnContent( + state = previewEarnUM( + mostlyUsed = EarnListUM.Content( + items = persistentListOf( + previewEarnListItemUM(), + previewEarnListItemUM( + tokenName = "Cosmos", + symbol = "ATOM", + network = "Cosmos", + ), + ), + ), + bestOpportunities = EarnBestOpportunitiesUM.Empty, + ), + ) + } + } +} + @Composable private fun previewEarnListItemUM( tokenName: String = "USDC", diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/CardFilterBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/CardFilterBlock.kt new file mode 100644 index 0000000000..56cb167e10 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/CardFilterBlock.kt @@ -0,0 +1,30 @@ +package com.tangem.features.feed.ui.earn.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun CardFilterBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors2.surface.level2, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.primary, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + content = content, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt new file mode 100644 index 0000000000..947d8e8e1b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt @@ -0,0 +1,55 @@ +package com.tangem.features.feed.ui.earn.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.impl.R + +@Composable +internal inline fun EarnFilterBottomSheet( + config: TangemBottomSheetConfig, + crossinline content: @Composable ColumnScope.(T) -> Unit, +) { + TangemBottomSheet( + config = config, + containerColor = TangemTheme.colors2.surface.level3, + title = { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing24) + .padding(bottom = TangemTheme.dimens.spacing12), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResourceSafe(R.string.earn_filter_by), + style = TangemTheme.typography2.headingSemibold17, + color = TangemTheme.colors2.text.neutral.primary, + textAlign = TextAlign.Center, + ) + SecondaryTangemButton( + modifier = Modifier.align(Alignment.CenterEnd), + onClick = config.onDismissRequest, + iconRes = R.drawable.ic_close_24, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + ) + } + }, + content = content, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt index fc3da1e441..78dd407f9d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt @@ -1,9 +1,7 @@ package com.tangem.features.feed.ui.earn.components import android.content.res.Configuration -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -15,11 +13,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.layoutId import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet @@ -28,28 +28,53 @@ import com.tangem.core.ui.components.inputrow.inner.DividerContainer import com.tangem.core.ui.components.rows.RowContentContainer import com.tangem.core.ui.components.rows.RowText import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.ds.checkbox.TangemCheckbox +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.earn.state.EarnFilterByNetworkBottomSheetContentUM import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList @Composable internal fun EarnFilterByNetworkBottomSheet(config: TangemBottomSheetConfig) { + if (LocalRedesignEnabled.current) { + EarnFilterByNetworkBottomSheetV2(config = config) + } else { + EarnFilterByNetworkBottomSheetV1(config = config) + } +} + +@Composable +private fun EarnFilterByNetworkBottomSheetV1(config: TangemBottomSheetConfig) { TangemBottomSheet( config = config, titleText = resourceReference(R.string.earn_filter_by), containerColor = TangemTheme.colors.background.tertiary, - content = { Content(it) }, + content = { ContentV1(it) }, ) } @Composable -private fun Content(content: EarnFilterByNetworkBottomSheetContentUM) { +private fun EarnFilterByNetworkBottomSheetV2(config: TangemBottomSheetConfig) { + EarnFilterBottomSheet( + config = config, + content = { ContentV2(it) }, + ) +} + +@Composable +private fun ContentV1(content: EarnFilterByNetworkBottomSheetContentUM) { val allMyNetworks = remember(content) { content.networks.filterIsInstance() + content.networks.filterIsInstance() @@ -79,6 +104,132 @@ private fun Content(content: EarnFilterByNetworkBottomSheetContentUM) { } } +@Composable +private fun ContentV2(content: EarnFilterByNetworkBottomSheetContentUM) { + val allMyNetworks = remember(content) { + (content.networks.filterIsInstance() + + content.networks.filterIsInstance()).toImmutableList() + } + val specificNetworks = remember(content) { + content + .networks + .filterIsInstance() + .toImmutableList() + } + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + NetworksTypesBlock( + allMyNetworks = allMyNetworks, + onOptionClick = content.onOptionClick, + ) + + SpecificNetworksBlock( + specificNetworks = specificNetworks, + onOptionClick = content.onOptionClick, + ) + } +} + +@Composable +private fun NetworksTypesBlock( + allMyNetworks: ImmutableList, + onOptionClick: (EarnFilterNetworkUM) -> Unit, +) { + CardFilterBlock { + allMyNetworks.fastForEachIndexed { index, item -> + TangemRowContainer( + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = allMyNetworks.lastIndex, + addDefaultPadding = false, + ) + .clickable { onOptionClick(item) }, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp), + ) { + Text( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), + text = when (item) { + is EarnFilterNetworkUM.AllNetworks -> TextReference.Res(R.string.earn_filter_all_networks) + is EarnFilterNetworkUM.MyNetworks -> TextReference.Res(R.string.earn_filter_my_networks) + is EarnFilterNetworkUM.Network -> TextReference.Str(item.text) + }.resolveReference(), + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + ) + + TangemCheckbox( + modifier = Modifier + .padding(start = 8.dp) + .layoutId(layoutId = TangemRowLayoutId.TAIL), + isChecked = item.isSelected, + onCheckedChange = { onOptionClick(item) }, + ) + } + } + } +} + +@Composable +private fun SpecificNetworksBlock( + specificNetworks: ImmutableList, + onOptionClick: (EarnFilterNetworkUM) -> Unit, +) { + if (specificNetworks.isNotEmpty()) { + CardFilterBlock { + Text( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = 8.dp), + text = stringResourceSafe(id = R.string.earn_filter_networks), + style = TangemTheme.typography2.bodyRegular14, + color = TangemTheme.colors2.text.neutral.tertiary, + ) + specificNetworks.fastForEachIndexed { index, item -> + TangemRowContainer( + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = specificNetworks.lastIndex, + addDefaultPadding = false, + ) + .clickable { onOptionClick(item) }, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp), + ) { + Image( + modifier = Modifier + .size(40.dp) + .layoutId(TangemRowLayoutId.HEAD), + imageVector = ImageVector.vectorResource(item.iconRes), + contentDescription = item.symbol, + ) + Text( + modifier = Modifier + .padding(start = 8.dp) + .layoutId(layoutId = TangemRowLayoutId.START_TOP), + text = item.text, + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + ) + + TangemCheckbox( + modifier = Modifier + .padding(start = 8.dp) + .layoutId(layoutId = TangemRowLayoutId.TAIL), + isChecked = item.isSelected, + onCheckedChange = { onOptionClick(item) }, + ) + } + } + } + } +} + private fun LazyListScope.allMyNetworksList( allMyNetworks: List, onOptionClicked: (EarnFilterNetworkUM) -> Unit, @@ -201,7 +352,7 @@ private fun LazyListScope.specificNetworksList( @Preview(widthDp = 360, heightDp = 800) @Preview(widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun Preview() { +private fun PreviewV1() { TangemThemePreview( alwaysShowBottomSheets = true, ) { @@ -235,4 +386,43 @@ private fun Preview() { ) } } +} + +@Preview(widthDp = 360, heightDp = 800) +@Preview(widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewV2() { + TangemThemePreviewRedesign( + alwaysShowBottomSheets = true, + ) { + Box(Modifier.background(TangemTheme.colors.background.secondary)) { + EarnFilterByNetworkBottomSheet( + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = EarnFilterByNetworkBottomSheetContentUM( + networks = persistentListOf( + EarnFilterNetworkUM.AllNetworks(isSelected = true), + EarnFilterNetworkUM.MyNetworks(isSelected = false), + EarnFilterNetworkUM.Network( + id = "ethereum", + text = "Ethereum", + symbol = "ETH", + iconRes = R.drawable.img_btc_22, + isSelected = false, + ), + EarnFilterNetworkUM.Network( + id = "polygon", + text = "Polygon", + symbol = "MATIC", + iconRes = R.drawable.img_btc_22, + isSelected = false, + ), + ), + onOptionClick = {}, + ), + ), + ) + } + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt index 7790ff977e..4a77e0adbd 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt @@ -5,34 +5,61 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box 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.ui.Modifier +import androidx.compose.ui.layout.layoutId import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.inputrow.InputRowChecked import com.tangem.core.ui.components.inputrow.inner.DividerContainer import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.ds.checkbox.TangemCheckbox +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.earn.state.EarnFilterByTypeBottomSheetContentUM import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM @Composable internal fun EarnFilterByTypeBottomSheet(config: TangemBottomSheetConfig) { + if (LocalRedesignEnabled.current) { + EarnFilterByTypeBottomSheetV2(config) + } else { + EarnFilterByTypeBottomSheetV1(config) + } +} + +@Composable +private fun EarnFilterByTypeBottomSheetV1(config: TangemBottomSheetConfig) { TangemBottomSheet( config = config, titleText = resourceReference(R.string.earn_filter_by), containerColor = TangemTheme.colors.background.tertiary, - content = { Content(it) }, + content = { ContentV1(it) }, ) } @Composable -private fun Content(content: EarnFilterByTypeBottomSheetContentUM) { +private fun EarnFilterByTypeBottomSheetV2(config: TangemBottomSheetConfig) { + EarnFilterBottomSheet( + config = config, + content = { ContentV2(it) }, + ) +} + +@Composable +private fun ContentV1(content: EarnFilterByTypeBottomSheetContentUM) { Column( modifier = Modifier .padding( @@ -62,10 +89,45 @@ private fun Content(content: EarnFilterByTypeBottomSheetContentUM) { } } +@Composable +private fun ContentV2(content: EarnFilterByTypeBottomSheetContentUM) { + CardFilterBlock( + modifier = Modifier.padding(horizontal = 16.dp), + ) { + EarnFilterTypeUM.entries.forEachIndexed { index, type -> + TangemRowContainer( + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = EarnFilterTypeUM.entries.lastIndex, + addDefaultPadding = false, + ) + .clickable { content.onOptionClick(type) }, + contentPadding = PaddingValues(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x3), + ) { + Text( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), + text = type.text.resolveReference(), + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + ) + + TangemCheckbox( + modifier = Modifier + .padding(start = 8.dp) + .layoutId(layoutId = TangemRowLayoutId.TAIL), + isChecked = type == content.selectedOption, + onCheckedChange = { content.onOptionClick(type) }, + ) + } + } + } +} + @Preview(widthDp = 360, heightDp = 640) @Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun Preview() { +private fun PreviewV1() { TangemThemePreview( alwaysShowBottomSheets = true, ) { @@ -82,4 +144,26 @@ private fun Preview() { ) } } +} + +@Preview(widthDp = 360, heightDp = 640) +@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewV2() { + TangemThemePreviewRedesign( + alwaysShowBottomSheets = true, + ) { + Box(Modifier.background(TangemTheme.colors.background.secondary)) { + EarnFilterByTypeBottomSheet( + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = EarnFilterByTypeBottomSheetContentUM( + selectedOption = EarnFilterTypeUM.All, + onOptionClick = {}, + ), + ), + ) + } + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnListItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnListItem.kt index 5f9a56de50..6e4e9799cb 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnListItem.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnListItem.kt @@ -4,12 +4,16 @@ import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.layoutId +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 @@ -18,15 +22,33 @@ import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +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.TangemColorPalette -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.* import com.tangem.features.feed.ui.earn.state.EarnListItemUM @Composable internal fun EarnListItem(item: EarnListItemUM, modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + EarnListItemV2( + item = item, + modifier = modifier, + ) + } else { + EarnListItemV1( + item = item, + modifier = modifier, + ) + } +} + +@Composable +private fun EarnListItemV1(item: EarnListItemUM, modifier: Modifier = Modifier) { Row( modifier = modifier .fillMaxWidth() @@ -82,27 +104,117 @@ internal fun EarnListItem(item: EarnListItemUM, modifier: Modifier = Modifier) { } } +@Composable +private fun EarnListItemV2(item: EarnListItemUM, modifier: Modifier = Modifier) { + TangemRowContainer( + modifier = modifier.clickable { item.onItemClick() }, + contentPadding = PaddingValues(12.dp), + content = { + TangemIcon( + tangemIconUM = TangemIconUM.Currency(item.currencyIconState), + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x2), + ) + + TokenTitle( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.START_TOP) + .padding(end = TangemTheme.dimens2.x2), + name = item.tokenName.resolveReference(), + symbol = item.symbol.resolveReference(), + ) + + Text( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.START_BOTTOM) + .padding(end = TangemTheme.dimens2.x2), + text = item.network.resolveReference(), + color = TangemTheme.colors2.text.neutral.secondary, + style = TangemTheme.typography2.captionSemibold12, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP), + text = item.earnValue.resolveReference(), + color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.bodySemibold16, + maxLines = 1, + ) + + ModeBlock( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM), + earnType = item.earnType, + ) + }, + ) +} + @Composable private fun TokenTitle(name: String, symbol: String, modifier: Modifier = Modifier) { Row(modifier = modifier) { - Text( - modifier = Modifier - .weight(1f, fill = false) - .alignByBaseline(), - text = name, - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.subtitle2, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + if (LocalRedesignEnabled.current) { + Text( + modifier = Modifier + .weight(1f, fill = false) + .alignByBaseline(), + text = name, + color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.bodySemibold16, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + SpacerW(4.dp) + Text( + modifier = Modifier.alignByBaseline(), + text = symbol, + color = TangemTheme.colors2.text.neutral.secondary, + style = TangemTheme.typography2.captionSemibold12, + maxLines = 1, + overflow = TextOverflow.Visible, + ) + } else { + Text( + modifier = Modifier + .weight(1f, fill = false) + .alignByBaseline(), + text = name, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + SpacerW(4.dp) + Text( + modifier = Modifier.alignByBaseline(), + text = symbol, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.body2, + maxLines = 1, + overflow = TextOverflow.Visible, + ) + } + } +} + +@Composable +private fun ModeBlock(earnType: TextReference, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_staking_new_16), + tint = TangemTheme.colors2.markers.iconGray, + contentDescription = null, ) - SpacerW(4.dp) Text( - modifier = Modifier.alignByBaseline(), - text = symbol, - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography.body2, - maxLines = 1, - overflow = TextOverflow.Visible, + text = earnType.resolveReference(), + style = TangemTheme.typography2.captionSemibold12, + color = TangemTheme.colors2.text.neutral.tertiary, ) } } @@ -110,7 +222,7 @@ private fun TokenTitle(name: String, symbol: String, modifier: Modifier = Modifi @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun EarnListItemPreview() { +private fun EarnListItemPreviewV1() { TangemThemePreview { Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) { EarnListItem( @@ -151,4 +263,53 @@ private fun EarnListItemPreview() { ) } } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun EarnListItemPreviewV2() { + TangemThemePreviewRedesign { + Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) { + SpacerH(12.dp) + EarnListItem( + item = EarnListItemUM( + network = stringReference("Ethereum"), + symbol = stringReference("ETH"), + tokenName = stringReference("Ethereum"), + currencyIconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_eth_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + earnValue = stringReference("APY 8.50%"), + earnType = stringReference("Yield"), + onItemClick = {}, + ), + ) + SpacerH(12.dp) + EarnListItem( + item = EarnListItemUM( + network = stringReference("Ethereum"), + symbol = stringReference("USDT"), + tokenName = stringReference("Tether"), + currencyIconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_eth_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + earnValue = stringReference("APY 8.50%"), + earnType = stringReference("Yield"), + onItemClick = {}, + ), + ) + SpacerH(12.dp) + } + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnListPlaceholder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnListPlaceholder.kt index 0f988ac0bb..021838ec9a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnListPlaceholder.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnListPlaceholder.kt @@ -8,27 +8,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.CircleShimmer -import com.tangem.core.ui.components.RectangleShimmer -import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.SpacerW -import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.components.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign @Composable -internal fun EarnListPlaceholder(modifier: Modifier = Modifier, placeholderCount: Int = PLACEHOLDER_ITEMS_COUNT) { - Column( - modifier = modifier.fillMaxSize(), - ) { - repeat(placeholderCount) { - EarnItemPlaceholder() - } - } -} - -@Composable -internal fun EarnItemPlaceholder(modifier: Modifier = Modifier) { +internal fun EarnItemPlaceholderV1(modifier: Modifier = Modifier) { Row( modifier = modifier .fillMaxWidth() @@ -38,9 +24,7 @@ internal fun EarnItemPlaceholder(modifier: Modifier = Modifier) { ), verticalAlignment = Alignment.CenterVertically, ) { - CircleShimmer( - modifier = Modifier.size(36.dp), - ) + CircleShimmer(modifier = Modifier.size(36.dp)) SpacerW(12.dp) @@ -91,18 +75,86 @@ internal fun EarnItemPlaceholder(modifier: Modifier = Modifier) { } } +@Composable +internal fun EarnItemPlaceholderV2(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircleShimmer(modifier = Modifier.size(40.dp)) + + SpacerW(4.dp) + + Column(modifier = Modifier.weight(1f)) { + Row(modifier = Modifier.fillMaxWidth()) { + RectangleShimmer( + modifier = Modifier + .width(96.dp) + .height(20.dp), + radius = TangemTheme.dimens2.x25, + ) + SpacerWMax() + RectangleShimmer( + modifier = Modifier + .width(56.dp) + .height(20.dp), + radius = TangemTheme.dimens2.x25, + ) + } + + SpacerH(4.dp) + + Row(modifier = Modifier.fillMaxWidth()) { + RectangleShimmer( + modifier = Modifier + .width(46.dp) + .height(16.dp), + radius = TangemTheme.dimens2.x25, + ) + SpacerWMax() + RectangleShimmer( + modifier = Modifier + .width(46.dp) + .height(16.dp), + radius = TangemTheme.dimens2.x25, + ) + } + } + } +} + private const val PLACEHOLDER_ITEMS_COUNT = 8 @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun EarnListPlaceholderPreview() { +private fun EarnListPlaceholderPreviewV1() { TangemThemePreview { - Box( + Column( modifier = Modifier .background(TangemTheme.colors.background.tertiary), ) { - EarnListPlaceholder() + repeat(PLACEHOLDER_ITEMS_COUNT) { + EarnItemPlaceholderV1() + } + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun EarnListPlaceholderPreviewV2() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .background(TangemTheme.colors2.surface.level3), + ) { + repeat(PLACEHOLDER_ITEMS_COUNT) { + EarnItemPlaceholderV2() + } } } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/FilterButtons.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/FilterButtons.kt new file mode 100644 index 0000000000..72d3494f1f --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/FilterButtons.kt @@ -0,0 +1,115 @@ +package com.tangem.features.feed.ui.earn.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +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.SpacerWMax +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.ds.button.PrimaryInverseTangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM +import com.tangem.features.feed.ui.earn.state.EarnFilterUM + +@Composable +internal fun FilterButtons( + earnFilterUM: EarnFilterUM, + onNetworkFilterClick: () -> Unit, + onTypeFilterClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (LocalRedesignEnabled.current) { + FilterButtonsV2( + earnFilterUM = earnFilterUM, + onNetworkFilterClick = onNetworkFilterClick, + onTypeFilterClick = onTypeFilterClick, + modifier = modifier, + ) + } else { + FilterButtonsV1( + earnFilterUM = earnFilterUM, + onNetworkFilterClick = onNetworkFilterClick, + onTypeFilterClick = onTypeFilterClick, + modifier = modifier, + ) + } +} + +@Composable +private fun FilterButtonsV1( + earnFilterUM: EarnFilterUM, + onNetworkFilterClick: () -> Unit, + onTypeFilterClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(horizontal = 16.dp), + ) { + SecondarySmallButton( + config = SmallButtonConfig( + text = when (earnFilterUM.selectedNetworkFilter) { + is EarnFilterNetworkUM.AllNetworks -> TextReference.Res(R.string.earn_filter_all_networks) + is EarnFilterNetworkUM.MyNetworks -> TextReference.Res(R.string.earn_filter_my_networks) + is EarnFilterNetworkUM.Network -> TextReference.Str(earnFilterUM.selectedNetworkFilter.text) + }, + onClick = onNetworkFilterClick, + icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24), + isEnabled = earnFilterUM.isNetworkFilterEnabled, + ), + ) + + SpacerWMax() + + SecondarySmallButton( + config = SmallButtonConfig( + text = earnFilterUM.selectedTypeFilter.text, + onClick = onTypeFilterClick, + icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24), + isEnabled = earnFilterUM.isTypeFilterEnabled, + ), + ) + } +} + +@Composable +private fun FilterButtonsV2( + earnFilterUM: EarnFilterUM, + onNetworkFilterClick: () -> Unit, + onTypeFilterClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { + PrimaryInverseTangemButton( + text = when (earnFilterUM.selectedNetworkFilter) { + is EarnFilterNetworkUM.AllNetworks -> TextReference.Res(R.string.earn_filter_all_networks) + is EarnFilterNetworkUM.MyNetworks -> TextReference.Res(R.string.earn_filter_my_networks) + is EarnFilterNetworkUM.Network -> TextReference.Str(earnFilterUM.selectedNetworkFilter.text) + }, + onClick = onNetworkFilterClick, + iconRes = R.drawable.ic_chewron_down_20, + iconPosition = com.tangem.core.ui.ds.button.TangemButtonIconPosition.End, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + enabled = earnFilterUM.isNetworkFilterEnabled, + ) + + SpacerWMax() + + PrimaryInverseTangemButton( + text = earnFilterUM.selectedTypeFilter.text, + onClick = onTypeFilterClick, + iconRes = R.drawable.ic_chewron_down_20, + iconPosition = com.tangem.core.ui.ds.button.TangemButtonIconPosition.End, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + enabled = earnFilterUM.isTypeFilterEnabled, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt index 5ce51b2458..b9c88ee004 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt @@ -44,7 +44,7 @@ internal fun MostlyUsedCard(item: EarnListItemUM, onClick: () -> Unit, modifier: private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) { OpportunitiesBG( modifier = modifier - .width(148.dp) + .width(178.dp) .clip(TangemTheme.shapes.roundedCornersXMedium) .clickable(onClick = onClick), icon = TangemIconUM.Currency(item.currencyIconState), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedPlaceholder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedPlaceholder.kt index ef300fa0b0..e27c045736 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedPlaceholder.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedPlaceholder.kt @@ -2,14 +2,9 @@ package com.tangem.features.feed.ui.earn.components import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -17,27 +12,36 @@ import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign @Composable -internal fun MostlyUsedPlaceholder(modifier: Modifier = Modifier) { +internal fun MostlyUsedPlaceholder( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues( + horizontal = 16.dp, + vertical = 12.dp, + ), +) { LazyRow( modifier = modifier, - contentPadding = PaddingValues( - horizontal = 16.dp, - vertical = 12.dp, - ), + contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items(PLACEHOLDER_ITEMS_COUNT) { - MostlyUsedItemPlaceholder() + if (LocalRedesignEnabled.current) { + MostlyUsedItemPlaceholderV2() + } else { + MostlyUsedItemPlaceholderV1() + } } } } @Composable -fun MostlyUsedItemPlaceholder(modifier: Modifier = Modifier) { +private fun MostlyUsedItemPlaceholderV1(modifier: Modifier = Modifier) { Column( modifier = modifier .width(148.dp) @@ -69,13 +73,52 @@ fun MostlyUsedItemPlaceholder(modifier: Modifier = Modifier) { } } +@Composable +private fun MostlyUsedItemPlaceholderV2(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .width(178.dp) + .height(130.dp) + .background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x4), + ) + .padding(12.dp), + ) { + CircleShimmer(modifier = Modifier.size(40.dp)) + SpacerH(22.dp) + RectangleShimmer( + modifier = Modifier + .width(56.dp) + .height(20.dp), + radius = TangemTheme.dimens2.x25, + ) + SpacerH(4.dp) + RectangleShimmer( + modifier = Modifier + .width(46.dp) + .height(16.dp), + radius = TangemTheme.dimens2.x25, + ) + } +} + private const val PLACEHOLDER_ITEMS_COUNT = 3 @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun MostlyUsedPlaceholderPreview() { +private fun MostlyUsedPlaceholderPreviewV1() { TangemThemePreview { MostlyUsedPlaceholder() } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun MostlyUsedPlaceholderPreviewV2() { + TangemThemePreviewRedesign { + MostlyUsedPlaceholder() + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/BlockHeader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/BlockHeader.kt index 9fd9997c5b..a1183e5467 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/BlockHeader.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/BlockHeader.kt @@ -2,10 +2,15 @@ package com.tangem.features.feed.ui.feed.components import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.RectangleShimmer @@ -13,6 +18,9 @@ import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.TangemTheme @Composable internal fun Header( @@ -21,6 +29,7 @@ internal fun Header( shouldShowSeeAll: Boolean, title: @Composable () -> Unit, ) { + val isRedesignEnabled = LocalRedesignEnabled.current AnimatedContent(isLoading) { animatedState -> Row( modifier = Modifier @@ -30,19 +39,47 @@ internal fun Header( verticalAlignment = Alignment.CenterVertically, ) { if (animatedState) { - RectangleShimmer(modifier = Modifier.size(width = 104.dp, height = 18.dp)) + if (isRedesignEnabled) { + RectangleShimmer( + modifier = Modifier.size(width = 130.dp, height = 24.dp), + radius = TangemTheme.dimens2.x25, + ) + } else { + RectangleShimmer(modifier = Modifier.size(width = 104.dp, height = 18.dp)) + } } else { Box(modifier = Modifier.weight(1f)) { title() } SpacerW(8.dp) AnimatedVisibility(shouldShowSeeAll) { - SecondarySmallButton( - config = SmallButtonConfig( - text = TextReference.Res(R.string.common_see_all), - onClick = onSeeAllClick, - ), - ) + if (isRedesignEnabled) { + Row( + modifier = Modifier + .padding(start = 8.dp) + .clickable(onClick = onSeeAllClick), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResourceSafe(R.string.common_see_all), + color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.bodySemibold16, + ) + Icon( + modifier = Modifier.size(24.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_small_right_24), + tint = TangemTheme.colors2.markers.iconGray, + contentDescription = null, + ) + } + } else { + SecondarySmallButton( + config = SmallButtonConfig( + text = TextReference.Res(R.string.common_see_all), + onClick = onSeeAllClick, + ), + ) + } } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/EarnBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/EarnBlock.kt index d97e6bd7a3..b2984c929c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/EarnBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/EarnBlock.kt @@ -1,9 +1,9 @@ package com.tangem.features.feed.ui.feed.components import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -16,9 +16,12 @@ import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.ui.earn.components.EarnItemPlaceholderV1 import com.tangem.features.feed.ui.earn.components.EarnListItem -import com.tangem.features.feed.ui.earn.components.EarnListPlaceholder +import com.tangem.features.feed.ui.earn.components.MostlyUsedCard +import com.tangem.features.feed.ui.earn.components.MostlyUsedPlaceholder import com.tangem.features.feed.ui.earn.state.EarnListItemUM import com.tangem.features.feed.ui.earn.state.EarnListUM import kotlinx.collections.immutable.ImmutableList @@ -26,7 +29,15 @@ import kotlinx.collections.immutable.ImmutableList @Composable internal fun EarnBlock(onSeeAllClick: () -> Unit, earnListUM: EarnListUM, modifier: Modifier = Modifier) { if (earnListUM is EarnListUM.Empty) return + if (LocalRedesignEnabled.current) { + EarnBlockV2(onSeeAllClick = onSeeAllClick, earnListUM = earnListUM, modifier = modifier) + } else { + EarnBlockV1(onSeeAllClick = onSeeAllClick, earnListUM = earnListUM, modifier = modifier) + } +} +@Composable +private fun EarnBlockV1(onSeeAllClick: () -> Unit, earnListUM: EarnListUM, modifier: Modifier = Modifier) { Column(modifier = modifier) { Header( title = { @@ -56,7 +67,11 @@ internal fun EarnBlock(onSeeAllClick: () -> Unit, earnListUM: EarnListUM, modifi when (earnListUM) { is EarnListUM.Content -> EarnContentBlock(items = earnListUM.items) is EarnListUM.Error -> EarnErrorBlock(onRetryClick = earnListUM.onRetryClicked) - EarnListUM.Loading -> EarnListPlaceholder(placeholderCount = PLACEHOLDER_ITEM_COUNT) + EarnListUM.Loading -> { + repeat(PLACEHOLDER_ITEM_COUNT) { + EarnItemPlaceholderV1() + } + } EarnListUM.Empty -> Unit } } @@ -65,6 +80,55 @@ internal fun EarnBlock(onSeeAllClick: () -> Unit, earnListUM: EarnListUM, modifi } } +@Composable +private fun EarnBlockV2(onSeeAllClick: () -> Unit, earnListUM: EarnListUM, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Header( + title = { + Text( + text = stringResourceSafe(R.string.markets_earn_common_title), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + }, + onSeeAllClick = onSeeAllClick, + isLoading = earnListUM is EarnListUM.Loading, + shouldShowSeeAll = earnListUM is EarnListUM.Content, + ) + + SpacerH(12.dp) + AnimatedContent( + targetState = earnListUM, + contentKey = { it::class.java }, + ) { earnListUM -> + when (earnListUM) { + is EarnListUM.Content -> EarnContentBlock(items = earnListUM.items) + is EarnListUM.Error -> { + BlockCard( + modifier = Modifier.padding(horizontal = 16.dp), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors2.surface.level3), + ) { + EarnErrorBlock(onRetryClick = earnListUM.onRetryClicked) + } + } + EarnListUM.Loading -> { + MostlyUsedPlaceholder( + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + bottom = 8.dp, + ), + ) + } + EarnListUM.Empty -> Unit + } + } + SpacerH(32.dp) + } +} + @Composable private fun EarnErrorBlock(onRetryClick: () -> Unit) { UnableToLoadData( @@ -77,9 +141,30 @@ private fun EarnErrorBlock(onRetryClick: () -> Unit) { @Composable private fun EarnContentBlock(items: ImmutableList) { - Column(modifier = Modifier.fillMaxWidth()) { - items.fastForEach { item -> - EarnListItem(item = item) + if (LocalRedesignEnabled.current) { + LazyRow( + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + bottom = 8.dp, + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = items, + key = { item -> "${item.tokenName}-${item.network}" }, + ) { item -> + MostlyUsedCard( + item = item, + onClick = item.onItemClick, + ) + } + } + } else { + Column(modifier = Modifier.fillMaxWidth()) { + items.fastForEach { item -> + EarnListItem(item = item) + } } } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index 0d552f2757..7ca5c1dbf1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -304,7 +304,7 @@ private inline fun BaseScaffoldWithMarkets( val peekHeight = bottomSheetHeaderHeightProvider() + TangemTheme.dimens2.x3 + bottomBarHeight val coroutineScope = rememberCoroutineScope() - val background = TangemTheme.colors2.surface.level3 + val background = TangemTheme.colors2.surface.level2 val bottomSheetState = rememberTangemStandardBottomSheetState() val scaffoldState = rememberTangemBottomSheetScaffoldState(bottomSheetState = bottomSheetState) From d84315a40c689658aefcbf3bec71bc7a9f5e9c1e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 11 Mar 2026 11:11:53 +0400 Subject: [PATCH 24/60] Updated on 2026-08-14 --- .../tap/di/domain/TokensDomainModule.kt | 12 - .../tokens/GetNetworkCoinStatusUseCase.kt | 74 ------ .../BaseCurrencyStatusOperations.kt | 227 +----------------- .../model/WcSendTransactionModel.kt | 21 +- 4 files changed, 12 insertions(+), 322 deletions(-) delete mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetNetworkCoinStatusUseCase.kt 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 62da11c64c..c389af965c 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 @@ -100,18 +100,6 @@ internal object TokensDomainModule { ) } - @Provides - @Singleton - fun provideGetCurrencyStatusByNetworkUseCase( - currencyStatusOperations: BaseCurrencyStatusOperations, - dispatchers: CoroutineDispatcherProvider, - ): GetNetworkCoinStatusUseCase { - return GetNetworkCoinStatusUseCase( - currencyStatusOperations = currencyStatusOperations, - dispatchers = dispatchers, - ) - } - @Provides @Singleton fun provideGetFeePaidCryptoCurrencyStatusSyncUseCase( diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetNetworkCoinStatusUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetNetworkCoinStatusUseCase.kt deleted file mode 100644 index 89a9baade6..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetNetworkCoinStatusUseCase.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.tangem.domain.tokens - -import arrow.core.Either -import com.tangem.domain.card.common.util.cardTypesResolver -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.isMultiCurrency -import com.tangem.domain.tokens.error.CurrencyStatusError -import com.tangem.domain.tokens.error.mapper.mapToCurrencyError -import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations -import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.* - -class GetNetworkCoinStatusUseCase( - private val currencyStatusOperations: BaseCurrencyStatusOperations, - private val dispatchers: CoroutineDispatcherProvider, -) { - - operator fun invoke( - userWalletId: UserWalletId, - networkId: Network.ID, - derivationPath: Network.DerivationPath, - isSingleWalletWithTokens: Boolean, - ): Flow> { - return flow { - emitAll( - flow = getCurrency( - userWalletId = userWalletId, - networkId = networkId, - derivationPath = derivationPath, - isSingleWalletWithTokens = isSingleWalletWithTokens, - ), - ) - } - .flowOn(dispatchers.io) - } - - suspend fun invokeSync( - userWallet: UserWallet, - networkId: Network.ID, - derivationPath: Network.DerivationPath, - ): Either { - val userWalletId = userWallet.walletId - - val maybeCurrency = if (userWallet.isMultiCurrency) { - currencyStatusOperations.getNetworkCoinSync(userWalletId, networkId, derivationPath) - } else if (userWallet is UserWallet.Cold && userWallet.cardTypesResolver.isSingleWalletWithToken()) { - currencyStatusOperations.getNetworkCoinForSingleWalletWithTokenSync(userWalletId, networkId) - } else { - currencyStatusOperations.getPrimaryCurrencyStatusSync(userWalletId) - } - - return maybeCurrency.mapLeft(CurrenciesStatusesOperations.Error::mapToCurrencyError) - } - - private suspend fun getCurrency( - userWalletId: UserWalletId, - networkId: Network.ID, - derivationPath: Network.DerivationPath, - isSingleWalletWithTokens: Boolean, - ): Flow> { - val networkFlow = if (isSingleWalletWithTokens) { - currencyStatusOperations.getNetworkCoinForSingleWalletWithTokenFlow(userWalletId, networkId) - } else { - currencyStatusOperations.getNetworkCoinFlow(userWalletId, networkId, derivationPath) - } - return networkFlow.map { maybeCurrency -> - maybeCurrency.mapLeft(CurrenciesStatusesOperations.Error::mapToCurrencyError) - } - } -} \ 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 117b907f78..a443a459eb 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 @@ -2,15 +2,10 @@ package com.tangem.domain.tokens.operations import arrow.core.* import arrow.core.raise.* -import com.tangem.blockchainsdk.utils.toBlockchain -import com.tangem.domain.core.utils.EitherFlow 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.quote.QuoteStatus import com.tangem.domain.models.staking.StakingBalance -import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.multi.MultiNetworkStatusProducer import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier @@ -20,7 +15,6 @@ import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.quotes.single.SingleQuoteStatusProducer import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.staking.StakingIdFactory -import com.tangem.domain.staking.model.isStakingSupported import com.tangem.domain.staking.multi.MultiStakingBalanceProducer import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier import com.tangem.domain.staking.single.SingleStakingBalanceProducer @@ -30,7 +24,7 @@ import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.utils.CurrencyStatusProxyCreator -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.firstOrNull /** * Base operations for working with currency status @@ -54,112 +48,6 @@ class BaseCurrencyStatusOperations( private val currencyStatusProxyCreator = CurrencyStatusProxyCreator() - suspend fun getCurrencyStatusFlow( - userWalletId: UserWalletId, - currencyId: CryptoCurrency.ID, - isSingleWalletWithTokens: Boolean, - ): Flow> { - val currency = recover( - block = { - if (isSingleWalletWithTokens) { - getSingleCurrencyWalletWithCardTokensCurrency(userWalletId, currencyId) - } else { - getMultiCurrencyWalletCurrency(userWalletId, currencyId) - } - }, - recover = { return flowOf(it.left()) }, - ) - - return getCurrencyStatusFlow(userWalletId = userWalletId, currency = currency) - } - - suspend fun getCurrencyStatusFlow( - userWalletId: UserWalletId, - currency: CryptoCurrency, - includeQuotes: Boolean = true, - subscribeOnStakingBalance: Boolean = true, - ): Flow> { - val rawCurrencyId = currency.id.rawCurrencyId - - val quoteFlow = if (includeQuotes && rawCurrencyId != null) { - getQuotes(rawCurrencyId) - .map { maybeQuotes -> - maybeQuotes.flatMap { quotes -> - quotes.singleOrNull { it.rawCurrencyId == currency.id.rawCurrencyId }?.right() - ?: Error.EmptyQuotes.left() - } - } - } else { - // don't use emptyFlow() - flow { emit(Error.EmptyQuotes.left()) } - } - - val statusFlow = getNetworkStatus(userWalletId = userWalletId, network = currency.network) - - val isStakingSupported = currency.network.toBlockchain().isStakingSupported - - val stakingBalanceFlow = if (isStakingSupported) { - val stakingId = stakingIdFactory.create( - userWalletId = userWalletId, - currencyId = currency.id, - network = currency.network, - ) - .getOrNull() - - stakingId?.let { - getStakingBalance(userWalletId = userWalletId, stakingId = it) - } - } else { - null - } - - return if (subscribeOnStakingBalance && stakingBalanceFlow != null) { - combine(quoteFlow, statusFlow, stakingBalanceFlow) { maybeQuote, maybeNetworkStatus, maybeStakingBalance -> - currencyStatusProxyCreator.createCurrencyStatus( - currency = currency, - maybeQuoteStatus = maybeQuote, - maybeNetworkStatus = maybeNetworkStatus, - maybeStakingBalance = maybeStakingBalance, - ) - } - } else { - combine(quoteFlow, statusFlow) { maybeQuote, maybeNetworkStatus -> - currencyStatusProxyCreator.createCurrencyStatus( - currency = currency, - maybeQuoteStatus = maybeQuote, - maybeNetworkStatus = maybeNetworkStatus, - maybeStakingBalance = null, - ) - } - } - } - - suspend fun getNetworkCoinFlow( - userWalletId: UserWalletId, - networkId: Network.ID, - derivationPath: Network.DerivationPath, - includeQuotes: Boolean = true, - ): Flow> { - val currency = recover( - block = { getNetworkCoin(userWalletId, networkId, derivationPath) }, - recover = { return flowOf(it.left()) }, - ) - - return getCurrencyStatusFlow(userWalletId, currency, includeQuotes) - } - - suspend fun getNetworkCoinForSingleWalletWithTokenFlow( - userWalletId: UserWalletId, - networkId: Network.ID, - ): Flow> { - val currency = recover( - block = { getNetworkCoinForSingleWalletWithToken(userWalletId, networkId) }, - recover = { return flowOf(it.left()) }, - ) - - return getCurrencyStatusFlow(userWalletId, currency) - } - suspend fun getNetworkCoinSync( userWalletId: UserWalletId, networkId: Network.ID, @@ -217,32 +105,6 @@ class BaseCurrencyStatusOperations( } } - suspend fun getNetworkCoinForSingleWalletWithTokenSync( - userWalletId: UserWalletId, - networkId: Network.ID, - ): Either = either { - val currency = getNetworkCoinForSingleWalletWithToken(userWalletId, networkId) - - return getCurrencyStatusSync(userWalletId, currency.id) - } - - suspend fun getPrimaryCurrencyStatusFlow( - userWalletId: UserWalletId, - includeQuotes: Boolean = true, - ): Flow> { - val currency = recover( - block = { getPrimaryCurrency(userWalletId) }, - recover = { return flowOf(it.left()) }, - ) - - return getCurrencyStatusFlow( - userWalletId = userWalletId, - currency = currency, - includeQuotes = includeQuotes, - subscribeOnStakingBalance = false, - ) - } - suspend fun getCurrenciesStatusesSync(userWalletId: UserWalletId): Either> { return either { catch( @@ -279,35 +141,6 @@ class BaseCurrencyStatusOperations( } } - suspend fun getPrimaryCurrencyStatusSync(userWalletId: UserWalletId): Either = either { - val currency = catch( - block = { currenciesRepository.getSingleCurrencyWalletPrimaryCurrency(userWalletId) }, - catch = { raise(Error.DataError(it)) }, - ) - - val quotes = currency.id.rawCurrencyId?.let { - singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(rawCurrencyId = it)) - .firstOrNull() - } - ?.right() - ?: Error.EmptyQuotes.left() - - val networkStatus = singleNetworkStatusSupplier( - params = SingleNetworkStatusProducer.Params(userWalletId = userWalletId, network = currency.network), - ) - .firstOrNull() - .right() - - val stakingBalances = getStakingBalanceSync(userWalletId, currency) - - return currencyStatusProxyCreator.createCurrencyStatus( - currency = currency, - maybeQuoteStatus = quotes, - maybeNetworkStatus = networkStatus, - maybeStakingBalance = stakingBalances, - ) - } - private suspend fun Raise.getMultiCurrencyWalletCurrency( userWalletId: UserWalletId, currencyId: CryptoCurrency.ID, @@ -323,36 +156,6 @@ class BaseCurrencyStatusOperations( .bind() } - private suspend fun Raise.getSingleCurrencyWalletWithCardTokensCurrency( - userWalletId: UserWalletId, - currencyId: CryptoCurrency.ID, - ): CryptoCurrency { - return Either.catch { currenciesRepository.getSingleCurrencyWalletWithCardCurrency(userWalletId, currencyId) } - .mapLeft { Error.DataError(it) } - .bind() - } - - private fun getStakingBalance(userWalletId: UserWalletId, stakingId: StakingID): EitherFlow { - return singleStakingBalanceSupplier( - params = SingleStakingBalanceProducer.Params( - userWalletId = userWalletId, - stakingId = stakingId, - ), - ) - .map> { it.right() } - .catch { emit(Error.DataError(it).left()) } - .onEmpty { emit(Error.EmptyStakingBalances.left()) } - } - - private fun getNetworkStatus(userWalletId: UserWalletId, network: Network): EitherFlow { - return singleNetworkStatusSupplier( - params = SingleNetworkStatusProducer.Params(userWalletId = userWalletId, network = network), - ) - .map>(NetworkStatus::right) - .distinctUntilChanged() - .onEmpty { emit(Error.EmptyNetworksStatuses.left()) } - } - private suspend fun Raise.getNetworkCoin( userWalletId: UserWalletId, networkId: Network.ID, @@ -370,27 +173,6 @@ class BaseCurrencyStatusOperations( .bind() } - private suspend fun Raise.getNetworkCoinForSingleWalletWithToken( - userWalletId: UserWalletId, - networkId: Network.ID, - ): CryptoCurrency { - return Either.catch { - currenciesRepository.getSingleCurrencyWalletWithCardCurrencies(userWalletId) - .find { it.network.id == networkId && it is CryptoCurrency.Coin } - ?: raise(Error.DataError(IllegalStateException("Coin with network $networkId not found for this card"))) - } - .mapLeft { Error.DataError(it) } - .bind() - } - - private fun getQuotes(id: CryptoCurrency.RawID): Flow>> { - return singleQuoteStatusSupplier( - params = SingleQuoteStatusProducer.Params(rawCurrencyId = id), - ) - .map>> { setOf(it).right() } - .distinctUntilChanged() - } - private suspend fun getStakingBalancesSync( userWalletId: UserWalletId, cryptoCurrencies: List, @@ -434,13 +216,6 @@ class BaseCurrencyStatusOperations( ensureNotNull(yieldBalance) { Error.EmptyStakingBalances } } - private suspend fun Raise.getPrimaryCurrency(userWalletId: UserWalletId): CryptoCurrency { - return catch( - block = { currenciesRepository.getSingleCurrencyWalletPrimaryCurrency(userWalletId) }, - catch = { raise(Error.DataError(it)) }, - ) - } - private fun getIds(currencies: List): Pair, NonEmptySet> { val currencyIdToNetworkId = currencies.associate { currency -> currency.id to currency.network 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 493b5158d4..5153c887a2 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 @@ -2,6 +2,8 @@ package com.tangem.features.walletconnect.transaction.model import androidx.compose.runtime.Stable import arrow.core.Either +import arrow.core.Option +import arrow.core.none import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.pushNew @@ -22,12 +24,12 @@ import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2.Icon.Type import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCoinStatus import com.tangem.domain.core.lce.Lce 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.tokens.GetNetworkCoinStatusUseCase -import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.SendTransactionError.UserCancelledError import com.tangem.domain.transaction.usecase.GetFeeUseCase @@ -79,7 +81,7 @@ internal class WcSendTransactionModel @Inject constructor( private val useCaseFactory: WcRequestUseCaseFactory, private val converter: WcSendTransactionUMConverter, private val getFeeUseCase: GetFeeUseCase, - private val getNetworkCoinUseCase: GetNetworkCoinStatusUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val notificationsFactory: WcNotificationsFactory, private val analytics: AnalyticsEventHandler, private val urlOpener: UrlOpener, @@ -121,7 +123,7 @@ internal class WcSendTransactionModel @Inject constructor( -> { this@WcSendTransactionModel.cryptoCurrencyStatus = getCryptoCurrencyStatus(userWallet = useCase.wallet, network = useCase.network) - .onLeft { unknownMethodRunnable() } + .onNone { unknownMethodRunnable() } .getOrNull() ?: return@launch this@WcSendTransactionModel.useCase = useCase (useCase as? WcMutableFee) @@ -233,12 +235,11 @@ internal class WcSendTransactionModel @Inject constructor( private suspend fun getCryptoCurrencyStatus( userWallet: UserWallet, network: Network, - ): Either { - return getNetworkCoinUseCase.invokeSync( - userWallet = userWallet, - networkId = network.id, - derivationPath = network.derivationPath, - ) + ): Option { + val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull(userWallet.walletId) + ?: return none() + + return accountStatusList.getCoinStatus(network) } private suspend fun buildUiState( From 871c8348a393e02785667136b41ef712d58e834f Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Mar 2026 08:51:56 +0100 Subject: [PATCH 25/60] Updated on 2026-08-14 --- .../main/res/drawable/ic_star_filled_20.xml | 10 ++ .../detailed/MarketsTokenDetailsContent.kt | 3 + .../components/InformationTextBlock.kt | 87 +++++++++++ .../detailed/components/ScoreStarsBlock.kt | 96 +++++++++++- .../detailed/components/SecurityScoreBlock.kt | 143 ++++++++++++++++-- .../components/TokenMarketDetailsBody.kt | 35 ++++- 6 files changed, 351 insertions(+), 23 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_star_filled_20.xml create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt diff --git a/core/ui/src/main/res/drawable/ic_star_filled_20.xml b/core/ui/src/main/res/drawable/ic_star_filled_20.xml new file mode 100644 index 0000000000..891f2755c5 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_star_filled_20.xml @@ -0,0 +1,10 @@ + + + diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt index 67cc815484..eb0862ce2e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt @@ -37,6 +37,7 @@ import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.markets.PriceChangeInterval @@ -81,6 +82,7 @@ private fun Content( modifier: Modifier = Modifier, portfolioBlock: @Composable ((Modifier) -> Unit)?, ) { + val isRedesignEnabled = LocalRedesignEnabled.current val density = LocalDensity.current val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } val lazyListState = rememberLazyListState() @@ -133,6 +135,7 @@ private fun Content( state = state.body, portfolioBlock = portfolioBlock, relatedNews = state.relatedNews, + isRedesignEnabled = isRedesignEnabled, ) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt new file mode 100644 index 0000000000..c0d6ca107e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt @@ -0,0 +1,87 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun InformationTextBlock( + text: TextReference, + onInfoClick: () -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle = TangemTheme.typography2.captionSemibold12, + textColor: Color = TangemTheme.colors2.text.neutral.secondary, + informationTextBlockIconPosition: InformationTextBlockIconPosition = InformationTextBlockIconPosition.START, +) { + val interactionSource = remember { MutableInteractionSource() } + + val infoIcon: @Composable () -> Unit = { + IconButton( + modifier = Modifier.requiredSize(TangemTheme.dimens2.x4), + interactionSource = interactionSource, + onClick = onInfoClick, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x4), + imageVector = ImageVector.vectorResource(id = R.drawable.ic_information_24), + tint = TangemTheme.colors2.markers.iconGray, + contentDescription = null, + ) + } + } + + val contentText: @Composable () -> Unit = { + Text( + text = text.resolveReference(), + style = textStyle, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Row( + modifier = modifier + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onInfoClick, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + when (informationTextBlockIconPosition) { + InformationTextBlockIconPosition.START -> { + infoIcon() + contentText() + } + InformationTextBlockIconPosition.END -> { + contentText() + infoIcon() + } + } + } +} + +internal enum class InformationTextBlockIconPosition { + START, END +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ScoreStarsBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ScoreStarsBlock.kt index 6190849b99..9243c0a91a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ScoreStarsBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ScoreStarsBlock.kt @@ -1,11 +1,7 @@ package com.tangem.features.feed.ui.market.detailed.components import androidx.annotation.FloatRange -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -22,6 +18,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.features.feed.impl.R import kotlin.math.round @@ -34,6 +31,28 @@ internal fun ScoreStarsBlock( horizontalSpacing: Dp, scoreTextStyle: TextStyle, modifier: Modifier = Modifier, +) { + if (LocalRedesignEnabled.current) { + ScoreStarsBlockV2( + score = score, + modifier = modifier, + ) + } else { + ScoreStarsBlockV1( + score = score, + horizontalSpacing = horizontalSpacing, + scoreTextStyle = scoreTextStyle, + modifier = modifier, + ) + } +} + +@Composable +private fun ScoreStarsBlockV1( + score: Float, + horizontalSpacing: Dp, + scoreTextStyle: TextStyle, + modifier: Modifier = Modifier, ) { val rounded = score.roundTo1decimal() val percentage = rounded / STARS_COUNT @@ -51,16 +70,38 @@ internal fun ScoreStarsBlock( } } +@Composable +private fun ScoreStarsBlockV2(score: Float, modifier: Modifier = Modifier) { + val rounded = score.roundTo1decimal() + val percentage = rounded / STARS_COUNT + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Stars(fraction = percentage) + } +} + @Suppress("MagicNumber") @Composable private fun Stars(@FloatRange(0.0, 1.0) fraction: Float = 0f) { + if (LocalRedesignEnabled.current) { + StarsV2(fraction) + } else { + StarsV1(fraction) + } +} + +@Suppress("MagicNumber") +@Composable +private fun StarsV1(@FloatRange(0.0, 1.0) fraction: Float = 0f) { val grayColor = TangemTheme.colors.icon.inactive Row( horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), verticalAlignment = Alignment.CenterVertically, ) { - repeat(times = 5) { i -> + repeat(times = STARS_COUNT) { i -> Box( modifier = Modifier.size(TangemTheme.dimens.size16), contentAlignment = Alignment.Center, @@ -94,6 +135,49 @@ private fun Stars(@FloatRange(0.0, 1.0) fraction: Float = 0f) { } } +@Suppress("MagicNumber") +@Composable +private fun StarsV2(@FloatRange(0.0, 1.0) fraction: Float = 0f) { + val grayColor = TangemTheme.colors2.markers.iconGray + + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(times = STARS_COUNT) { i -> + Box( + modifier = Modifier.size(TangemTheme.dimens2.x5), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier + .requiredSize(16.dp) + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithCache { + onDrawWithContent { + val starFraction = ((fraction - i * 0.2) / 0.2).coerceIn(0.0, 1.0) + val starFractionFloat = starFraction + .toFloat() + .roundTo1decimal() + + drawContent() + drawRect( + color = grayColor, + topLeft = Offset(x = size.width * starFractionFloat, y = 0f), + size = Size(size.width * (1 - starFractionFloat), size.height), + blendMode = BlendMode.SrcIn, + ) + } + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_star_filled_20), + contentDescription = null, + tint = TangemTheme.colors2.markers.iconBlue, + ) + } + } + } +} + @Suppress("MagicNumber") private fun Float.roundTo1decimal(): Float { return round(this * 10) / 10 diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt index 1fe9bbc7fa..ec4c86a487 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt @@ -2,33 +2,44 @@ package com.tangem.features.feed.ui.market.detailed.components import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.layoutId import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.text.TooltipText +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.utils.PreviewShimmerContainer import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreUM @Composable internal fun SecurityScoreBlock(state: SecurityScoreUM, modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + SecurityScoreBlockV2(state, modifier) + } else { + SecurityScoreBlockV1(state, modifier) + } +} + +@Composable +private fun SecurityScoreBlockV1(state: SecurityScoreUM, modifier: Modifier = Modifier) { Row( modifier = modifier .clip(TangemTheme.shapes.roundedCornersXMedium) @@ -66,8 +77,92 @@ internal fun SecurityScoreBlock(state: SecurityScoreUM, modifier: Modifier = Mod } } +@Composable +private fun SecurityScoreBlockV2(state: SecurityScoreUM, modifier: Modifier = Modifier) { + TangemRowContainer(modifier = modifier) { + Text( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), + text = "${state.score}", + color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.headingBold28, + ) + + InformationTextBlock( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_BOTTOM), + text = resourceReference(R.string.markets_token_details_security_score), + onInfoClick = state.onInfoClick, + textColor = TangemTheme.colors2.text.neutral.primary, + informationTextBlockIconPosition = InformationTextBlockIconPosition.END, + ) + + Text( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM), + text = state.description.resolveReference(), + style = TangemTheme.typography2.captionSemibold12, + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + ScoreStarsBlock( + modifier = Modifier + .padding(bottom = 16.dp) + .layoutId(layoutId = TangemRowLayoutId.END_TOP), + score = state.score, + scoreTextStyle = TangemTheme.typography.body1, + horizontalSpacing = TangemTheme.dimens.spacing8, + ) + } +} + @Composable internal fun SecurityScoreBlockPlaceholder(modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + SecurityScoreBlockPlaceholderV2(modifier) + } else { + SecurityScoreBlockPlaceholderV1(modifier) + } +} + +@Composable +private fun SecurityScoreBlockPlaceholderV2(modifier: Modifier = Modifier) { + TangemRowContainer(modifier = modifier) { + TextShimmer( + modifier = Modifier + .width(74.dp) + .layoutId(layoutId = TangemRowLayoutId.START_TOP), + style = TangemTheme.typography2.headingBold28, + radius = TangemTheme.dimens2.x25, + ) + + TextShimmer( + modifier = Modifier + .width(96.dp) + .layoutId(layoutId = TangemRowLayoutId.START_BOTTOM), + style = TangemTheme.typography2.captionSemibold12, + radius = TangemTheme.dimens2.x25, + ) + + TextShimmer( + modifier = Modifier + .width(120.dp) + .layoutId(layoutId = TangemRowLayoutId.END_TOP), + style = TangemTheme.typography2.headingBold28, + radius = TangemTheme.dimens2.x25, + ) + + TextShimmer( + modifier = Modifier + .width(72.dp) + .layoutId(layoutId = TangemRowLayoutId.END_BOTTOM), + style = TangemTheme.typography2.captionSemibold12, + radius = TangemTheme.dimens2.x25, + ) + } +} + +@Composable +private fun SecurityScoreBlockPlaceholderV1(modifier: Modifier = Modifier) { Row( modifier = modifier .clip(TangemTheme.shapes.roundedCornersXMedium) @@ -109,7 +204,7 @@ internal fun SecurityScoreBlockPlaceholder(modifier: Modifier = Modifier) { @Preview(widthDp = 328, showBackground = true, locale = "ru") @Preview(widthDp = 328, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun ContentPreview() { +private fun ContentPreviewV1() { TangemThemePreview { SecurityScoreBlock( state = SecurityScoreUM( @@ -124,7 +219,7 @@ private fun ContentPreview() { @Preview(widthDp = 328, showBackground = true) @Preview(widthDp = 328, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun PreviewPlaceholder() { +private fun PreviewPlaceholderV1() { TangemThemePreview { PreviewShimmerContainer( shimmerContent = { @@ -133,8 +228,36 @@ private fun PreviewPlaceholder() { ) }, actualContent = { - ContentPreview() + ContentPreviewV1() }, ) } +} + +@Preview(widthDp = 328, showBackground = true) +@Preview(widthDp = 328, showBackground = true, locale = "ru") +@Preview(widthDp = 328, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreviewV2() { + CompositionLocalProvider( + LocalRedesignEnabled provides true, + ) { + TangemThemePreviewRedesign { + Column(modifier = Modifier.background(TangemTheme.colors2.surface.level2)) { + SecurityScoreBlock( + state = SecurityScoreUM( + score = 3.5f, + description = stringReference("Based on 3 ratings"), + onInfoClick = {}, + ), + ) + + SpacerH(10.dp) + + SecurityScoreBlockPlaceholder( + modifier = Modifier.fillMaxWidth(), + ) + } + } + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt index a0457c58ec..4abff26fa9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -22,6 +22,7 @@ import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM.R @Suppress("CanBeNonNullable") internal fun LazyListScope.tokenMarketDetailsBody( + isRedesignEnabled: Boolean, state: MarketsTokenDetailsUM.Body, portfolioBlock: @Composable ((Modifier) -> Unit)?, relatedNews: RelatedNews, @@ -40,7 +41,7 @@ internal fun LazyListScope.tokenMarketDetailsBody( aboutCoinHeader() - loadingInfoBlocks() + loadingInfoBlocks(isRedesignEnabled) } is MarketsTokenDetailsUM.Body.Content -> { if (state.description != null) { @@ -59,7 +60,10 @@ internal fun LazyListScope.tokenMarketDetailsBody( aboutCoinHeader() - infoBlocksList(state.infoBlocks) + infoBlocksList( + state = state.infoBlocks, + isRedesignEnabled = isRedesignEnabled, + ) } is MarketsTokenDetailsUM.Body.Error -> { error(state) @@ -112,7 +116,7 @@ private fun LazyListScope.description(description: MarketsTokenDetailsUM.Descrip } } -internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.InformationBlocks) { +internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.InformationBlocks, isRedesignEnabled: Boolean) { if (state.insights != null) { item("insights") { InsightsBlock( @@ -122,7 +126,7 @@ internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.Informati } } - if (state.securityScore != null) { + if (state.securityScore != null && !isRedesignEnabled) { item("securityScore") { SecurityScoreBlock( modifier = Modifier.blockPaddings(), @@ -156,6 +160,15 @@ internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.Informati ) } + if (state.securityScore != null && isRedesignEnabled) { + item("securityScore") { + SecurityScoreBlock( + modifier = Modifier.blockPaddings(), + state = state.securityScore, + ) + } + } + if (state.links != null) { item("links") { LinksBlock( @@ -166,15 +179,17 @@ internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.Informati } } -private fun LazyListScope.loadingInfoBlocks() { +private fun LazyListScope.loadingInfoBlocks(isRedesignEnabled: Boolean) { item("insights-loading") { InsightsBlockPlaceholder( modifier = Modifier.blockPaddings(), ) } - item("securityScore-loading") { - SecurityScoreBlockPlaceholder(modifier = Modifier.blockPaddings()) + if (!isRedesignEnabled) { + item("securityScore-loading") { + SecurityScoreBlockPlaceholder(modifier = Modifier.blockPaddings()) + } } item("metrics-loading") { @@ -189,6 +204,12 @@ private fun LazyListScope.loadingInfoBlocks() { ListedOnBlockPlaceholder(modifier = Modifier.blockPaddings()) } + if (isRedesignEnabled) { + item("securityScore-loading") { + SecurityScoreBlockPlaceholder(modifier = Modifier.blockPaddings()) + } + } + item("links-loading") { LinksBlockPlaceholder(modifier = Modifier.blockPaddings()) } From 6588ff4fbc0ef478398acedb261964285bfbf291 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Mar 2026 11:28:18 +0300 Subject: [PATCH 26/60] Updated on 2026-08-14 --- .../com/tangem/common/extensions/KNode.kt | 24 +++++++++ .../tangem/common/extensions/UiDeviceExt.kt | 25 +++++++++ .../tangem/screens/SendAddressPageObject.kt | 7 ++- .../com/tangem/screens/TopBarPageObject.kt | 1 + .../kotlin/com/tangem/tests/FeedbackTest.kt | 24 +++++++-- .../tests/balance/TotalBalanceUpdateTest.kt | 2 +- .../addressScreen/SendAddressScreenTest.kt | 54 ++++++++++++++++--- .../confirmScreen/SendConfirmScreenTest.kt | 19 ++++--- .../com/tangem/tests/swap/SwapStoriesTest.kt | 6 ++- .../tangem/tests/swap/SwapTokenScreenTest.kt | 3 -- .../components/inputrow/InputRowRecipient.kt | 3 +- .../core/ui/test/SendAddressScreenTestTags.kt | 2 + .../destination/ui/SendDestinationContent.kt | 1 + .../ui/components/common/WalletTopBar.kt | 1 + 14 files changed, 144 insertions(+), 28 deletions(-) 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 b91b4767eb..3ce482fb53 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt @@ -1,6 +1,8 @@ package com.tangem.common.extensions +import androidx.compose.ui.test.ComposeTimeoutException import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule import io.github.kakaocup.compose.node.element.KNode fun KNode.clickWithAssertion() { @@ -34,3 +36,25 @@ fun KNode.assertVisibility(shouldBeDisplayed: Boolean) { assertIsNotDisplayed() } } + +fun KNode.clickAndWaitFor( + rule: ComposeTestRule, + timeoutMs: Long = 5_000, + maxRetries: Int = 3, + expectedCondition: () -> Unit, +) { + for (attempt in 1..maxRetries) { + performClick() + rule.waitForIdle() + + try { + rule.waitUntil(timeoutMs) { + runCatching { expectedCondition() }.isSuccess + } + return + } catch (_: ComposeTimeoutException) { + } + } + + throw AssertionError("Condition not met after $maxRetries click attempts") +} diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt index 03dcf422f5..2ec823d1e8 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt @@ -1,6 +1,7 @@ package com.tangem.common.extensions import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.wallet.R @@ -46,7 +47,23 @@ fun BaseTestCase.swipeMarketsBlock(direction: SwipeDirection) { } fun BaseTestCase.openTheAppFromRecents() { + device.uiDevice.waitForIdle() + device.uiDevice.pressRecentApps() + device.uiDevice.waitForIdle() + + val recentsOpened = device.uiDevice.wait( + Until.hasObject(By.res("com.android.launcher3:id/snapshot")), + 3_000 + ) + + if (!recentsOpened) { + device.uiDevice.pressRecentApps() + device.uiDevice.wait( + Until.hasObject(By.res("com.android.launcher3:id/snapshot")), + 3_000 + ) + } val centerX = device.uiDevice.displayWidth / 2 val centerY = device.uiDevice.displayHeight / 3 @@ -54,6 +71,14 @@ fun BaseTestCase.openTheAppFromRecents() { device.uiDevice.click(centerX, centerY) } +fun BaseTestCase.collapseAppByHomeButton() { + device.uiDevice.pressHome() + device.uiDevice.wait( + Until.hasObject(By.pkg(device.uiDevice.launcherPackageName)), + 3_000 + ) +} + fun BaseTestCase.disableWiFi() { device.uiDevice.executeShellCommand("svc wifi disable") } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt index 0458682ee0..b963fa0d14 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt @@ -23,6 +23,11 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider useUnmergedTree = true } + val addressesShimmer: KNode = child { + hasTestTag(SendAddressScreenTestTags.ADDRESSES_SHIMMER) + useUnmergedTree = true + } + val topAppBarTitle: KNode = child { hasTestTag(TopAppBarTestTags.TITLE) hasText(getResourceString(CoreUiR.string.common_address)) @@ -62,7 +67,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider } val clearTextFieldButton: KNode = child { - hasContentDescription(getResourceString(CoreUiR.string.common_close)) + hasTestTag(SendAddressScreenTestTags.CROSS_ICON) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TopBarPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TopBarPageObject.kt index e18c510849..cb347bb51b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TopBarPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TopBarPageObject.kt @@ -14,6 +14,7 @@ class TopBarPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ) { val moreButton: KNode = child { hasTestTag(MainScreenTestTags.MORE_BUTTON) + useUnmergedTree = true } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt index 624ea971b6..f6d9837a42 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -5,6 +5,7 @@ import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.core.TangemSdkError +import com.tangem.common.extensions.clickAndWaitFor import com.tangem.common.extensions.clickWithAssertion import com.tangem.domain.redux.StateDialog import com.tangem.scenarios.checkFailedTransactionDialog @@ -46,6 +47,7 @@ class FeedbackTest : BaseTestCase() { onTopBar { moreButton.clickWithAssertion() } } step("Click 'Contact support' button") { + waitForIdle() onDetailsScreen { contactSupportButton.clickWithAssertion() } } step("Assert 'Gmail' app is open") { @@ -96,17 +98,29 @@ class FeedbackTest : BaseTestCase() { step("Enter address") { onSendAddressScreen { addressTextField.performTextInput(recipientAddress) } } - step("Click 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } + step("Assert text field contains text: $recipientAddress") { + onSendAddressScreen { addressTextField.assertTextEquals(recipientAddress) } } - step("Assert sеnding text is displayed") { - onSendConfirmScreen { sendingText.assertIsDisplayed() } + step("Click 'Next' button") { + onSendAddressScreen { + nextButton.clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onSendConfirmScreen { sendingText.assertIsDisplayed() } + } + ) + } } step("Click 'Send' button") { waitForIdle() onSendConfirmScreen { sendButton.assertIsEnabled() - sendButton.performClick() + sendButton.clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onFailedTransactionDialog { dialogContainer.assertIsDisplayed() } + } + ) } } step("Check 'Failed transaction' dialog") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt index 2003821062..45000fa4ad 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt @@ -126,7 +126,7 @@ class TotalBalanceUpdateTest : BaseTestCase() { onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } } step("Press 'Home' to collapse the app") { - device.uiDevice.pressHome() + collapseAppByHomeButton() } step("Open the app from recent apps") { openTheAppFromRecents() diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt index 572fbce81c..32177f7b76 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt @@ -8,10 +8,12 @@ import com.tangem.common.constants.TestConstants.ENS_ETHEREUM_RECIPIENT_SHORTENE import com.tangem.common.constants.TestConstants.ENS_NAME import com.tangem.common.constants.TestConstants.ETHEREUM_ADDRESS import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.XRP_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.XRP_X_ADDRESS import com.tangem.common.constants.TestConstants.XRP_X_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.XRP_X_RECIPIENT_ADDRESS_WITH_TAG +import com.tangem.common.extensions.clickAndWaitFor import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.clearClipboard import com.tangem.common.utils.resetWireMockScenarioState @@ -115,6 +117,9 @@ class SendAddressScreenTest : BaseTestCase() { clearClipboard() } ).run { + step("Set clipboard text") { + setClipboardText(context, recipientAddress) + } step("Open 'Main Screen'") { openMainScreen() } @@ -124,10 +129,11 @@ class SendAddressScreenTest : BaseTestCase() { step("Open 'Send Address' screen") { openSendAddressScreen(tokenName, sendAmount) } - step("Set clipboard text") { - setClipboardText(context, recipientAddress) + step("Assert 'Addresses shimmer' is not displayed") { + onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() } } step("Click on 'Paste' button") { + waitForIdle() onSendAddressScreen { addressPasteButton.clickWithAssertion() } } step("Assert address text field contains correct recipient address") { @@ -142,8 +148,22 @@ class SendAddressScreenTest : BaseTestCase() { step("Set clipboard text") { setClipboardText(context, invalidAddress) } + step("Assert 'Addresses shimmer' is not displayed") { + onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() } + } step("Click on 'Cross' button") { - onSendAddressScreen { clearTextFieldButton.clickWithAssertion() } + waitForIdle() + onSendAddressScreen { + clearTextFieldButton.clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onSendAddressScreen { addressPasteButton.assertIsDisplayed() } + } + ) + } + } + step("Assert 'Addresses shimmer' is not displayed") { + onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() } } step("Click on 'Paste' button") { onSendAddressScreen { addressPasteButton.clickWithAssertion() } @@ -154,20 +174,38 @@ class SendAddressScreenTest : BaseTestCase() { step("Assert invalid address text field title is displayed") { onSendAddressScreen { addressTextFieldTitle.assertTextContains(notAValidAddress) } } - step("Assert 'Next' button is disabled") { - onSendAddressScreen { nextButton.assertIsNotEnabled() } - } step("Set clipboard text") { setClipboardText(context, walletAddress) } + step("Assert 'Next' button is disabled") { + onSendAddressScreen { nextButton.assertIsNotEnabled() } + } + step("Assert 'Addresses shimmer' is not displayed") { + onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() } + } step("Click on 'Cross' button") { - onSendAddressScreen { clearTextFieldButton.clickWithAssertion() } + onSendAddressScreen { + clearTextFieldButton.clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onSendAddressScreen { addressPasteButton.assertIsDisplayed() } + } + ) + } + } + step("Assert 'Addresses shimmer' is not displayed") { + onSendAddressScreen { addressesShimmer.assertDoesNotExist() } } step("Click on 'Paste' button") { + waitForIdle() onSendAddressScreen { addressPasteButton.clickWithAssertion() } } step("Assert address text field contains invalid address") { - onSendAddressScreen { addressTextField.assertTextContains(walletAddress) } + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendAddressScreen { + addressTextField.assertTextContains(walletAddress) + } + } } step("Assert 'Address is the same as wallet address' error title is displayed") { onSendAddressScreen { addressTextFieldTitle.assertTextContains(sameAsWalletAddress) } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt index 6ecfda055b..69307ea8b4 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt @@ -267,22 +267,21 @@ class SendConfirmScreenTest : BaseTestCase() { step("Click on 'Next' button") { onSendScreen { nextButton.clickWithAssertion() } } - step("Type address in input text field") { - onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) } - } step("Turn off internet") { disableWiFi() disableMobileData() } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) } + } step("Click on 'Next' button") { onSendAddressScreen { nextButton.clickWithAssertion() } } - step("Turn on internet") { - enableWiFi() - enableMobileData() - } step("Assert 'Network fee info unreachable' warning title is displayed") { - onSendConfirmScreen { warningTitle(warningTitle).assertIsDisplayed() } + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + waitForIdle() + onSendConfirmScreen { warningTitle(warningTitle).assertIsDisplayed() } + } } step("Assert 'Check your internet connection' warning message is displayed") { onSendConfirmScreen { warningMessage(warningMessageResId).assertIsDisplayed() } @@ -290,6 +289,10 @@ class SendConfirmScreenTest : BaseTestCase() { step("Assert warning icon is displayed") { onSendConfirmScreen { warningIcon(warningTitle).assertIsDisplayed() } } + step("Turn on internet") { + enableWiFi() + enableMobileData() + } step("Click on 'Refresh' button") { waitForIdle() onSendConfirmScreen { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt index 21e65feb9b..9820b6fe9b 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt @@ -3,6 +3,7 @@ package com.tangem.tests.swap import androidx.compose.ui.test.longClick import androidx.test.InstrumentationRegistry.getTargetContext import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.extensions.assertHasBadge import com.tangem.common.extensions.restartApp import com.tangem.common.utils.resetWireMockScenarioState @@ -208,7 +209,10 @@ class SwapStoriesTest : BaseTestCase() { } step("Assert 'Swap' button has badge") { waitForIdle() - onMainScreen { swapButton.assertHasBadge() } + flakySafely(WAIT_UNTIL_TIMEOUT) { + composeTestRule.mainClock.advanceTimeBy(500) + onMainScreen { swapButton.assertHasBadge() } + } } step("Open 'Swap' screen") { openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt index 095b1ccacb..515ca716f9 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt @@ -557,9 +557,6 @@ class SwapTokenScreenTest : BaseTestCase() { } } - @ApiEnv( - ApiEnvConfig(ApiConfig.ID.Express, ApiEnvironment.PROD) - ) @AllureId("8536") @DisplayName("Swap: check switch fee type (unable to cover 'Market' and 'Fast' fee)") @Test 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 4a4334393e..b216e3991a 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 @@ -124,7 +124,8 @@ fun InputRowRecipient( onClick = onPasteClick, modifier = Modifier .align(CenterVertically) - .padding(start = TangemTheme.dimens.spacing8), + .padding(start = TangemTheme.dimens.spacing8) + .testTag(SendAddressScreenTestTags.CROSS_ICON), ) } Row( 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 index 662611b4d4..92f6450322 100644 --- 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 @@ -7,7 +7,9 @@ object SendAddressScreenTestTags { const val ADDRESS_TEXT_FIELD = "SEND_ADDRESS_SCREEN_TEXT_FIELD" const val QR_BUTTON = "SEND_ADDRESS_SCREEN_QR_BUTTON" const val ADDRESS_PASTE_BUTTON = "SEND_ADDRESS_SCREEN_ADDRESS_PASTE_BUTTON" + const val CROSS_ICON = "SEND_ADDRESS_SCREEN_ADDRESS_CROSS_ICON" const val RESOLVED_ADDRESS = "SEND_ADDRESS_SCREEN_RESOLVED_ADDRESS" + const val ADDRESSES_SHIMMER = "SEND_ADDRESS_SCREEN_ADDRESSES_SHIMMER" const val RECENT_ADDRESS_ITEM = "SEND_ADDRESS_SCREEN_RECENT_ADDRESS_ITEM" const val RECENT_ADDRESS_TITLE = "SEND_ADDRESS_SCREEN_RECENT_ADDRESS_TITLE" diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt index a748635db5..9a999b3ece 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt @@ -234,6 +234,7 @@ private fun LazyListScope.listHeaderItem( TextShimmer( style = TangemTheme.typography.subtitle2, text = stringResourceSafe(titleRes), + modifier = Modifier.testTag(SendAddressScreenTestTags.ADDRESSES_SHIMMER), ) } } else { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt index 520a13c331..7d820d015c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt @@ -115,6 +115,7 @@ internal fun WalletTopBar(config: WalletTopBarConfig) { Icon( painter = painterResource(id = action.iconRes), contentDescription = null, + modifier = Modifier.testTag(MainScreenTestTags.MORE_BUTTON), ) } } From ea180bc5037e7d670e44af4acbed34ef998eb96e Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Mar 2026 11:50:24 +0300 Subject: [PATCH 27/60] Updated on 2026-08-14 --- .../ui/components/common/WalletContent.kt | 60 ++++++++++--------- .../MultiCurrencyAccountContent.kt | 45 +++++++------- .../multicurrency/MultiCurrencyContent.kt | 23 +++++-- 3 files changed, 70 insertions(+), 58 deletions(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt index 3d542d67a6..6217c6f14f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt @@ -15,6 +15,7 @@ import com.tangem.common.ui.notifications.notificationsCarousel 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.utils.TangemSharedTransitionLayout import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.tokensListItems @@ -37,39 +38,40 @@ internal fun WalletListContent( val movableItemModifier = Modifier.padding(horizontal = TangemTheme.dimens2.x3) val itemModifier = movableItemModifier.padding(top = TangemTheme.dimens2.x3) - LazyColumn( - modifier = modifier, - state = listState, - contentPadding = contentPadding, - horizontalAlignment = Alignment.CenterHorizontally, - overscrollEffect = rememberOverscrollEffect(), - ) { - notifications( - notifications = currentWallet.notifications.map { it.messageUM }.toPersistentList(), - contentColor = containerColor, - modifier = movableItemModifier, - ) - notificationsCarousel( - containerColor = containerColor, - modifier = movableItemModifier, - notifications = currentWallet.notificationsCarousel.map { it.messageUM }.toPersistentList(), - ) + TangemSharedTransitionLayout(modifier) { + LazyColumn( + state = listState, + contentPadding = contentPadding, + horizontalAlignment = Alignment.CenterHorizontally, + overscrollEffect = rememberOverscrollEffect(), + ) { + notifications( + notifications = currentWallet.notifications.map { it.messageUM }.toPersistentList(), + contentColor = containerColor, + modifier = movableItemModifier, + ) + notificationsCarousel( + containerColor = containerColor, + modifier = movableItemModifier, + notifications = currentWallet.notificationsCarousel.map { it.messageUM }.toPersistentList(), + ) - tangemPay( - walletUM = currentWallet, - isBalanceHiding = isBalanceHidden, - modifier = itemModifier, - ) + tangemPay( + walletUM = currentWallet, + isBalanceHiding = isBalanceHidden, + modifier = itemModifier, + ) - tokensListItems2( - walletTokensListUM = currentWallet.tokensListUM, - modifier = movableItemModifier, - isBalanceHidden = isBalanceHidden, - ) + tokensListItems2( + walletTokensListUM = currentWallet.tokensListUM, + modifier = movableItemModifier, + isBalanceHidden = isBalanceHidden, + ) - nftCollections2(state = currentWallet, itemModifier = itemModifier) + nftCollections2(state = currentWallet, itemModifier = itemModifier) - organizeTokens2(state = currentWallet, itemModifier = itemModifier) + organizeTokens2(state = currentWallet, itemModifier = itemModifier) + } } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt index f8349a879c..e9a092a806 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt @@ -2,6 +2,9 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrenc import androidx.compose.animation.* import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -26,7 +29,6 @@ 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 -import kotlinx.coroutines.delay internal fun LazyListScope.portfolioContentItems( items: ImmutableList, @@ -155,18 +157,17 @@ private fun LazyListScope.portfolioItem( key = "account-${portfolio.id}", contentType = "account-content", ) { - var lastIndexProxy by remember { mutableIntStateOf(lastIndex) } - - // When collapsing the portfolio, we delay updating lastIndexProxy to allow - // shrinking animation to complete before changing the shape. - LaunchedEffect(lastIndex) { - if (lastIndex != 0) { - lastIndexProxy = lastIndex - return@LaunchedEffect - } - delay(timeMillis = minOf(50 * tokens.size, 250).toLong()) - lastIndexProxy = 0 - } + // Snap immediately on expand; on collapse, hold until all child items finish + // their shrink animation, then snap to fully-rounded shape. + val effectiveLastIndex by animateIntAsState( + targetValue = lastIndex, + animationSpec = if (lastIndex != 0) { + snap() + } else { + snap(delayMillis = minOf(50 * tokens.lastIndex, 250) + 150) + }, + label = "lastIndex", + ) PortfolioListItem( state = portfolio, @@ -176,7 +177,7 @@ private fun LazyListScope.portfolioItem( .roundedShapeItemDecoration( currentIndex = 0, radius = TangemTheme.dimens.radius14, - lastIndex = lastIndexProxy, + lastIndex = effectiveLastIndex, backgroundColor = TangemTheme.colors.background.primary, ), ) @@ -199,18 +200,14 @@ internal fun SlideInItemVisibility( AnimatedVisibility( modifier = modifier, visible = visible, - enter = fadeIn( - tween(100, delayMillis = delayEnter), - ) + expandVertically( - tween(100, delayMillis = delayEnter, easing = FastOutLinearInEasing), + enter = expandVertically( + tween(200, delayMillis = delayEnter, easing = LinearOutSlowInEasing), expandFrom = Alignment.Top, - ), - exit = fadeOut( - tween(100, delayMillis = delayExit), - ) + shrinkVertically( - tween(100, delayMillis = delayExit, easing = FastOutLinearInEasing), + ) + fadeIn(tween(200, delayMillis = delayEnter, easing = LinearOutSlowInEasing)), + exit = shrinkVertically( + tween(150, delayMillis = delayExit, easing = FastOutLinearInEasing), shrinkTowards = Alignment.Top, - ), + ) + fadeOut(tween(150, delayMillis = delayExit, easing = FastOutLinearInEasing)), ) { content() } 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 5041832a6b..2f1006c1cc 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 @@ -3,6 +3,8 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrenc import androidx.compose.animation.* import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -214,6 +216,7 @@ private fun LazyListScope.portfolioItem( ) } +@Suppress("MagicNumber") private fun LazyListScope.accountItem( listItem: TokensListItemUM2.Portfolio, modifier: Modifier, @@ -225,6 +228,18 @@ private fun LazyListScope.accountItem( key = listItem.tokenRowUM.id, contentType = listItem.tokenRowUM::class.java, ) { + // Snap immediately on expand; on collapse, hold the current value until all + // child items finish their shrink animation, then snap to fully-rounded shape. + val effectiveLastIndex by animateIntAsState( + targetValue = if (listItem.isExpanded) lastIndex else 0, + animationSpec = if (listItem.isExpanded) { + snap() + } else { + snap(delayMillis = minOf(50 * maxOf(listItem.tokenList.lastIndex, 0), 250) + 150) + }, + label = "lastIndex", + ) + val portfolioModifier = modifier .padding(top = if (index != 0) TangemTheme.dimens2.x2 else TangemTheme.dimens2.x3) .testTag(MainScreenTestTags.TOKEN_LIST_ITEM) @@ -233,7 +248,7 @@ private fun LazyListScope.accountItem( currentIndex = 0, radius = 18.dp, addDefaultPadding = false, - lastIndex = if (listItem.isExpanded) lastIndex else 0, + lastIndex = effectiveLastIndex, backgroundColor = TangemTheme.colors2.surface.level3, ) if (listItem.isCollapsable) { @@ -288,10 +303,9 @@ internal fun PortfolioRowItem( isBalanceHidden: Boolean, modifier: Modifier = Modifier, ) { - // TangemSharedTransitionLayout { ProvideSharedTransitionScope(modifier) { - val iconSharedContentState = rememberSharedContentState(key = "icon") - val titleSharedContentState = rememberSharedContentState(key = "title") + val iconSharedContentState = rememberSharedContentState(key = "icon_${item.tokenRowUM.id}") + val titleSharedContentState = rememberSharedContentState(key = "title_${item.tokenRowUM.id}") val boundsTransform = BoundsTransform { _, _ -> tween(250) } AnimatedContent( @@ -382,7 +396,6 @@ internal fun PortfolioRowItem( ) } } - // } } } From 79aaeaf74f43cba29811fb743b8f1a28a8b1c183 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Mar 2026 14:24:35 +0500 Subject: [PATCH 28/60] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 19 ++ .../common/extensions/CustomAssertsExt.kt | 51 +++++ .../com/tangem/scenarios/MarketsScenarios.kt | 50 +++++ .../screens/MarketsExchangesPageObject.kt | 71 ++++++ .../com/tangem/screens/MarketsPageObject.kt | 19 ++ .../tests/markets/MarketsExchangesTest.kt | 209 ++++++++++++++++++ .../token/internal/TokenFiatAmount.kt | 5 +- .../tangem/core/ui/test/MarketsTestTags.kt | 1 + .../core/ui/test/TokenElementsTestTags.kt | 1 + .../detailed/components/ListedOnBlock.kt | 5 +- 10 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/MarketsExchangesPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index cb51a8d6be..0f2fb8d3c6 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -30,6 +30,8 @@ import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.rules.RuleChain import org.junit.rules.TestRule +import org.junit.rules.TestWatcher +import org.junit.runner.Description import javax.inject.Inject abstract class BaseTestCase : TestCase( @@ -68,6 +70,12 @@ abstract class BaseTestCase : TestCase( */ val composeTestRule = createEmptyComposeRule() + private val semanticTreePrinterRule = object : TestWatcher() { + override fun failed(e: Throwable?, description: Description?) { + runCatching { printAllRoots() } + } + } + @Rule @JvmField val ruleChain: TestRule = RuleChain @@ -76,6 +84,7 @@ abstract class BaseTestCase : TestCase( .around(permissionRule) .around(apiEnvironmentRule) .around(composeTestRule) + .around(semanticTreePrinterRule) /** * Initialization order is important: @@ -140,6 +149,16 @@ abstract class BaseTestCase : TestCase( .printToLog(tag, maxDepth) } + fun printAllRoots( + tag: String = "ComposeTree", + ) { + val roots = composeTestRule.onAllNodes(isRoot()) + val count = roots.fetchSemanticsNodes().size + repeat(count) { index -> + roots[index].printToLog("$tag[$index]") + } + } + fun waitForIdle() = composeTestRule.waitForIdle() private fun applicationInjectionRule(): ApplicationInjectionExecutionRule { diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt index 827136c346..2815c2cc17 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt @@ -1,10 +1,14 @@ package com.tangem.common.extensions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import com.tangem.common.utils.LazyListItemNode import com.tangem.core.ui.components.buttons.actions.HasBadgeKey import com.tangem.core.ui.components.buttons.actions.IsDimmedKey import io.github.kakaocup.compose.node.element.KNode +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue fun assertElementDoesNotExist( elementProvider: () -> KNode, @@ -42,4 +46,51 @@ fun Any.assertHasBadge(expectedValue: Boolean = true) { is KNode, is LazyListItemNode -> this.assert(matcher) else -> throw IllegalArgumentException("Unsupported type: ${this::class}") } +} + +fun List.assertSortedByVolumeDescending() { + val volumes = this.mapNotNull { parseVolume(it) } + assertFalse("Trading volumes list should not be empty", volumes.isEmpty()) + assertTrue( + "Exchanges list should be sorted by volume in descending order", + volumes == volumes.sortedDescending(), + ) +} + +fun List.assertExchangeTypesAreCexOrDex() { + assertFalse("Exchange types list should not be empty", isEmpty()) + forEach { node -> + val text = extractText(node) + assertTrue( + "Exchange type should be 'CEX' or 'DEX', but found: $text", + text == "CEX" || text == "DEX", + ) + } +} + +fun List.assertTrustScoresValid() { + val validScores = setOf("Risky", "Caution", "Trusted") + assertFalse("Trust scores list should not be empty", isEmpty()) + forEach { node -> + val text = extractText(node) + assertTrue( + "Trust score should be one of $validScores, but found: $text", + text in validScores, + ) + } +} + +/** + * Extracts the text value from a semantic node's config. + */ +private fun extractText(node: SemanticsNode): String? { + if (SemanticsProperties.Text in node.config) { + return node.config[SemanticsProperties.Text].firstOrNull()?.text + } + return null +} + +private fun parseVolume(node: SemanticsNode): Double? { + val text = extractText(node) ?: return null + return text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull() } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt index 02142e077c..b4749cd5d3 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt @@ -1,8 +1,12 @@ package com.tangem.scenarios import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.SwipeDirection import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.swipeMarketsBlock +import com.tangem.common.extensions.swipeVertical import com.tangem.screens.onMainScreen +import com.tangem.screens.onMarketsExchangesScreen import com.tangem.screens.onMarketsScreen import com.tangem.screens.onMarketsTokenDetailsScreen import io.qameta.allure.kotlin.Allure.step @@ -23,4 +27,50 @@ fun BaseTestCase.openMarketTokenDetailsScreen(blockchainName: String, tokenName: waitForIdle() onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() } } +} + +fun BaseTestCase.assertMarketsExchangesScreen() { + step("Assert 'Title' is displayed") { + onMarketsExchangesScreen { exchangesTitle.assertIsDisplayed() } + } + step("Assert 'Exchange name' is displayed") { + onMarketsExchangesScreen { exchangeName.assertIsDisplayed() } + } + step("Assert 'Logo' is displayed") { + onMarketsExchangesScreen { exchangeLogo.assertIsDisplayed() } + } + step("Assert 'Exchange type' is displayed") { + onMarketsExchangesScreen { exchangeType.assertIsDisplayed() } + } + step("Assert 'Trust score' is displayed") { + onMarketsExchangesScreen { trustScore.assertIsDisplayed() } + } +} + +fun BaseTestCase.openMarketsScreen() { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Markets' screen") { + swipeMarketsBlock(SwipeDirection.UP) + waitForIdle() + } +} + +fun BaseTestCase.openMarketsExchangesScreen(tokenName: String) { + openMarketsScreen() + step("Click on '$tokenName' token") { + onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() } + waitForIdle() + } + step("Scroll down") { + swipeVertical(SwipeDirection.UP) + } + step("Click on 'Listed on exchanges' block") { + onMarketsScreen { listedOnBlockContainer.performClick() } + waitForIdle() + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MarketsExchangesPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MarketsExchangesPageObject.kt new file mode 100644 index 0000000000..00d7f17067 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/MarketsExchangesPageObject.kt @@ -0,0 +1,71 @@ +package com.tangem.screens + +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.hasParent +import androidx.compose.ui.test.hasTestTag +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.TokenElementsTestTags +import com.tangem.core.ui.test.TopAppBarTestTags +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.kakao.common.utilities.getResourceString + +class MarketsExchangesPageObject(private val provider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = provider) { + + fun allTradingVolumeNodes(): List = + provider + .onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT_TEXT)) + .fetchSemanticsNodes() + + fun allExchangeTypeNodes(): List = + provider + .onAllNodes(hasParent(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_PRICE)))) + .fetchSemanticsNodes() + + fun allTrustScoreNodes(): List = + provider + .onAllNodes(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT))) + .fetchSemanticsNodes() + + val exchangesTitle: KNode = child { + hasTestTag(TopAppBarTestTags.TITLE) + hasText(getResourceString(R.string.markets_token_details_exchanges_title)) + useUnmergedTree = true + } + + val exchangeName: KNode = child { + hasTestTag(TokenElementsTestTags.TOKEN_TITLE) + } + + val exchangeLogo: KNode = child { + hasTestTag(TokenElementsTestTags.TOKEN_ICON) + } + + val tradingVolume: KNode = child { + hasTestTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT) + } + + val exchangeType: KNode = child { + hasTestTag(TokenElementsTestTags.TOKEN_PRICE) + } + + val trustScore: KNode = child { + hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT) + } + + val tryAgainButton: KNode = child { + hasText(getResourceString(R.string.alert_button_try_again)) + } + + val errorMessage: KNode = child { + hasText(getResourceString(R.string.markets_loading_error_title)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onMarketsExchangesScreen(function: MarketsExchangesPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt index ff3a31ce16..1eefd12486 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt @@ -35,6 +35,25 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : useUnmergedTree = true } + val listedOnExchangesCount: KNode = child { + hasTestTag(MarketsTestTags.LISTED_ON_EXCHANGES_COUNT) + useUnmergedTree = true + } + + val listedOnBlockContainer: KNode = child { + hasText(getResourceString(R.string.markets_token_details_listed_on), substring = true) + } + + val listedOnEmptyText: KNode = child { + hasText(getResourceString(R.string.markets_token_details_empty_exchanges)) + useUnmergedTree = true + } + + val seeAllButton: KNode = child { + hasText(getResourceString(com.tangem.core.ui.R.string.common_see_all)) + useUnmergedTree = true + } + fun tokenWithTitle(title: String): KNode { return child { hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt new file mode 100644 index 0000000000..86d9afdcee --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt @@ -0,0 +1,209 @@ +package com.tangem.tests.markets + +import com.tangem.common.BaseTestCase +import com.tangem.common.annotations.ApiEnv +import com.tangem.common.annotations.ApiEnvConfig +import com.tangem.common.extensions.* +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.scenarios.* +import com.tangem.screens.onMarketsExchangesScreen +import com.tangem.screens.onMarketsScreen +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 MarketsExchangesTest : BaseTestCase() { + + @Test + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @AllureId("58") + @DisplayName("Markets: verify exchanges list screen") + fun marketsExchangesListTest() { + val tokenName = "Solana" + setupHooks().run { + step("Open 'Markets' screen") { + openMarketsScreen() + } + step("Click on 'See all' button") { + onMarketsScreen { seeAllButton.clickWithAssertion() } + } + step("Click on '$tokenName' token") { + onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() } + waitForIdle() + } + step("Scroll down") { + swipeVertical(SwipeDirection.UP) + } + step("Click on 'Listed on exchanges' block") { + onMarketsScreen { listedOnBlockContainer.performClick() } + } + step("Assert 'Exchanges' list screen is displayed") { + assertMarketsExchangesScreen() + } + } + } + + @Test + @AllureId("56") + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @DisplayName("Markets: verify exchanges block is displayed in token details") + fun marketsExchangesBlockDisplayedTest() { + val tokenName = "Bitcoin" + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Markets' screen") { + swipeMarketsBlock(SwipeDirection.UP) + waitForIdle() + } + step("Click on '$tokenName' token") { + onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() } + waitForIdle() + } + step("Scroll down") { + swipeVertical(SwipeDirection.UP) + } + step("Assert 'Listed on exchanges' block has title") { + onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() } + } + step("Assert 'Listed on exchanges' block has exchanges count") { + onMarketsScreen { listedOnExchangesCount.assertIsDisplayed() } + } + step("Assert 'Listed on exchanges' block has arrow button") { + onMarketsScreen { listedOnBlockContainer.assertHasClickAction() } + } + step("Tap on 'Listed on exchanges' block and navigate to exchanges list") { + onMarketsScreen { listedOnBlockContainer.performClick() } + waitForIdle() + } + step("Assert 'Exchanges' list screen is displayed") { + assertMarketsExchangesScreen() + } + } + } + + @Test + @AllureId("60") + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @DisplayName("Markets: verify exchanges list is sorted by volume descending") + fun marketsExchangesListSortedByVolumeTest() { + val tokenName = "Bitcoin" + setupHooks().run { + step("Open 'Markets Exhanges Screen with token: $tokenName'") { + openMarketsExchangesScreen(tokenName) + } + step("Assert exchanges are sorted by trading volume in descending order") { + flakySafely { + onMarketsExchangesScreen { allTradingVolumeNodes().assertSortedByVolumeDescending() } + } + } + } + } + + @Test + @AllureId("61") + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @DisplayName("Markets: verify exchange types are CEX or DEX") + fun marketsExchangesTypeTest() { + val tokenName = "Bitcoin" + setupHooks().run { + step("Open 'Markets Exhanges Screen with token: $tokenName'") { + openMarketsExchangesScreen(tokenName) + } + step("Assert exchange types list is not empty and all types are 'CEX' or 'DEX'") { + flakySafely { + onMarketsExchangesScreen { allExchangeTypeNodes().assertExchangeTypesAreCexOrDex() } + } + } + } + } + + @Test + @AllureId("62") + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @DisplayName("Markets: verify exchange trust scores are valid") + fun marketsExchangesTrustScoreTest() { + val tokenName = "Bitcoin" + setupHooks().run { + step("Open 'Markets Exhanges Screen with token: $tokenName'") { + openMarketsExchangesScreen(tokenName) + } + step("Assert trust scores list is not empty and all scores are valid") { + flakySafely { + onMarketsExchangesScreen { allTrustScoreNodes().assertTrustScoresValid() } + } + } + } + } + + @Test + @AllureId("57") + @DisplayName("Markets: verify empty exchanges state") + fun marketsExchangesEmptyTest() { + val tokenName = "Bitcoin" + val scenarioName = "coins_bitcoin" + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(scenarioName = scenarioName, state = "EmptyExchanges") + }, + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + }, + ).run { + step("Open 'Markets Exhanges Screen with token: $tokenName'") { + openMarketsExchangesScreen(tokenName) + } + step("Assert 'Listed on exchanges' block shows no exchanges") { + onMarketsScreen { listedOnEmptyText.assertIsDisplayed() } + } + } + } + + @Test + @AllureId("59") + @DisplayName("Markets: verify exchanges error state and retry") + fun marketsExchangesErrorStateTest() { + val tokenName = "Bitcoin" + val scenarioName = "bitcoin_exchange" + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(scenarioName = scenarioName, state = "Unreachable") + }, + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + }, + ).run { + step("Open 'Markets Exhanges Screen with token: $tokenName'") { + openMarketsExchangesScreen(tokenName) + } + step("Assert 'Unable to load data' error state is displayed") { + step("Assert 'Error message' is displayed") { + onMarketsExchangesScreen { errorMessage.assertIsDisplayed() } + } + step("Assert 'Try again button' is displayed") { + onMarketsExchangesScreen { tryAgainButton.assertIsDisplayed() } + } + + } + step("Set WireMock scenario '$scenarioName' to state 'Started'") { + setWireMockScenarioState(scenarioName = scenarioName, state = "Started") + } + step("Tap 'Try again' button") { + onMarketsExchangesScreen { tryAgainButton.clickWithAssertion() } + waitForIdle() + } + step("Assert 'Exchanges' list screen is displayed after retry") { + assertMarketsExchangesScreen() + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt index 0651618f73..d821328360 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt @@ -13,6 +13,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import com.tangem.core.ui.test.TokenElementsTestTags import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow @@ -121,7 +124,7 @@ private fun FiatAmountText( isFlickering: Boolean = false, ) { Text( - modifier = modifier, + modifier = modifier.semantics { testTag = TokenElementsTestTags.TOKEN_FIAT_AMOUNT_TEXT }, text = text, maxLines = 1, overflow = TextOverflow.Ellipsis, 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 index 0721a092cd..8ab6a276ae 100644 --- 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 @@ -4,4 +4,5 @@ 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" + const val LISTED_ON_EXCHANGES_COUNT = "MARKETS_LISTED_ON_EXCHANGES_COUNT" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TokenElementsTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TokenElementsTestTags.kt index d513f7791c..70277a9e2c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/TokenElementsTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TokenElementsTestTags.kt @@ -5,6 +5,7 @@ object TokenElementsTestTags { const val TOKEN_ICON = "TOKEN_ICON" const val TOKEN_PRICE = "TOKEN_PRICE" const val TOKEN_FIAT_AMOUNT = "TOKEN_FIAT_AMOUNT" + const val TOKEN_FIAT_AMOUNT_TEXT = "TOKEN_FIAT_AMOUNT_TEXT" const val TOKEN_CRYPTO_AMOUNT = "TOKEN_CRYPTO_AMOUNT" const val TOKEN_NON_FIAT_BLOCK = "TOKEN_NON_FIAT_BLOCK" const val TOKEN_YIELD_PROMO_BANNER = "TOKEN_YIELD_PROMO_BANNER" diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt index 77d5ba5239..7836a63094 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt @@ -21,12 +21,15 @@ 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 androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import com.tangem.common.ui.R import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.block.information.InformationBlock 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.MarketsTestTags import com.tangem.features.feed.ui.market.detailed.state.ListedOnUM import kotlinx.coroutines.delay @@ -99,7 +102,7 @@ internal fun ListedOnBlockPlaceholder(modifier: Modifier = Modifier) { private fun Description(state: ListedOnUM, modifier: Modifier = Modifier) { Text( text = state.description.resolveReference(), - modifier = modifier, + modifier = modifier.semantics { testTag = MarketsTestTags.LISTED_ON_EXCHANGES_COUNT }, color = TangemTheme.colors.text.tertiary, overflow = TextOverflow.Ellipsis, maxLines = 1, From 0143202ddebed5f0a5d90f7bd80a553c5065783c Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Mar 2026 16:34:54 +0500 Subject: [PATCH 29/60] Updated on 2026-08-14 --- .../bottomsheets/TangemBottomSheet.kt | 10 - ...tTangemButton.kt => StatusTangemButton.kt} | 51 ++-- .../tangem/core/ui/ds/button/TangemButton.kt | 15 +- .../core/ui/ds/button/TangemButtonInternal.kt | 226 +++++++++++------- .../core/ui/ds/button/TangemButtonUM.kt | 1 + .../com/tangem/core/ui/res/TangemColors2.kt | 4 + .../com/tangem/core/ui/res/TangemDimens2.kt | 1 + .../tangem/core/ui/res/TangemThemeRedesign.kt | 6 +- .../storybook/page/buttons/ButtonsStory.kt | 15 +- .../wallet/ui/components/WalletItemBlocks.kt | 2 +- 10 files changed, 209 insertions(+), 122 deletions(-) rename core/ui/src/main/java/com/tangem/core/ui/ds/button/{AccentTangemButton.kt => StatusTangemButton.kt} (77%) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt index 2eceba7401..60b4d4699a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt @@ -1,7 +1,6 @@ package com.tangem.core.ui.components.bottomsheets 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.shape.RoundedCornerShape @@ -212,14 +211,6 @@ inline fun BasicBottomSheet( Modal -> windowSize.height * MODAL_SHEET_MAX_HEIGHT } - val buttonHeight by animateDpAsState( - if (footer != null) { - 80.dp - } else { - 0.dp - }, - ) - val contentModifier = when (type) { Default -> Modifier.clip( RoundedCornerShape( @@ -260,7 +251,6 @@ inline fun BasicBottomSheet( Box( modifier = Modifier .fillMaxWidth() - .height(buttonHeight) .align(Alignment.BottomCenter), ) { if (footer != null) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/AccentTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/StatusTangemButton.kt similarity index 77% rename from core/ui/src/main/java/com/tangem/core/ui/ds/button/AccentTangemButton.kt rename to core/ui/src/main/java/com/tangem/core/ui/ds/button/StatusTangemButton.kt index 8c14fb6a7f..aa18decee3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/AccentTangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/StatusTangemButton.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview @@ -27,14 +28,15 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign * @param modifier Modifier to be applied to the button. */ @Composable -fun AccentTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { - AccentTangemButton( +fun StatusTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { + StatusTangemButton( onClick = buttonUM.onClick, modifier = modifier, text = buttonUM.text, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, enabled = buttonUM.isEnabled, + type = buttonUM.type, size = buttonUM.size, state = buttonUM.state, shape = buttonUM.shape, @@ -57,35 +59,39 @@ fun AccentTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) [REDACTED_AUTHOR] */ @Composable -fun AccentTangemButton( +fun StatusTangemButton( onClick: () -> Unit, modifier: Modifier = Modifier, text: TextReference? = null, @DrawableRes iconRes: Int? = null, iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, enabled: Boolean = true, + type: TangemButtonType = TangemButtonType.Accent, size: TangemButtonSize = TangemButtonSize.X15, state: TangemButtonState = TangemButtonState.Default, shape: TangemButtonShape = TangemButtonShape.Default, ) { + val statusColor = type.getStatusColor() + val contentColor = when (state) { + TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled + else -> TangemTheme.colors2.text.neutral.primaryInvertedConstant + } + val backgroundModifier = when (state) { + TangemButtonState.Disabled -> Modifier.background(TangemTheme.colors2.button.backgroundDisabled) + TangemButtonState.Default -> Modifier.background(statusColor) + TangemButtonState.Loading, + TangemButtonState.Pressed, + -> Modifier + .background(statusColor) + .background(TangemTheme.colors2.overlay.overlaySecondary) + } TangemButtonInternal( onClick = onClick, modifier = modifier .clip(shape.toShape(size)) - .then( - when (state) { - TangemButtonState.Disabled, - TangemButtonState.Default, - -> Modifier.background(TangemTheme.colors2.button.backgroundPositive) - TangemButtonState.Loading, - TangemButtonState.Pressed, - -> Modifier - .background(TangemTheme.colors2.button.backgroundPositive) - .background(TangemTheme.colors2.overlay.overlaySecondary) - }, - ), + .then(backgroundModifier), text = text, - contentColor = TangemTheme.colors2.text.neutral.primaryInvertedConstant, + contentColor = contentColor, iconRes = iconRes, enabled = enabled, size = size, @@ -94,11 +100,19 @@ fun AccentTangemButton( ) } +@ReadOnlyComposable +@Composable +private fun TangemButtonType.getStatusColor() = when (this) { + TangemButtonType.Accent -> TangemTheme.colors2.button.backgroundAccent + TangemButtonType.Positive -> TangemTheme.colors2.button.backgroundPositive + else -> TangemTheme.colors2.button.backgroundPrimary +} + // region Preview @Composable @Preview(showBackground = true, widthDp = 480) @Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun AccentTangemButton_Preview( +private fun StatusTangemButton_Preview( @PreviewParameter(AccentTangemButtonPreviewProvider::class) params: TangemButtonState, ) { TangemThemePreviewRedesign { @@ -118,13 +132,14 @@ private fun AccentTangemButton_Preview( } else { TangemButtonIconPosition.End } - AccentTangemButton( + StatusTangemButton( onClick = {}, text = text, size = TangemButtonSize.X15, shape = shape, iconPosition = iconPosition, iconRes = R.drawable.ic_tangem_24, + type = TangemButtonType.Accent, state = params, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt index 6e8600bac6..7e78082811 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt @@ -39,13 +39,26 @@ fun TangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { state = buttonUM.state, shape = buttonUM.shape, ) - TangemButtonType.Accent -> AccentTangemButton( + TangemButtonType.Accent -> StatusTangemButton( onClick = buttonUM.onClick, modifier = modifier, text = buttonUM.text, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, enabled = buttonUM.isEnabled, + type = TangemButtonType.Positive, + size = buttonUM.size, + state = buttonUM.state, + shape = buttonUM.shape, + ) + TangemButtonType.Positive -> StatusTangemButton( + onClick = buttonUM.onClick, + modifier = modifier, + text = buttonUM.text, + iconRes = buttonUM.iconRes, + iconPosition = buttonUM.iconPosition, + enabled = buttonUM.isEnabled, + type = TangemButtonType.Positive, size = buttonUM.size, state = buttonUM.state, shape = buttonUM.shape, diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt index b9426982dd..66665e2f5d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt @@ -1,9 +1,11 @@ package com.tangem.core.ui.ds.button import androidx.annotation.DrawableRes -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.TextAutoSize @@ -15,6 +17,7 @@ import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.testTag @@ -25,13 +28,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.clickableSingle -import com.tangem.core.ui.extensions.conditionalCompose -import com.tangem.core.ui.extensions.resolveReference +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.test.BaseButtonTestTags +private const val LOADING_ANIMATION_DURATION = 150 + /** * A customizable button component that supports text, icons, and different states. * @@ -61,7 +64,7 @@ internal fun TangemButtonInternal( state: TangemButtonState = TangemButtonState.Default, ) { ProvideButtonRippleConfiguration { - Row( + Box( modifier = modifier .testTag(BaseButtonTestTags.BUTTON) .clickableSingle(enabled = enabled, onClick = onClick, role = Role.Button) @@ -70,63 +73,107 @@ internal fun TangemButtonInternal( width(size.toHeightDp()) } .conditionalCompose(text != null) { - padding(horizontal = size.toPaddingDp()) + padding(size.toPaddingDp()) } .animateContentSize(), - horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, ) { - AnimatedVisibility( - visible = iconRes != null && iconPosition == TangemButtonIconPosition.Start, - modifier = Modifier.size(size = size.toContentSize()), + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .align(Alignment.Center) + .conditional(state == TangemButtonState.Loading) { + alpha(0f) + }, ) { - val wrappedIconRes = remember(this, iconRes) { requireNotNull(iconRes) } - TangemButtonIcon(iconRes = wrappedIconRes, state = state, iconColor = contentColor, size = size) - } + val isStartIcon = iconRes != null && iconPosition == TangemButtonIconPosition.Start + TangemButtonIcon( + iconRes = iconRes, + iconColor = contentColor, + isVisible = isStartIcon, + size = size, + ) + if (isStartIcon && (text != null || descriptionText != null)) { + SpacerW(TangemTheme.dimens2.x1) + } - AnimatedVisibility(text != null && state != TangemButtonState.Loading) { - val wrappedText = remember(this) { requireNotNull(text) } - val textStyle = size.toTextStyle() - Text( - text = wrappedText.resolveReference(), - style = textStyle, + ButtonContent( + text = text, + descriptionText = descriptionText, + contentColor = contentColor, + size = size, + ) + + val isEndIcon = iconRes != null && iconPosition == TangemButtonIconPosition.End + if (isEndIcon && (text != null || descriptionText != null)) { + SpacerW(TangemTheme.dimens2.x1) + } + TangemButtonIcon( + iconRes = iconRes, + iconColor = contentColor, + isVisible = isEndIcon, + size = size, + ) + } + AnimatedVisibility( + modifier = Modifier + .align(Alignment.Center) + .size(size.toContentSize()), + visible = state == TangemButtonState.Loading, + exit = fadeOut(animationSpec = tween(LOADING_ANIMATION_DURATION)), + enter = fadeIn(animationSpec = tween(LOADING_ANIMATION_DURATION)), + ) { + CircularProgressIndicator( color = contentColor, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - autoSize = TextAutoSize.StepBased( - minFontSize = 12.sp, - maxFontSize = textStyle.fontSize, - ), - modifier = Modifier.testTag(BaseButtonTestTags.TEXT), + strokeWidth = 2.dp, + strokeCap = StrokeCap.Round, + modifier = Modifier.size(size.toContentSize()), ) } + } + } +} - AnimatedVisibility(descriptionText != null && state != TangemButtonState.Loading) { - val wrappedText = remember(this) { requireNotNull(descriptionText) } - val textStyle = TangemTheme.typography2.captionSemibold12 - Text( - text = wrappedText.resolveReference(), - style = textStyle, - color = TangemTheme.colors2.text.status.disabled, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - autoSize = TextAutoSize.StepBased( - minFontSize = 12.sp, - maxFontSize = textStyle.fontSize, - ), - modifier = Modifier.testTag(BaseButtonTestTags.TEXT), - ) - } - - AnimatedVisibility( - visible = iconRes != null && iconPosition == TangemButtonIconPosition.End, - modifier = Modifier.size(size = size.toContentSize()), - ) { - val wrappedIconRes = remember(this) { requireNotNull(iconRes) } - TangemButtonIcon(iconRes = wrappedIconRes, state = state, iconColor = contentColor, size = size) - } +@Composable +private fun ButtonContent( + text: TextReference?, + descriptionText: TextReference?, + contentColor: Color, + size: TangemButtonSize, +) { + Column { + AnimatedVisibility(text != null) { + val wrappedText = remember(this) { text.orEmpty() } + val textStyle = size.toTextStyle() + Text( + text = wrappedText.resolveReference(), + style = textStyle, + color = contentColor, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = 12.sp, + maxFontSize = textStyle.fontSize, + ), + modifier = Modifier.testTag(BaseButtonTestTags.TEXT), + ) + } + AnimatedVisibility(descriptionText != null) { + val wrappedText = remember(this) { descriptionText.orEmpty() } + val textStyle = TangemTheme.typography2.captionSemibold12 + Text( + text = wrappedText.resolveReference(), + style = textStyle, + color = TangemTheme.colors2.text.status.disabled, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = 12.sp, + maxFontSize = textStyle.fontSize, + ), + modifier = Modifier.testTag(BaseButtonTestTags.TEXT), + ) } } } @@ -150,36 +197,21 @@ private inline fun ProvideButtonRippleConfiguration(crossinline content: @Compos @Composable private fun TangemButtonIcon( - @DrawableRes iconRes: Int, + @DrawableRes iconRes: Int?, iconColor: Color, - state: TangemButtonState, + isVisible: Boolean, size: TangemButtonSize, ) { - AnimatedContent(state) { targetState -> - when (targetState) { - TangemButtonState.Loading -> CircularProgressIndicator( - color = iconColor, - strokeWidth = 2.dp, - strokeCap = StrokeCap.Round, - modifier = Modifier.padding( - when (size) { - TangemButtonSize.X7, - TangemButtonSize.X8, - TangemButtonSize.X9, - TangemButtonSize.X10, - -> 0.5.dp - TangemButtonSize.X12, - TangemButtonSize.X15, - -> 4.5.dp - }, - ), - ) - else -> Icon( - painter = painterResource(id = iconRes), - contentDescription = null, - tint = iconColor, - ) - } + AnimatedVisibility( + visible = isVisible, + modifier = Modifier.size(size = size.toContentSize()), + ) { + val wrappedIconRes = remember(this) { requireNotNull(iconRes) } + Icon( + painter = painterResource(id = wrappedIconRes), + contentDescription = null, + tint = iconColor, + ) } } @@ -227,14 +259,30 @@ enum class TangemButtonSize { @ReadOnlyComposable @Composable internal fun toPaddingDp() = when (this) { - X7 -> TangemTheme.dimens2.x2 - X8, - X9, - X10, - -> TangemTheme.dimens2.x3 - X12, - X15, - -> TangemTheme.dimens2.x6 + X7 -> PaddingValues( + horizontal = TangemTheme.dimens2.x2, + vertical = TangemTheme.dimens2.x0_5, + ) + X8 -> PaddingValues( + horizontal = TangemTheme.dimens2.x3, + vertical = TangemTheme.dimens2.x1_5, + ) + X9 -> PaddingValues( + horizontal = TangemTheme.dimens2.x3, + vertical = TangemTheme.dimens2.x2, + ) + X10 -> PaddingValues( + horizontal = TangemTheme.dimens2.x3, + vertical = TangemTheme.dimens2.x2_5, + ) + X12 -> PaddingValues( + horizontal = TangemTheme.dimens2.x6, + vertical = TangemTheme.dimens2.x2_5, + ) + X15 -> PaddingValues( + horizontal = TangemTheme.dimens2.x6, + vertical = TangemTheme.dimens2.x4, + ) } @ReadOnlyComposable diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonUM.kt index a3bb16c4da..6d96b1f577 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonUM.kt @@ -39,6 +39,7 @@ enum class TangemButtonType { Primary, Secondary, Accent, + Positive, Outline, PrimaryInverse, Ghost, diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt index 0cd61de19c..afa963b0fe 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt @@ -165,6 +165,7 @@ class TangemColors2 internal constructor( backgroundPrimary: Color, backgroundSecondary: Color, backgroundDisabled: Color, + backgroundAccent: Color, backgroundPositive: Color, backgroundPrimaryInverse: Color, textPrimary: Color, @@ -183,6 +184,8 @@ class TangemColors2 internal constructor( private set var backgroundPositive by mutableStateOf(backgroundPositive) private set + var backgroundAccent by mutableStateOf(backgroundAccent) + private set var backgroundPrimaryInverse by mutableStateOf(backgroundPrimaryInverse) private set var textPrimary by mutableStateOf(textPrimary) @@ -205,6 +208,7 @@ class TangemColors2 internal constructor( backgroundSecondary = other.backgroundSecondary backgroundDisabled = other.backgroundDisabled backgroundPositive = other.backgroundPositive + backgroundAccent = other.backgroundAccent backgroundPrimaryInverse = other.backgroundPrimaryInverse textPrimary = other.textPrimary textSecondary = other.textSecondary diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens2.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens2.kt index 292a6555e3..f8e2484ba3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens2.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens2.kt @@ -11,6 +11,7 @@ data class TangemDimens2 internal constructor( val x0: Dp = 0.dp, val x0_5: Dp = 2.dp, val x1: Dp = 4.dp, + val x1_5: Dp = 6.dp, val x2: Dp = 8.dp, val x2_5: Dp = 10.dp, val x3: Dp = 12.dp, diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt index 619a08d5b8..724f09365d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt @@ -112,7 +112,8 @@ private fun lightThemeColors2(): TangemColors2 { backgroundPrimary = TangemColorPalette.Dark6, backgroundSecondary = TangemColorPalette.Dark_10, backgroundDisabled = TangemColorPalette.Light3, - backgroundPositive = TangemColorPalette.Azure, + backgroundAccent = TangemColorPalette.Azure, + backgroundPositive = TangemColorPalette.Eucalyptus, backgroundPrimaryInverse = TangemColorPalette.White, textSecondary = TangemColorPalette.Dark6, textPrimary = TangemColorPalette.Light2, @@ -288,7 +289,8 @@ private fun darkThemeColors2(): TangemColors2 { backgroundPrimary = TangemColorPalette.Light1V2, backgroundSecondary = TangemColorPalette.Light_10, backgroundDisabled = TangemColorPalette.Dark5, - backgroundPositive = TangemColorPalette.Azure, + backgroundAccent = TangemColorPalette.Azure, + backgroundPositive = TangemColorPalette.Eucalyptus, backgroundPrimaryInverse = TangemColorPalette.Light_10, textSecondary = TangemColorPalette.Light4, textPrimary = TangemColorPalette.Dark4, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt index 9d9a6a9c09..7a7815c402 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt @@ -82,7 +82,7 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } item("accent") { ButtonSection(title = "Accent") { state, text, shape -> - AccentTangemButton( + StatusTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, iconRes = R.drawable.ic_tangem_24, @@ -92,6 +92,19 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { ) } } + item("positive") { + ButtonSection(title = "Positive") { state, text, shape -> + StatusTangemButton( + onClick = {}, + text = if (text) stringReference("Continue") else null, + iconRes = R.drawable.ic_tangem_24, + size = TangemButtonSize.X10, + type = TangemButtonType.Positive, + state = state, + shape = shape, + ) + } + } item("ghost") { ButtonSection(title = "Ghost") { state, text, shape -> GhostTangemButton( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletItemBlocks.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletItemBlocks.kt index 443bd5f959..56506408ef 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletItemBlocks.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletItemBlocks.kt @@ -26,7 +26,7 @@ internal fun LazyListScope.organizeTokens2(state: WalletUM, itemModifier: Modifi contentType = "OrganizeTokensButton", ) { TangemButton( - organizeButton, + buttonUM = organizeButton, modifier = itemModifier, ) } From a65b28883fc89c401f46c3869523621a7b9ed430 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Mar 2026 18:22:34 +0500 Subject: [PATCH 30/60] Updated on 2026-08-14 --- .../tangem/core/ui/ds/badge/TangemBadge.kt | 17 +++++++ .../core/ui/ds/row/token/TangemTokenRowUM.kt | 10 +++- .../internal/TangemTokenRowPreviewData.kt | 2 + .../row/token/internal/TokenRowPromoBanner.kt | 47 +++++++++++++++---- .../res/drawable/ic_yield_mode_default_24.xml | 15 ++++++ .../res/drawable/ic_yield_mode_mini_12.xml | 9 ++++ .../WalletTokenCurrencyItemConverter.kt | 28 ++++++----- 7 files changed, 105 insertions(+), 23 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_yield_mode_default_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_yield_mode_mini_12.xml diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt index e3332f834d..c196b908aa 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt @@ -289,6 +289,7 @@ enum class TangemBadgeColor { Red, Gray, Green, + GreenAlt, } @ReadOnlyComposable @@ -313,6 +314,12 @@ private fun getIconColor(type: TangemBadgeType, color: TangemBadgeColor) = when -> TangemTheme.colors2.markers.iconGreen TangemBadgeType.Solid -> TangemTheme.colors2.graphic.neutral.primaryInvertedConstant } + TangemBadgeColor.GreenAlt -> when (type) { + TangemBadgeType.Outline, + TangemBadgeType.Tinted, + -> TangemTheme.colors2.markers.iconGreenAlt + TangemBadgeType.Solid -> TangemTheme.colors2.graphic.neutral.primaryInvertedConstant + } } @ReadOnlyComposable @@ -337,6 +344,12 @@ private fun getTextColor(type: TangemBadgeType, color: TangemBadgeColor) = when -> TangemTheme.colors2.markers.textGreen TangemBadgeType.Solid -> TangemTheme.colors2.text.neutral.primaryInvertedConstant } + TangemBadgeColor.GreenAlt -> when (type) { + TangemBadgeType.Outline, + TangemBadgeType.Tinted, + -> TangemTheme.colors2.markers.textGreenAlt + TangemBadgeType.Solid -> TangemTheme.colors2.text.neutral.primaryInvertedConstant + } } @Suppress("CyclomaticComplexMethod") @@ -349,6 +362,7 @@ private fun Modifier.getBackgroundColor(type: TangemBadgeType, color: TangemBadg TangemBadgeColor.Blue -> TangemTheme.colors2.markers.backgroundSolidBlue TangemBadgeColor.Red -> TangemTheme.colors2.markers.backgroundSolidRed TangemBadgeColor.Green -> TangemTheme.colors2.markers.backgroundSolidGreen + TangemBadgeColor.GreenAlt -> TangemTheme.colors2.markers.backgroundSolidGreenAlt }, ) TangemBadgeType.Tinted -> background( @@ -357,6 +371,7 @@ private fun Modifier.getBackgroundColor(type: TangemBadgeType, color: TangemBadg TangemBadgeColor.Blue -> TangemTheme.colors2.markers.backgroundTintedBlue TangemBadgeColor.Red -> TangemTheme.colors2.markers.backgroundTintedRed TangemBadgeColor.Green -> TangemTheme.colors2.markers.backgroundTintedGreen + TangemBadgeColor.GreenAlt -> TangemTheme.colors2.markers.backgroundTintedGreenAlt }, ) TangemBadgeType.Outline -> { @@ -366,6 +381,7 @@ private fun Modifier.getBackgroundColor(type: TangemBadgeType, color: TangemBadg TangemBadgeColor.Blue -> TangemTheme.colors2.markers.borderTintedBlue TangemBadgeColor.Red -> TangemTheme.colors2.markers.borderTintedRed TangemBadgeColor.Green -> TangemTheme.colors2.markers.borderTintedGreen + TangemBadgeColor.GreenAlt -> TangemTheme.colors2.markers.borderTintedGreenAlt }, shape = shape, width = 1.dp, @@ -410,6 +426,7 @@ private class TangemBadgePreviewProvider : PreviewParameterProvider Unit, val onCloseClick: () -> Unit, val onPromoShown: () -> Unit = {}, - ) : PromoBannerUM() + ) : PromoBannerUM() { + enum class Type { + Yield, + Staking, + } + } data object Empty : PromoBannerUM() } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt index fb7b25744f..e4a05a37db 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt @@ -30,6 +30,8 @@ internal object TangemTokenRowPreviewData { val promoBannerUM: TangemTokenRowUM.PromoBannerUM.Content get() = TangemTokenRowUM.PromoBannerUM.Content( title = stringReference("Earn yield by supplying your crypto assets"), + iconRes = R.drawable.ic_yield_mode_mini_12, + type = TangemTokenRowUM.PromoBannerUM.Content.Type.Yield, onPromoBannerClick = {}, onPromoShown = {}, onCloseClick = {}, diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPromoBanner.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPromoBanner.kt index 013204a20f..e7cf89db35 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPromoBanner.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPromoBanner.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.tangem.core.ui.R import com.tangem.core.ui.ds.badge.* import com.tangem.core.ui.ds.image.TangemIconUM @@ -39,7 +41,18 @@ internal fun TokenRowPromoBanner(promoBannerUM: TangemTokenRowUM.PromoBannerUM.C LaunchedEffect(promoBannerUM) { promoBannerUM.onPromoShown() } - val bgColor = TangemTheme.colors2.markers.backgroundTintedGreen + val bgColor = when (promoBannerUM.type) { + TangemTokenRowUM.PromoBannerUM.Content.Type.Yield -> TangemTheme.colors2.markers.backgroundTintedGreenAlt + TangemTokenRowUM.PromoBannerUM.Content.Type.Staking -> TangemTheme.colors2.markers.backgroundTintedBlue + } + val contentColor = when (promoBannerUM.type) { + TangemTokenRowUM.PromoBannerUM.Content.Type.Yield -> TangemTheme.colors2.markers.textGreenAlt + TangemTokenRowUM.PromoBannerUM.Content.Type.Staking -> TangemTheme.colors2.markers.textBlue + } + val badgeColor = when (promoBannerUM.type) { + TangemTokenRowUM.PromoBannerUM.Content.Type.Yield -> TangemBadgeColor.GreenAlt + TangemTokenRowUM.PromoBannerUM.Content.Type.Staking -> TangemBadgeColor.Blue + } Column( modifier = modifier, ) { @@ -63,9 +76,9 @@ internal fun TokenRowPromoBanner(promoBannerUM: TangemTokenRowUM.PromoBannerUM.C horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), ) { Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_analytics_up_24), + imageVector = ImageVector.vectorResource(id = promoBannerUM.iconRes), contentDescription = null, - tint = TangemTheme.colors2.markers.textGreen, + tint = contentColor, modifier = Modifier .padding(vertical = TangemTheme.dimens2.x0_5) .size(TangemTheme.dimens2.x3), @@ -73,14 +86,14 @@ internal fun TokenRowPromoBanner(promoBannerUM: TangemTokenRowUM.PromoBannerUM.C Text( text = promoBannerUM.title.resolveReference(), style = TangemTheme.typography2.captionSemibold11, - color = TangemTheme.colors2.markers.textGreen, + color = contentColor, modifier = Modifier .padding(vertical = TangemTheme.dimens2.x0_5), ) TangemBadge( size = TangemBadgeSize.X4, shape = TangemBadgeShape.Rounded, - color = TangemBadgeColor.Green, + color = badgeColor, type = TangemBadgeType.Tinted, tangemIconUM = TangemIconUM.Icon(R.drawable.ic_close_24), iconPosition = TangemBadgeIconPosition.None, @@ -90,14 +103,30 @@ internal fun TokenRowPromoBanner(promoBannerUM: TangemTokenRowUM.PromoBannerUM.C } } +// region Preview @Preview(widthDp = 360, showBackground = true) @Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun Preview_TokenRowPromoBanner() { +private fun Preview_TokenRowPromoBanner( + @PreviewParameter(TangemTokenRowPromoBannerPreviewProvider::class) + promoType: TangemTokenRowUM.PromoBannerUM.Content.Type, +) { TangemThemePreviewRedesign { TokenRowPromoBanner( - promoBannerUM = TangemTokenRowPreviewData.promoBannerUM, - modifier = Modifier.fillMaxWidth(), + promoBannerUM = TangemTokenRowPreviewData.promoBannerUM.copy(type = promoType), + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level1), ) } -} \ No newline at end of file +} + +internal class TangemTokenRowPromoBannerPreviewProvider : + PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemTokenRowUM.PromoBannerUM.Content.Type.Yield, + TangemTokenRowUM.PromoBannerUM.Content.Type.Staking, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_yield_mode_default_24.xml b/core/ui/src/main/res/drawable/ic_yield_mode_default_24.xml new file mode 100644 index 0000000000..3237c3b395 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_yield_mode_default_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_yield_mode_mini_12.xml b/core/ui/src/main/res/drawable/ic_yield_mode_mini_12.xml new file mode 100644 index 0000000000..d07223975e --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_yield_mode_mini_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt index ed2a68e455..c1794a9422 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt @@ -46,38 +46,38 @@ internal class WalletTokenCurrencyItemConverter( stakingApyMap = stakingAvailabilityMap, ) - override fun convert(currencyStatus: CryptoCurrencyStatus): TangemTokenRowUM { - val earnApyInfo = earnApyConverter.convert(currencyStatus) + override fun convert(value: CryptoCurrencyStatus): TangemTokenRowUM { + val earnApyInfo = earnApyConverter.convert(value) return TangemTokenRowUM.Content( - id = currencyStatus.currency.id.value, + id = value.currency.id.value, headIconUM = TangemIconUM.Currency( - currencyIconState = currencyToIconStateConverter.convert(currencyStatus), + currencyIconState = currencyToIconStateConverter.convert(value), ), - titleUM = toCurrencyRowTitle(currencyStatus, earnApyInfo), - subtitleUM = toCurrencyRowSubtitle(currencyStatus), - topEndContentUM = toCurrencyRowTopEnd(currencyStatus), - bottomEndContentUM = toCurrencyRowBottomEnd(currencyStatus), + titleUM = toCurrencyRowTitle(value, earnApyInfo), + subtitleUM = toCurrencyRowSubtitle(value), + topEndContentUM = toCurrencyRowTopEnd(value), + bottomEndContentUM = toCurrencyRowBottomEnd(value), promoBannerUM = toPromoBannerUM( accountId, - currencyStatus, + value, earnApyInfo.takeIf { shouldShowPromo }, ), - onItemClick = when (currencyStatus.value) { + onItemClick = when (value.value) { CryptoCurrencyStatus.Loading, is CryptoCurrencyStatus.MissedDerivation, -> null else -> { { - clickIntents.onTokenItemClick(accountId, currencyStatus) + clickIntents.onTokenItemClick(accountId, value) } } }, - onItemLongClick = when (currencyStatus.value) { + onItemLongClick = when (value.value) { CryptoCurrencyStatus.Loading -> null else -> { { - clickIntents.onTokenItemLongClick(accountId, currencyStatus) + clickIntents.onTokenItemLongClick(accountId, value) } } }, @@ -280,6 +280,8 @@ internal class WalletTokenCurrencyItemConverter( R.string.yield_module_main_screen_promo_banner_message, wrappedList(earnApyInfo.apy), ), + iconRes = R.drawable.ic_yield_mode_mini_12, + type = TangemTokenRowUM.PromoBannerUM.Content.Type.Yield, onPromoBannerClick = { clickIntents.onYieldPromoClicked(currency) clickIntents.onApyLabelClick( From a4aa242f79a33a9bd7e4007abd1243b8e0a318d8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Mar 2026 18:22:49 +0500 Subject: [PATCH 31/60] Updated on 2026-08-14 --- core/res/src/main/res/values-ja/strings.xml | 1 + core/res/src/main/res/values-ru/strings.xml | 1 + core/res/src/main/res/values/strings.xml | 4 ++ .../wallet/ui/components/MarketsHint.kt | 58 +++++++------------ 4 files changed, 27 insertions(+), 37 deletions(-) diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index e1c2294119..db979e464e 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -692,6 +692,7 @@ Manaレベル 暗号資産および取引の追跡を開始するには、トークンを追加してください トークンの管理 + QRコードをスキャンして送金するか、アプリに接続します。 すべてのネットワークにアクセスするには、カードをスキャンする必要があります。 カードまたはリングをスキャンする 2月%2$s - %3$sの期間、Changelly経由のスワップは%1$sのサービス手数料となります。 diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 32e497df40..c727172575 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -843,6 +843,7 @@ Объем торгов (24ч) Общая сумма криптовалюты, которая была продана за последние 24 часа, показывающая уровень активности и ликвидности на рынке. Объем торгов (24ч) + %s в сумме Объем Потяните вверх или коснитесь поисковой строки, чтобы добавить токены напрямую из рынка Добавить токены diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 7e5f655ad3..3d4d8d7a48 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -737,6 +737,8 @@ Earn with Tangem To generate addresses for selected networks, you must scan your Tangem Wallet card or ring To add tokens pull this up or tap the search bar + Swipe up to explore the market + Find new hidden gems This section’s data is sourced from the following networks: %s Unable to load the data… No data @@ -826,9 +828,11 @@ Total supply The maximum number of coins or tokens that can ever exist for a particular cryptocurrency Total supply + 24h Trading volume (24h) The total amount of a cryptocurrency that has been traded within the last 24 hours, indicating the level of activity and liquidity in the market Trading volume (24h) + %s in total Volume Pull this up or tap the search bar to add tokens directly from the market Add tokens diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/MarketsHint.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/MarketsHint.kt index 32c059a463..c34f8e901b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/MarketsHint.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/MarketsHint.kt @@ -5,9 +5,10 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,17 +16,13 @@ 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.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign 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.TangemThemePreviewRedesign import com.tangem.feature.wallet.impl.R -private const val STARS_INLINE_CONTENT_ID = "stars" - @Composable internal fun MarketsHint(isVisible: Boolean, modifier: Modifier = Modifier) { AnimatedVisibility( @@ -36,41 +33,28 @@ internal fun MarketsHint(isVisible: Boolean, modifier: Modifier = Modifier) { ) { Column( horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), ) { Text( - text = "Swipe up to explore the market", // todo redesign main lokalise - style = TangemTheme.typography2.bodyRegular14, + text = stringResourceSafe(R.string.markets_hint_part_one), + style = TangemTheme.typography2.bodyRegular15, color = TangemTheme.colors2.text.neutral.primary, textAlign = TextAlign.Center, ) - Text( - text = buildAnnotatedString { - append("Find new hidden gems ") // todo redesign main lokalise - appendInlineContent( - STARS_INLINE_CONTENT_ID, - alternateText = "\uDBC0\uDDBF", - ) - }, - inlineContent = mapOf( - STARS_INLINE_CONTENT_ID to InlineTextContent( - placeholder = Placeholder( - width = TangemTheme.typography2.bodyRegular14.fontSize, - height = TangemTheme.typography2.bodyRegular14.fontSize, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - children = { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_magic_default_24), - tint = TangemTheme.colors2.text.neutral.tertiary, - contentDescription = null, - ) - }, - ), - ), - style = TangemTheme.typography2.bodyRegular14, - color = TangemTheme.colors2.text.neutral.tertiary, - textAlign = TextAlign.Center, - ) + Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) { + Text( + text = stringResourceSafe(R.string.markets_hint_part_two), + style = TangemTheme.typography2.bodyRegular15, + color = TangemTheme.colors2.text.neutral.tertiary, + textAlign = TextAlign.Center, + ) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_magic_default_24), + tint = TangemTheme.colors2.text.neutral.tertiary, + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens2.x5), + ) + } } } } From 07f94d3a9554862ab740ec965f403609d46d1c38 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Mar 2026 17:55:33 +0300 Subject: [PATCH 32/60] Updated on 2026-08-14 --- .../AppsFlyerReferralParamsHandler.kt | 7 +-- .../appsflyer/AppsFlyerAnalyticsClient.kt | 8 +--- .../tap/core/DefaultAppCoroutineScope.kt | 44 +++++++++++++++++ .../java/com/tangem/tap/di/ActivityModule.kt | 7 ++- .../tangem/tap/di/TangemSdkManagerModule.kt | 6 +-- .../java/com/tangem/tap/di/UtilsModule.kt | 36 +++++++++----- .../tap/di/domain/TransactionDomainModule.kt | 8 ++-- .../tap/di/domain/YieldSupplyDomainModule.kt | 7 ++- .../sdk/impl/DefaultTangemSdkManager.kt | 6 +-- .../AppsFlyerReferralParamsHandlerTest.kt | 4 +- .../common/test/TestAppCoroutineScope.kt | 11 +++++ .../core/abtests/di/ABTestsManagerModule.kt | 8 ++-- .../manager/impl/AmplitudeABTestsManager.kt | 4 +- .../config/managers/DevApiConfigsManager.kt | 8 ++-- .../config/managers/MockApiConfigsManager.kt | 22 ++++++--- .../di/AppPreferencesStoreModule.kt | 4 +- .../datasource/di/ExpressAssetsStoreModule.kt | 8 ++-- .../com/tangem/datasource/di/NetworkModule.kt | 8 ++-- .../datasource/di/StakingStoreModule.kt | 20 ++++---- .../di/TokenReceiveWarningModule.kt | 8 ++-- .../datasource/di/WalletConnectModule.kt | 7 +-- .../tangem/datasource/di/YieldSupplyModule.kt | 8 ++-- .../datasource/local/logs/AppLogsStore.kt | 7 +-- .../local/nft/NFTPersistenceStoreFactory.kt | 8 ++-- .../local/preferences/PreferencesDataStore.kt | 12 ++--- .../utils/coroutines/AppCoroutineScope.kt | 5 ++ .../tangem/utils/coroutines/DelayedWork.kt | 7 --- .../utils/di/DelayedWorkCoroutineModule.kt | 23 --------- .../data/account/di/AccountDataModule.kt | 11 ++--- .../store/AccountsResponseStoreFactory.kt | 8 ++-- .../DefaultMainAccountTokensMigration.kt | 8 +--- .../store/AccountsResponseStoreFactoryTest.kt | 4 +- .../DefaultMainAccountTokensMigrationTest.kt | 4 +- .../tangem/data/common/di/DataCommonModule.kt | 6 +-- .../common/quote/DefaultQuotesFetcherTest.kt | 6 +-- .../data/networks/di/NetworkDataModule.kt | 9 ++-- .../store/DefaultNetworksStatusesStore.kt | 6 +-- .../com/tangem/data/networks/store/GetTest.kt | 4 +- .../data/networks/store/InitializationTest.kt | 8 ++-- .../store/ParameterizedStoreStatusTest.kt | 4 +- .../store/ParameterizedStoreSuccessTest.kt | 4 +- .../networks/store/ParameterizedStoreTest.kt | 4 +- .../networks/store/SetSourceAsCacheTest.kt | 4 +- .../store/SetSourceAsOnlyCacheTest.kt | 4 +- .../data/networks/store/StoreStatusTest.kt | 4 +- .../data/networks/store/StoreSuccessTest.kt | 4 +- .../tangem/data/networks/store/StoreTest.kt | 4 +- .../networks/store/UpdateStatusSourceTest.kt | 4 +- .../data/onramp/DefaultHotCryptoRepository.kt | 7 +-- .../tangem/data/onramp/di/OnrampDataModule.kt | 3 ++ .../tangem/data/quotes/di/QuotesDataModule.kt | 14 +++--- .../quotes/multi/DefaultMultiQuoteUpdater.kt | 7 +-- .../store/DefaultQuotesStatusesStore.kt | 8 +--- .../DefaultMultiQuoteStatusUpdaterTest.kt | 4 +- .../store/QuotesStatusesStoreExtTest.kt | 4 +- .../quotes/store/QuotesStatusesStoreTest.kt | 10 ++-- .../di/StakingBalanceSupplierModule.kt | 10 ++-- .../store/DefaultP2PEthPoolBalancesStore.kt | 8 +--- .../store/DefaultStakeKitBalancesStore.kt | 8 +--- .../StakingBalancesStoreGetMethodTest.kt | 4 +- .../StakingBalancesStoreInitializationTest.kt | 8 ++-- .../StakingBalancesStoreUpdateMethodsTest.kt | 4 +- .../pay/DefaultTangemPayEligibilityManager.kt | 5 +- .../tangem/data/pay/di/TangemPayDataModule.kt | 8 ++-- .../DefaultTangemPayCardDetailsRepository.kt | 3 +- .../DefaultTangemPayWithdrawRepository.kt | 3 +- .../pay/store/PaymentAccountStatusesStore.kt | 8 +--- .../di/WalletConnectDataModule.kt | 14 ++---- .../walletconnect/pair/WcPairSdkDelegate.kt | 4 +- .../sessions/DefaultWcSessionsManager.kt | 3 +- .../data/walletconnect/utils/WcScope.kt | 13 ----- .../wallets/hot/DefaultHotWalletAccessor.kt | 8 +--- domain/account/status/build.gradle.kts | 1 + .../AccountStatusListProducerFactoryModule.kt | 5 -- .../status/di/AccountStatusUseCaseModule.kt | 10 ++-- .../producer/DefaultFlowProducerTools.kt | 48 +++---------------- .../usecase/ManageCryptoCurrenciesUseCase.kt | 3 +- .../tangem/domain/core/flow/FlowProducer.kt | 3 -- .../usecase/SendTransactionUseCase.kt | 4 +- .../usecase/YieldSupplyPendingTracker.kt | 4 +- .../usecase/YieldSupplyPendingTrackerTest.kt | 3 +- .../send/v2/common/SendBalanceUpdater.kt | 11 +++-- .../impl/presentation/model/StakingModel.kt | 4 +- .../state/helpers/StakingBalanceUpdater.kt | 4 +- .../utils/AccountListSortingSaver.kt | 8 +--- .../di/BlockchainSDKFactoryModule.kt | 7 ++- 86 files changed, 317 insertions(+), 387 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/core/DefaultAppCoroutineScope.kt create mode 100644 common/test/src/main/java/com/tangem/common/test/TestAppCoroutineScope.kt create mode 100644 core/utils/src/main/java/com/tangem/utils/coroutines/AppCoroutineScope.kt delete mode 100644 core/utils/src/main/java/com/tangem/utils/coroutines/DelayedWork.kt delete mode 100644 core/utils/src/main/java/com/tangem/utils/di/DelayedWorkCoroutineModule.kt delete mode 100644 data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcScope.kt diff --git a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt index cd9e78634b..a643b0142e 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt @@ -2,11 +2,9 @@ package com.tangem.tap.common.analytics.appsflyer import com.appsflyer.deeplink.DeepLink import com.tangem.datasource.local.appsflyer.AppsFlyerStore +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.wallets.models.AppsFlyerConversionData import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -20,10 +18,9 @@ import kotlin.contracts.contract class AppsFlyerReferralParamsHandler @Inject constructor( private val appsFlyerStore: AppsFlyerStore, private val setShouldShowMobileWalletPromoUseCase: SetShouldShowMobileWalletPromoUseCase, - dispatchers: CoroutineDispatcherProvider, + private val coroutineScope: AppCoroutineScope, ) { - private val coroutineScope = CoroutineScope(dispatchers.io + SupervisorJob()) private val mutex = Mutex() fun handle(deepLink: DeepLink) { 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 index f5aa24257d..2f07e571b1 100644 --- 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 @@ -6,16 +6,14 @@ import com.appsflyer.attribution.AppsFlyerRequestListener import com.tangem.core.analytics.api.EventLogger import com.tangem.core.analytics.api.UserIdHolder import com.tangem.datasource.local.appsflyer.AppsFlyerStore +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener import com.tangem.tap.common.analytics.appsflyer.TangemAFConversionListener import com.tangem.tap.common.analytics.handlers.firebase.UnderscoreAnalyticsEventConverter -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import timber.log.Timber @@ -26,15 +24,13 @@ class AppsFlyerClient @AssistedInject constructor( @ApplicationContext private val context: Context, appsFlyerDeepLinkListener: AppsFlyerDeepLinkListener, tangemAFConversionListener: TangemAFConversionListener, - dispatchers: CoroutineDispatcherProvider, private val appsFlyerStore: AppsFlyerStore, + private val coroutineScope: AppCoroutineScope, ) : AppsFlyerAnalyticsClient { private val appsFlyerLib: AppsFlyerLib = AppsFlyerLib.getInstance() private val eventConverter = UnderscoreAnalyticsEventConverter() - private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default) - init { with(appsFlyerLib) { setAppId(context.packageName) diff --git a/app/src/main/java/com/tangem/tap/core/DefaultAppCoroutineScope.kt b/app/src/main/java/com/tangem/tap/core/DefaultAppCoroutineScope.kt new file mode 100644 index 0000000000..190af9d4eb --- /dev/null +++ b/app/src/main/java/com/tangem/tap/core/DefaultAppCoroutineScope.kt @@ -0,0 +1,44 @@ +package com.tangem.tap.core + +import co.touchlab.kermit.Logger +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.SupervisorJob +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext + +internal class DefaultAppCoroutineScope @Inject constructor( + dispatchers: CoroutineDispatcherProvider, + private val analyticsExceptionHandler: AnalyticsExceptionHandler, +) : AppCoroutineScope { + + private val tag = "AppCoroutineScope" + + override val coroutineContext: CoroutineContext = SupervisorJob() + + // keep IO dispatcher to avoid blocking Default with IO operations + dispatchers.io + + CoroutineName(tag) + + CoroutineExceptionHandler { context, throwable -> + val coroutineName = context[CoroutineName]?.name.orEmpty() + logError(throwable, coroutineName) + } + + private fun logError(throwable: Throwable, coroutineName: String) { + Logger.withTag(tag).e( + messageString = "CoroutineName $coroutineName", + throwable = throwable, + ) + val event = ExceptionAnalyticsEvent( + exception = throwable, + params = mapOf( + "source" to tag, + "coroutineName" to coroutineName, + ), + ) + analyticsExceptionHandler.sendException(event) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/ActivityModule.kt b/app/src/main/java/com/tangem/tap/di/ActivityModule.kt index e5600cc208..e3e54d1699 100644 --- a/app/src/main/java/com/tangem/tap/di/ActivityModule.kt +++ b/app/src/main/java/com/tangem/tap/di/ActivityModule.kt @@ -4,6 +4,7 @@ import com.tangem.datasource.api.moonpay.MoonPayApi import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -21,8 +22,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -62,8 +61,8 @@ internal object ActivityModule { @Provides @Singleton @DelayedWork - fun provideActivityDelayedWorkCoroutineScope(): CoroutineScope { - return CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun provideActivityDelayedWorkCoroutineScope(appScope: AppCoroutineScope): CoroutineScope { + return appScope } @Provides diff --git a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt index 53f53dffef..8febdb45f1 100644 --- a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt @@ -8,6 +8,7 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.domain.card.BuildConfig import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.sdk.api.TangemSdkManager @@ -17,7 +18,6 @@ import com.tangem.tap.domain.tasks.product.BlockchainToDeriveFinder import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask import com.tangem.tap.domain.visa.VisaCardScanHandler -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -44,7 +44,7 @@ internal class TangemSdkManagerModule { analyticsExceptionHandler: AnalyticsExceptionHandler, blockchainToDeriveFinder: BlockchainToDeriveFinder, analyticsEventHandler: AnalyticsEventHandler, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): TangemSdkManager { return if (BuildConfig.MOCK_DATA_SOURCE) { MockTangemSdkManager(resources = context.resources) @@ -62,7 +62,7 @@ internal class TangemSdkManagerModule { analyticsExceptionHandler = analyticsExceptionHandler, blockchainToDeriveFinder = blockchainToDeriveFinder, analyticsEventHandler = analyticsEventHandler, - dispatchers = dispatchers, + coroutineScope = appScope, ) } } diff --git a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt index 7fdf0263cc..8ee75b9d1a 100644 --- a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt @@ -5,10 +5,13 @@ import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.core.navigation.settings.SettingsManager import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.tap.common.finisher.AndroidAppFinisher import com.tangem.tap.common.settings.IntentSettingsManager import com.tangem.tap.common.share.IntentShareManager import com.tangem.tap.common.url.CustomTabsUrlOpener +import com.tangem.tap.core.DefaultAppCoroutineScope +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,21 +21,28 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal object UtilsModule { +internal interface UtilsModule { - @Provides - @Singleton - fun provideShareManager(): ShareManager = IntentShareManager() + @Binds + fun provideAppScope(defaultAppScope: DefaultAppCoroutineScope): AppCoroutineScope - @Provides - @Singleton - fun provideUrlOpener(): UrlOpener = CustomTabsUrlOpener() + companion object { - @Provides - @Singleton - fun provideAppFinisher(@ApplicationContext context: Context): AppFinisher = AndroidAppFinisher(context) + @Provides + @Singleton + fun provideShareManager(): ShareManager = IntentShareManager() - @Provides - @Singleton - fun provideSettingsManager(@ApplicationContext context: Context): SettingsManager = IntentSettingsManager(context) + @Provides + @Singleton + fun provideUrlOpener(): UrlOpener = CustomTabsUrlOpener() + + @Provides + @Singleton + fun provideAppFinisher(@ApplicationContext context: Context): AppFinisher = AndroidAppFinisher(context) + + @Provides + @Singleton + fun provideSettingsManager(@ApplicationContext context: Context): SettingsManager = + IntentSettingsManager(context) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 16a63b51aa..cfeacf9297 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 @@ -4,6 +4,7 @@ import com.tangem.data.wallets.hot.TangemHotWalletSigner import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier @@ -18,13 +19,10 @@ import com.tangem.domain.transaction.WalletAddressServiceRepository import com.tangem.domain.transaction.usecase.* import com.tangem.domain.transaction.usecase.gasless.* import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.utils.coroutines.CoroutineDispatcherProvider 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 @Suppress("TooManyFunctions", "LargeClass") @@ -55,8 +53,8 @@ internal object TransactionDomainModule { walletManagersFacade: WalletManagersFacade, singleNetworkStatusFetcher: SingleNetworkStatusFetcher, tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory, - dispatchers: CoroutineDispatcherProvider, pushNotificationsRepository: PushNotificationsRepository, + appScope: AppCoroutineScope, ): SendTransactionUseCase { return SendTransactionUseCase( demoConfig = DemoConfig, @@ -64,7 +62,7 @@ internal object TransactionDomainModule { transactionRepository = transactionRepository, walletManagersFacade = walletManagersFacade, singleNetworkStatusFetcher = singleNetworkStatusFetcher, - parallelUpdatingScope = CoroutineScope(SupervisorJob() + dispatchers.io), + parallelUpdatingScope = appScope, getHotWalletSigner = tangemHotWalletSignerFactory::create, pushNotificationsRepository = pushNotificationsRepository, ) diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt index c893ae8a08..fbc69fcf80 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -1,6 +1,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.blockaid.BlockAidGasEstimate +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -15,8 +16,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 @Suppress("TooManyFunctions") @@ -253,12 +252,12 @@ internal object YieldSupplyDomainModule { fun provideYieldSupplyPendingProcessorUseCase( yieldSupplyRepository: YieldSupplyRepository, singleNetworkStatusFetcher: SingleNetworkStatusFetcher, - dispatcherProvider: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): YieldSupplyPendingTracker { return YieldSupplyPendingTracker( yieldSupplyRepository = yieldSupplyRepository, singleNetworkStatusFetcher = singleNetworkStatusFetcher, - coroutineScope = CoroutineScope(SupervisorJob() + dispatcherProvider.io), + coroutineScope = appScope, ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index caf25e2b96..9736bca843 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -34,6 +34,7 @@ import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.scan.CardDTO @@ -65,7 +66,6 @@ import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask import com.tangem.tap.domain.twins.CreateSecondTwinWalletTask import com.tangem.tap.domain.twins.FinalizeTwinTask import com.tangem.tap.domain.visa.VisaCardScanHandler -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.wallet.R import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex @@ -86,7 +86,7 @@ internal class DefaultTangemSdkManager( private val analyticsExceptionHandler: AnalyticsExceptionHandler, private val blockchainToDeriveFinder: BlockchainToDeriveFinder, private val analyticsEventHandler: AnalyticsEventHandler, - dispatchers: CoroutineDispatcherProvider, + private val coroutineScope: AppCoroutineScope, ) : TangemSdkManager { private val awaitInitializationMutex = Mutex() @@ -115,8 +115,6 @@ internal class DefaultTangemSdkManager( override val userCodeRequestPolicy: UserCodeRequestPolicy get() = tangemSdk.config.userCodeRequestPolicy - private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.io) - override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean { return try { needEnrollBiometrics diff --git a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt index e83e93807b..42ca5e34cc 100644 --- a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt @@ -5,8 +5,8 @@ import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.domain.wallets.models.AppsFlyerConversionData import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase import com.tangem.test.core.ProvideTestModels -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import arrow.core.right +import com.tangem.common.test.TestAppCoroutineScope import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify @@ -30,7 +30,7 @@ class AppsFlyerReferralParamsHandlerTest { } private val handler = AppsFlyerReferralParamsHandler( appsFlyerStore = appsFlyerStore, - dispatchers = TestingCoroutineDispatcherProvider(), + coroutineScope = TestAppCoroutineScope(), setShouldShowMobileWalletPromoUseCase = setShouldShowMobileWalletPromoUseCase, ) diff --git a/common/test/src/main/java/com/tangem/common/test/TestAppCoroutineScope.kt b/common/test/src/main/java/com/tangem/common/test/TestAppCoroutineScope.kt new file mode 100644 index 0000000000..5baa3c239d --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/TestAppCoroutineScope.kt @@ -0,0 +1,11 @@ +package com.tangem.common.test + +import com.tangem.utils.coroutines.AppCoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestScope +import kotlin.coroutines.CoroutineContext + +class TestAppCoroutineScope(override val coroutineContext: CoroutineContext = Dispatchers.Unconfined) : AppCoroutineScope { + + constructor(testScope: TestScope) : this(testScope.coroutineContext) +} \ No newline at end of file diff --git a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/di/ABTestsManagerModule.kt b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/di/ABTestsManagerModule.kt index 30c1e3b07e..79dde6cead 100644 --- a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/di/ABTestsManagerModule.kt +++ b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/di/ABTestsManagerModule.kt @@ -6,13 +6,11 @@ import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.abtests.manager.impl.AmplitudeABTestsManager import com.tangem.core.abtests.manager.impl.StubABTestsManager import com.tangem.datasource.local.config.environment.EnvironmentConfig -import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.AppCoroutineScope 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 @@ -24,7 +22,7 @@ internal object ABTestsManagerModule { fun provideABTestsManager( application: Application, environmentConfig: EnvironmentConfig, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): ABTestsManager { return if (BuildConfig.AB_TESTS_ENABLED) { StubABTestsManager() @@ -32,7 +30,7 @@ internal object ABTestsManagerModule { AmplitudeABTestsManager( application = application, apiKey = environmentConfig.amplitudeApiKey, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = appScope, ) } } diff --git a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt index 2307363d90..703c717a79 100644 --- a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt +++ b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt @@ -7,14 +7,14 @@ import com.amplitude.experiment.ExperimentConfig import com.amplitude.experiment.ExperimentUser import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.models.AnalyticsParam -import kotlinx.coroutines.CoroutineScope +import com.tangem.utils.coroutines.AppCoroutineScope import kotlinx.coroutines.launch import timber.log.Timber internal class AmplitudeABTestsManager( val application: Application, val apiKey: String, - val scope: CoroutineScope, + val scope: AppCoroutineScope, ) : ABTestsManager { private lateinit var client: ExperimentClient diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt index 3bbd41372d..d52c2d3a96 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt @@ -7,9 +7,7 @@ import com.tangem.datasource.api.common.config.ApiEnvironmentConfig import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectMap -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob +import com.tangem.utils.coroutines.AppCoroutineScope import kotlinx.coroutines.flow.* /** @@ -22,7 +20,7 @@ import kotlinx.coroutines.flow.* internal class DevApiConfigsManager( private val apiConfigs: ApiConfigs, private val appPreferencesStore: AppPreferencesStore, - private val dispatchers: CoroutineDispatcherProvider, + private val appScope: AppCoroutineScope, ) : MutableApiConfigsManager() { override val configs: StateFlow> @@ -51,7 +49,7 @@ internal class DevApiConfigsManager( notifyListeners(apiConfigs = apiConfigs, savedEnvironments = savedEnvironments) } - .launchIn(CoroutineScope(SupervisorJob() + dispatchers.default)) + .launchIn(appScope) } override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/MockApiConfigsManager.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/MockApiConfigsManager.kt index 10318bb1d7..7f6fbccbfa 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/MockApiConfigsManager.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/MockApiConfigsManager.kt @@ -4,10 +4,22 @@ import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiConfigs import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.datasource.api.common.config.ApiEnvironmentConfig -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob +import com.tangem.utils.coroutines.AppCoroutineScope import kotlinx.coroutines.flow.* +import kotlin.Boolean +import kotlin.String +import kotlin.Unit +import kotlin.collections.Map +import kotlin.collections.any +import kotlin.collections.associateWith +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.first +import kotlin.collections.firstOrNull +import kotlin.collections.mapValues +import kotlin.collections.plus +import kotlin.error +import kotlin.to /** * Implementation of [ApiConfigsManager] in MOCK environment @@ -18,7 +30,7 @@ import kotlinx.coroutines.flow.* */ internal class MockApiConfigsManager( private val apiConfigs: ApiConfigs, - dispatchers: CoroutineDispatcherProvider, + private val coroutineScope: AppCoroutineScope, ) : MutableApiConfigsManager() { override val configs: StateFlow> @@ -26,8 +38,6 @@ internal class MockApiConfigsManager( override val initializedState: StateFlow = MutableStateFlow(value = true) - private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default) - override fun initialize() = Unit override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig { diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt index d572234840..c5262e4825 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt @@ -3,6 +3,7 @@ package com.tangem.datasource.di import android.content.Context import com.squareup.moshi.Moshi import com.tangem.datasource.local.preferences.* +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -20,10 +21,11 @@ internal object AppPreferencesStoreModule { fun provideAppPreferencesStore( @ApplicationContext appContext: Context, dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, @SdkMoshi moshi: Moshi, ): AppPreferencesStore { return AppPreferencesStore( - preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, dispatcher = dispatchers.io), + preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, appScope = appScope), moshi = moshi, dispatchers = dispatchers, ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ExpressAssetsStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ExpressAssetsStoreModule.kt index 512085bd3e..ef1befd7a1 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ExpressAssetsStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ExpressAssetsStoreModule.kt @@ -11,14 +11,12 @@ import com.tangem.datasource.local.token.ExpressAssetsStore import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.listTypes import com.tangem.datasource.utils.mapWithStringKeyTypes -import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -30,7 +28,7 @@ internal object ExpressAssetsStoreModule { fun provideExpressAssetsStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): ExpressAssetsStore { return DefaultExpressAssetsStore( persistenceStore = DataStoreFactory.create( @@ -40,7 +38,7 @@ internal object ExpressAssetsStoreModule { defaultValue = emptyMap(), ), produceFile = { context.dataStoreFile("express_assets") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = appScope, ), runtimeStore = RuntimeDataStore(), ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 25833cc27c..6689718905 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -25,7 +25,7 @@ import com.tangem.datasource.api.visa.VisaApi import com.tangem.datasource.di.utils.RetrofitApiBuilder import com.tangem.datasource.di.utils.RetrofitApiBuilder.Timeouts import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -47,11 +47,11 @@ internal object NetworkModule { fun provideApiConfigManager( apiConfigs: ApiConfigs, appPreferencesStore: AppPreferencesStore, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): ApiConfigsManager { return when { - BuildConfig.BUILD_TYPE == MOCKED_BUILD_TYPE -> MockApiConfigsManager(apiConfigs, dispatchers) - BuildConfig.TESTER_MENU_ENABLED -> DevApiConfigsManager(apiConfigs, appPreferencesStore, dispatchers) + BuildConfig.BUILD_TYPE == MOCKED_BUILD_TYPE -> MockApiConfigsManager(apiConfigs, appScope) + BuildConfig.TESTER_MENU_ENABLED -> DevApiConfigsManager(apiConfigs, appPreferencesStore, appScope) else -> ProdApiConfigsManager(apiConfigs) } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt index d4bd6b3e65..25cedcd5bc 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt @@ -20,14 +20,12 @@ import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.listTypes import com.tangem.datasource.utils.mapWithStringKeyTypes import com.tangem.datasource.utils.setTypes -import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -39,7 +37,7 @@ internal object StakingStoreModule { fun provideStakingTokensStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): StakingYieldsStore { return DefaultStakingYieldsStore( dataStore = DataStoreFactory.create( @@ -49,7 +47,7 @@ internal object StakingStoreModule { defaultValue = emptyList(), ), produceFile = { context.dataStoreFile(fileName = "yields_cache") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = appScope, ), ) } @@ -59,7 +57,7 @@ internal object StakingStoreModule { fun provideYieldsBalancesPersistenceStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): DataStore>> { return DataStoreFactory.create( serializer = MoshiDataStoreSerializer( @@ -68,7 +66,7 @@ internal object StakingStoreModule { defaultValue = emptyMap(), ), produceFile = { context.dataStoreFile(fileName = "yield_balances") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = appScope, ) } @@ -83,7 +81,7 @@ internal object StakingStoreModule { fun provideP2PEthPoolBalancesPersistenceStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): DataStore>> { return DataStoreFactory.create( serializer = MoshiDataStoreSerializer( @@ -92,7 +90,7 @@ internal object StakingStoreModule { defaultValue = emptyMap(), ), produceFile = { context.dataStoreFile(fileName = "p2p_eth_pool_balances") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = appScope, ) } @@ -101,7 +99,7 @@ internal object StakingStoreModule { fun provideP2PEthPoolVaultsStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): P2PEthPoolVaultsStore { return DefaultP2PEthPoolVaultsStore( dataStore = DataStoreFactory.create( @@ -111,7 +109,7 @@ internal object StakingStoreModule { defaultValue = emptyList(), ), produceFile = { context.dataStoreFile(fileName = "p2p_eth_pool_vaults") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = appScope, ), ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/TokenReceiveWarningModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/TokenReceiveWarningModule.kt index c0aa4159dd..03cc572506 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/TokenReceiveWarningModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/TokenReceiveWarningModule.kt @@ -8,14 +8,12 @@ import com.tangem.datasource.local.token.DefaultTokenReceiveWarningActionStore import com.tangem.datasource.local.token.TokenReceiveWarningActionStore import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.setTypes -import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -27,7 +25,7 @@ object TokenReceiveWarningModule { fun provideTokenReceiveWarningStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): TokenReceiveWarningActionStore { return DefaultTokenReceiveWarningActionStore( persistenceStore = DataStoreFactory.create( @@ -37,7 +35,7 @@ object TokenReceiveWarningModule { defaultValue = emptySet(), ), produceFile = { context.dataStoreFile(fileName = "token_receive_warnings_viewed") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = appScope, ), ) } 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 e790ce4dc7..33d36f1edd 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,16 +8,14 @@ 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.utils.coroutines.AppCoroutineScope import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO import com.tangem.domain.walletconnect.model.WcSessionDTO -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -29,9 +27,8 @@ object WalletConnectModule { fun provideWalletConnectStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, + scope: AppCoroutineScope, ): WalletConnectStore { - val scope = CoroutineScope(context = dispatchers.io + SupervisorJob()) return DefaultWalletConnectStore( persistenceStore = DataStoreFactory.create( serializer = MoshiDataStoreSerializer( diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt index 179051f898..cb14310516 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt @@ -9,14 +9,12 @@ import com.tangem.datasource.local.yieldsupply.DefaultYieldMarketsStore import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.listTypes -import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -28,7 +26,7 @@ object YieldSupplyModule { fun provideYieldMarketsStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): YieldMarketsStore { return DefaultYieldMarketsStore( persistenceStore = DataStoreFactory.create( @@ -38,7 +36,7 @@ object YieldSupplyModule { defaultValue = emptyList(), ), produceFile = { context.dataStoreFile(fileName = "yield_markets_cache") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = appScope, ), ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt index 4cadac0967..007f386f0e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.local.logs import android.content.Context +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.* @@ -27,13 +28,9 @@ import javax.inject.Singleton class AppLogsStore @Inject constructor( @ApplicationContext private val applicationContext: Context, private val dispatchers: CoroutineDispatcherProvider, + private val scope: AppCoroutineScope, ) { - private val scope = CoroutineScope( - context = SupervisorJob() + dispatchers.io + - CoroutineExceptionHandler { _, error -> Timber.e("AppLogsStore.scope is failed $error") }, - ) - private val mutex = Mutex() private val zipMutex = Mutex() diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt index 2a5387357e..6b3694be92 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt @@ -10,12 +10,10 @@ import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.nft.custom.NFTPriceId import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.listTypes +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.network.Network 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.lang.reflect.ParameterizedType import javax.inject.Inject import javax.inject.Singleton @@ -24,7 +22,7 @@ import javax.inject.Singleton class NFTPersistenceStoreFactory @Inject constructor( @NetworkMoshi private val moshi: Moshi, @ApplicationContext private val context: Context, - private val dispatchers: CoroutineDispatcherProvider, + private val appScope: AppCoroutineScope, ) { fun provide(userWalletId: UserWalletId, network: Network): NFTPersistenceStore { @@ -61,7 +59,7 @@ class NFTPersistenceStoreFactory @Inject constructor( defaultValue = defaultValue, ), produceFile = { context.dataStoreFile(fileName = fileName) }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = appScope, ) private fun Network.ID.formatted(): String = rawId.value diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt index e91af36e84..72835aa183 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt @@ -16,10 +16,8 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_SWAP_PR import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_RING_PROMO_KEY import com.tangem.datasource.local.preferences.utils.CleanupKeyMigration import com.tangem.datasource.local.preferences.utils.SharedPreferencesKeyMigration -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob +import com.tangem.utils.coroutines.AppCoroutineScope import timber.log.Timber -import kotlin.coroutines.CoroutineContext /** * Application preferences data store 'DataStore'. @@ -35,15 +33,15 @@ internal object PreferencesDataStore { private var INSTANCE: DataStore? = null - fun getInstance(context: Context, dispatcher: CoroutineContext): DataStore { - return INSTANCE ?: create(context, dispatcher).also { INSTANCE = it } + fun getInstance(context: Context, appScope: AppCoroutineScope): DataStore { + return INSTANCE ?: create(context, appScope).also { INSTANCE = it } } - private fun create(context: Context, dispatcher: CoroutineContext): DataStore { + private fun create(context: Context, appScope: AppCoroutineScope): DataStore { return PreferenceDataStoreFactory.create( corruptionHandler = createCorruptionHandler(), migrations = createMigrations(context = context), - scope = CoroutineScope(context = dispatcher + SupervisorJob()), + scope = appScope, produceFile = { context.preferencesDataStoreFile(name = PREFERENCES_FILE_NAME) }, ) } diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/AppCoroutineScope.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/AppCoroutineScope.kt new file mode 100644 index 0000000000..e8f95bc46b --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/AppCoroutineScope.kt @@ -0,0 +1,5 @@ +package com.tangem.utils.coroutines + +import kotlinx.coroutines.CoroutineScope + +interface AppCoroutineScope : CoroutineScope \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/DelayedWork.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/DelayedWork.kt deleted file mode 100644 index 8c822c02ed..0000000000 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/DelayedWork.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tangem.utils.coroutines - -import javax.inject.Qualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class DelayedWork \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/di/DelayedWorkCoroutineModule.kt b/core/utils/src/main/java/com/tangem/utils/di/DelayedWorkCoroutineModule.kt deleted file mode 100644 index 4f5ad6e74f..0000000000 --- a/core/utils/src/main/java/com/tangem/utils/di/DelayedWorkCoroutineModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.utils.di - -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.DelayedWork -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 -@InstallIn(SingletonComponent::class) -object DelayedWorkCoroutineModule { - - @Provides - @Singleton - @DelayedWork - fun provideDelayedWorkCoroutineScope(coroutineDispatcherProvider: CoroutineDispatcherProvider): CoroutineScope { - return CoroutineScope(SupervisorJob() + coroutineDispatcherProvider.io) - } -} \ 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 5af224e026..b2ba244666 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 @@ -26,14 +26,13 @@ import com.tangem.datasource.utils.setTypes import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.repository.AccountsExpandedRepository import com.tangem.domain.account.tokens.MainAccountTokensMigration +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -69,7 +68,7 @@ internal object AccountDataModule { fun provideAccountsExpandedRepository( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): AccountsExpandedRepository { val store = DataStoreFactory.create>>( serializer = MoshiDataStoreSerializer( @@ -78,7 +77,7 @@ internal object AccountDataModule { defaultValue = emptyMap(), ), produceFile = { context.dataStoreFile(fileName = "account_expanded_store") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = appScope, ) return DefaultAccountsExpandedRepository( @@ -107,14 +106,14 @@ internal object AccountDataModule { userTokensSaver: UserTokensSaver, accountTokenMigrationStore: AccountTokenMigrationStore, eTagsStore: ETagsStore, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): DefaultMainAccountTokensMigration { return DefaultMainAccountTokensMigration( accountsResponseStoreFactory = accountsResponseStoreFactory, accountTokenMigrationStore = accountTokenMigrationStore, userTokensSaver = userTokensSaver, eTagsStore = eTagsStore, - dispatchers = dispatchers, + coroutineScope = appScope, ) } } \ 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 index df3f05aaa0..9beea3a5eb 100644 --- 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 @@ -10,11 +10,9 @@ 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.utils.coroutines.AppCoroutineScope 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 import javax.inject.Singleton @@ -35,7 +33,7 @@ typealias AccountsResponseStore = DataStore internal class AccountsResponseStoreFactory @Inject constructor( @ApplicationContext private val context: Context, @NetworkMoshi private val moshi: Moshi, - private val dispatchers: CoroutineDispatcherProvider, + private val appScope: AppCoroutineScope, ) { @OptIn(ExperimentalStdlibApi::class) @@ -53,7 +51,7 @@ internal class AccountsResponseStoreFactory @Inject constructor( DataStoreFactory.create( serializer = MoshiDataStoreSerializer(defaultValue = null, adapter = adapter), produceFile = { context.dataStoreFile(fileName = "wallet_accounts_${userWalletId.stringValue}") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = appScope, ) } } diff --git a/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt b/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt index 3973b17662..b48fff6967 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt @@ -20,12 +20,10 @@ import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.local.accounts.AccountTokenMigrationStore import com.tangem.datasource.utils.getSyncOrNull import com.tangem.domain.account.tokens.MainAccountTokensMigration +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId import com.tangem.lib.crypto.derivation.AccountNodeRecognizer -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import timber.log.Timber @@ -43,11 +41,9 @@ internal class DefaultMainAccountTokensMigration( private val accountTokenMigrationStore: AccountTokenMigrationStore, private val userTokensSaver: UserTokensSaver, private val eTagsStore: ETagsStore, - dispatchers: CoroutineDispatcherProvider, + private val coroutineScope: AppCoroutineScope, ) : MainAccountTokensMigration { - private val coroutineScope = CoroutineScope(dispatchers.default + SupervisorJob()) - internal suspend fun migrate(userWalletId: UserWalletId): Either = either { val store = accountsResponseStoreFactory.create(userWalletId) val response = store.getSyncOrNull() 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 index 6c39f7181c..f0fa74d20a 100644 --- 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 @@ -3,8 +3,8 @@ package com.tangem.data.account.store import android.content.Context import com.google.common.truth.Truth import com.squareup.moshi.Moshi +import com.tangem.common.test.TestAppCoroutineScope 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 @@ -19,7 +19,7 @@ class AccountsResponseStoreFactoryTest { private val factory: AccountsResponseStoreFactory = AccountsResponseStoreFactory( context = context, moshi = moshi, - dispatchers = TestingCoroutineDispatcherProvider(), + appScope = TestAppCoroutineScope(), ) @AfterEach diff --git a/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt b/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt index 70d14974b1..5b1401f2b3 100644 --- a/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.account.token import arrow.core.right +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO import com.tangem.data.account.store.AccountsResponseStore @@ -18,7 +19,6 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.test.core.assertEither import com.tangem.test.core.assertEitherLeft import com.tangem.test.core.assertEitherRight -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -43,7 +43,7 @@ class DefaultMainAccountTokensMigrationTest { accountTokenMigrationStore = accountTokenMigrationStore, userTokensSaver = userTokensSaver, eTagsStore = eTagsStore, - dispatchers = TestingCoroutineDispatcherProvider(), + coroutineScope = TestAppCoroutineScope(), ) private val userWalletId = UserWalletId("011") 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 d30a846e5f..e963742c1b 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 @@ -13,6 +13,7 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.repository.WalletsRepository @@ -22,8 +23,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 @@ -69,6 +68,7 @@ internal object DataCommonModule { dispatchers: CoroutineDispatcherProvider, addressesEnricher: UserTokensResponseAddressesEnricher, walletServerBinder: WalletServerBinder, + appScope: AppCoroutineScope, ): UserTokensSaver { return UserTokensSaver( tangemTechApi = tangemTechApi, @@ -76,7 +76,7 @@ internal object DataCommonModule { dispatchers = dispatchers, addressesEnricher = addressesEnricher, pushTokensRetryerPool = RetryerPool( - coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default), + coroutineScope = appScope, ), walletServerBinder = walletServerBinder, ) diff --git a/data/common/src/test/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcherTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcherTest.kt index 0569178c46..dd54dc9e0d 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcherTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcherTest.kt @@ -151,7 +151,7 @@ internal class DefaultQuotesFetcherTest { quotes = setOf( QuoteMetadata( cryptoCurrencyId = "ethereum", - timestamp = DateTime.now().millis - 10_000, + timestamp = DateTime.now().millis - 20_000, value = cachedQuote, ), ), @@ -194,7 +194,7 @@ internal class DefaultQuotesFetcherTest { quotes = setOf( QuoteMetadata( cryptoCurrencyId = "ethereum", - timestamp = DateTime.now().millis - 10_000, + timestamp = DateTime.now().millis - 20_000, value = cachedQuote, ), QuoteMetadata( @@ -385,7 +385,7 @@ internal class DefaultQuotesFetcherTest { quotes = setOf( QuoteMetadata( cryptoCurrencyId = "solana", - timestamp = DateTime.now().millis - 10_000, + timestamp = DateTime.now().millis - 20_000, value = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO), ), ), diff --git a/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt index 43648cfd89..e97384ee27 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt @@ -15,6 +15,7 @@ import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.mapWithStringKeyTypes import com.tangem.datasource.utils.setTypes +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.networks.repository.NetworksRepository import com.tangem.domain.networks.utils.NetworksCleaner import com.tangem.domain.walletmanager.WalletManagersFacade @@ -24,8 +25,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -37,7 +36,7 @@ internal object NetworkDataModule { fun provideNetworksStatusesStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): NetworksStatusesStore { return DefaultNetworksStatusesStore( context = context, @@ -49,9 +48,9 @@ internal object NetworkDataModule { defaultValue = emptyMap(), ), produceFile = { context.dataStoreFile(fileName = "networks_statuses_2") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = appScope, ), - dispatchers = dispatchers, + scope = appScope, ) } diff --git a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt index 7961e06c77..0cb7dba076 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt @@ -7,11 +7,11 @@ import com.tangem.data.networks.converters.SimpleNetworkStatusConverter import com.tangem.data.networks.models.SimpleNetworkStatus import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.StatusSource import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.addOrReplace import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -33,11 +33,9 @@ internal class DefaultNetworksStatusesStore( context: Context, private val runtimeStore: RuntimeSharedStore, private val persistenceDataStore: DataStore, - dispatchers: CoroutineDispatcherProvider, + private val scope: AppCoroutineScope, ) : NetworksStatusesStore { - private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) - init { scope.launch { try { diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/GetTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/GetTest.kt index 7a8ab68799..1408149256 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/GetTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/GetTest.kt @@ -2,6 +2,7 @@ package com.tangem.data.networks.store import com.google.common.truth.Truth import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory @@ -10,7 +11,6 @@ import com.tangem.data.networks.toSimple import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.wallet.UserWalletId import com.tangem.test.core.getEmittedValues -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.Test @@ -27,7 +27,7 @@ internal class GetTest { context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) @Test diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/InitializationTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/InitializationTest.kt index 63d2daa46b..c28121a1f5 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/InitializationTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/InitializationTest.kt @@ -2,6 +2,7 @@ package com.tangem.data.networks.store import androidx.datastore.core.DataStore import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.data.networks.models.SimpleNetworkStatus @@ -10,7 +11,6 @@ import com.tangem.data.networks.toSimple import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.emptyFlow @@ -33,7 +33,7 @@ internal class InitializationTest { context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null) @@ -48,7 +48,7 @@ internal class InitializationTest { context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptyMap>()) @@ -71,7 +71,7 @@ internal class InitializationTest { context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) val expectedStatus = status.toSimple().copy(value = status.value.copySealed(source = StatusSource.CACHE)) diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreStatusTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreStatusTest.kt index d310e1bdde..84b5a3d6bb 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreStatusTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreStatusTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.store import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.data.networks.models.SimpleNetworkStatus @@ -10,7 +11,6 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest @@ -31,7 +31,7 @@ internal class ParameterizedStoreStatusTest(private val model: Model) { context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) @Test diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreSuccessTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreSuccessTest.kt index 7e62ff9843..513a4c0b55 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreSuccessTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreSuccessTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.store import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.data.networks.models.SimpleNetworkStatus @@ -10,7 +11,6 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest @@ -31,7 +31,7 @@ internal class ParameterizedStoreSuccessTest(private val model: Model) { context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) @Test diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreTest.kt index 99436f876e..543ab9a7c6 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.store import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.data.networks.models.SimpleNetworkStatus @@ -9,7 +10,6 @@ import com.tangem.data.networks.toSimple import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest @@ -30,7 +30,7 @@ internal class ParameterizedStoreTest(private val model: Model) { context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) @Test diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsCacheTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsCacheTest.kt index cfb2eb16f0..405cbcf352 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsCacheTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsCacheTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.store import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory @@ -10,7 +11,6 @@ import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.domain.models.StatusSource import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest @@ -28,7 +28,7 @@ internal class SetSourceAsCacheTest { context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) @Test diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsOnlyCacheTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsOnlyCacheTest.kt index 1136ce2921..df2021163d 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsOnlyCacheTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsOnlyCacheTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.store import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory @@ -10,7 +11,6 @@ import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.domain.models.StatusSource import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest @@ -28,7 +28,7 @@ internal class SetSourceAsOnlyCacheTest { context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) @Test diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/StoreStatusTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/StoreStatusTest.kt index aa545d3ba4..52d06670d9 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/StoreStatusTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/StoreStatusTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.store import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory @@ -10,7 +11,6 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.domain.models.StatusSource import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest @@ -28,7 +28,7 @@ internal class StoreStatusTest { context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) @Test diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/StoreSuccessTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/StoreSuccessTest.kt index ebf54b27c7..40bdcbcad4 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/StoreSuccessTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/StoreSuccessTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.store import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory @@ -9,7 +10,6 @@ import com.tangem.data.networks.toSimple import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest @@ -27,7 +27,7 @@ internal class StoreSuccessTest { context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) @Test diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/StoreTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/StoreTest.kt index eb5cd705b3..b0bd11e6a7 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/StoreTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/StoreTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.store import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory @@ -9,7 +10,6 @@ import com.tangem.data.networks.toSimple import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest @@ -27,7 +27,7 @@ internal class StoreTest { context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) @Test diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/UpdateStatusSourceTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/UpdateStatusSourceTest.kt index 785c1b8d1b..703466e756 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/UpdateStatusSourceTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/UpdateStatusSourceTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.store import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory @@ -13,7 +14,6 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest @@ -31,7 +31,7 @@ internal class UpdateStatusSourceTest { context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) @Test diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultHotCryptoRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultHotCryptoRepository.kt index 735a95f01e..5f768924a0 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultHotCryptoRepository.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultHotCryptoRepository.kt @@ -22,6 +22,7 @@ import com.tangem.domain.card.common.extensions.canHandleToken import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.common.wallets.loadAndGet +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.onramp.model.HotCryptoCurrency @@ -30,10 +31,9 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.runCatching import com.tangem.utils.coroutines.saveIn -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.* +import kotlinx.coroutines.plus import timber.log.Timber /** @@ -59,9 +59,10 @@ internal class DefaultHotCryptoRepository( private val walletAccountsFetcher: WalletAccountsFetcher, private val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, + appScope: AppCoroutineScope, ) : HotCryptoRepository { - private val coroutineScope = CoroutineScope(dispatchers.main + SupervisorJob()) + private val coroutineScope = appScope + dispatchers.main private val hotCryptoJobHolder = JobHolder() diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/di/OnrampDataModule.kt b/data/onramp/src/main/java/com/tangem/data/onramp/di/OnrampDataModule.kt index ca1ca2f6fc..7e95788c7a 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/di/OnrampDataModule.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/di/OnrampDataModule.kt @@ -28,6 +28,7 @@ import com.tangem.datasource.local.onramp.sepa.OnrampCurrentCountryByIPStore import com.tangem.datasource.local.onramp.sepa.OnrampSepaAvailabilityStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.onramp.repositories.* import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -109,6 +110,7 @@ internal object OnrampDataModule { dispatchers: CoroutineDispatcherProvider, analyticsEventHandler: AnalyticsEventHandler, walletAccountsFetcher: WalletAccountsFetcher, + appScope: AppCoroutineScope, ): HotCryptoRepository { return DefaultHotCryptoRepository( excludedBlockchains = excludedBlockchains, @@ -119,6 +121,7 @@ internal object OnrampDataModule { dispatchers = dispatchers, analyticsEventHandler = analyticsEventHandler, walletAccountsFetcher = walletAccountsFetcher, + appScope = appScope, ) } diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt index 0cf87628bb..f7e09bf552 100644 --- a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt @@ -14,17 +14,15 @@ import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.mapWithStringKeyTypes +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.quotes.multi.MultiQuoteUpdater -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -36,7 +34,7 @@ internal object QuotesDataModule { fun provideQuotesStoreV2( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): QuotesStatusesStore { return DefaultQuotesStatusesStore( runtimeStore = RuntimeSharedStore(), @@ -47,9 +45,9 @@ internal object QuotesDataModule { defaultValue = emptyMap(), ), produceFile = { context.dataStoreFile(fileName = "quotes") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = appScope, ), - dispatchers = dispatchers, + scope = appScope, ) } @@ -65,13 +63,13 @@ internal object QuotesDataModule { appCurrencyResponseStore: AppCurrencyResponseStore, quotesStatusesStore: QuotesStatusesStore, multiQuoteStatusFetcher: MultiQuoteStatusFetcher, - dispatchers: CoroutineDispatcherProvider, + coroutineScope: AppCoroutineScope, ): MultiQuoteUpdater { return DefaultMultiQuoteUpdater( appCurrencyResponseStore = appCurrencyResponseStore, quotesStatusesStore = quotesStatusesStore, multiQuoteStatusFetcher = multiQuoteStatusFetcher, - dispatchers = dispatchers, + coroutineScope = coroutineScope, ) } } \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt index 73e2446946..f499f8a4a0 100644 --- a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt +++ b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt @@ -4,16 +4,14 @@ import androidx.annotation.VisibleForTesting import arrow.core.left import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.core.utils.EitherFlow import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.quotes.multi.MultiQuoteUpdater -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import timber.log.Timber @@ -32,10 +30,9 @@ internal class DefaultMultiQuoteUpdater( private val appCurrencyResponseStore: AppCurrencyResponseStore, private val quotesStatusesStore: QuotesStatusesStore, private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, - dispatchers: CoroutineDispatcherProvider, + private val coroutineScope: AppCoroutineScope, ) : MultiQuoteUpdater { - private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default) private val updaterHolder = JobHolder() override fun subscribe() { diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStatusesStore.kt b/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStatusesStore.kt index 52b464efa0..3225adfea6 100644 --- a/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStatusesStore.kt +++ b/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStatusesStore.kt @@ -4,13 +4,11 @@ import androidx.datastore.core.DataStore import com.tangem.data.quotes.converter.QuoteStatusConverter import com.tangem.datasource.api.tangemTech.models.QuotesResponse import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.addOrReplace -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull @@ -29,11 +27,9 @@ internal typealias CurrencyIdWithQuote = Map internal class DefaultQuotesStatusesStore( private val runtimeStore: RuntimeSharedStore>, private val persistenceDataStore: DataStore, - dispatchers: CoroutineDispatcherProvider, + private val scope: AppCoroutineScope, ) : QuotesStatusesStore { - private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) - init { scope.launch { val cachedStatuses = persistenceDataStore.data.firstOrNull() diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusUpdaterTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusUpdaterTest.kt index ecb0adfbb3..4126c45425 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusUpdaterTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusUpdaterTest.kt @@ -2,12 +2,12 @@ package com.tangem.data.quotes.multi import arrow.core.right import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse import com.tangem.datasource.appcurrency.AppCurrencyResponseStore import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.test.core.getEmittedValues -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.runTest @@ -26,7 +26,7 @@ internal class DefaultMultiQuoteStatusUpdaterTest { appCurrencyResponseStore = appCurrencyResponseStore, quotesStatusesStore = quotesStore, multiQuoteStatusFetcher = multiQuoteStatusFetcher, - dispatchers = TestingCoroutineDispatcherProvider(), + coroutineScope = TestAppCoroutineScope(), ) @Test diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreExtTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreExtTest.kt index 583fd0c1ed..1d187c765b 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreExtTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreExtTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.quotes.store import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.data.quote.MockQuoteResponseFactory import com.tangem.common.test.data.quote.toDomain import com.tangem.common.test.datastore.MockStateDataStore @@ -9,7 +10,6 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus import com.tangem.test.core.ProvideTestModels -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested @@ -39,7 +39,7 @@ internal class QuotesStatusesStoreExtTest { store = DefaultQuotesStatusesStore( runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) } diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreTest.kt index dd83f58209..67df2cab9e 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreTest.kt @@ -2,6 +2,7 @@ package com.tangem.data.quotes.store import androidx.datastore.core.DataStore import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.data.quote.MockQuoteResponseFactory import com.tangem.common.test.data.quote.toDomain import com.tangem.common.test.datastore.MockStateDataStore @@ -11,7 +12,6 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus import com.tangem.test.core.ProvideTestModels import com.tangem.test.core.getEmittedValues -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.emptyFlow @@ -53,7 +53,7 @@ internal class QuotesStatusesStoreTest { store = DefaultQuotesStatusesStore( runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) } @@ -73,7 +73,7 @@ internal class QuotesStatusesStoreTest { DefaultQuotesStatusesStore( runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) val actual = runtimeStore.getSyncOrNull() @@ -93,7 +93,7 @@ internal class QuotesStatusesStoreTest { DefaultQuotesStatusesStore( runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) val actual = runtimeStore.getSyncOrNull() @@ -120,7 +120,7 @@ internal class QuotesStatusesStoreTest { DefaultQuotesStatusesStore( runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) val actual = runtimeStore.getSyncOrNull() diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt index 60fd0250f8..4ddab5988c 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt @@ -8,11 +8,11 @@ import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.staking.multi.MultiStakingBalanceProducer import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier import com.tangem.domain.staking.single.SingleStakingBalanceProducer import com.tangem.domain.staking.single.SingleStakingBalanceSupplier -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -27,12 +27,12 @@ internal object StakingBalanceSupplierModule { @Singleton fun provideStakingBalancesStore( persistenceStore: DataStore>>, - dispatchers: CoroutineDispatcherProvider, + scope: AppCoroutineScope, ): StakeKitBalancesStore { return DefaultStakeKitBalancesStore( runtimeStore = RuntimeSharedStore(), persistenceStore = persistenceStore, - dispatchers = dispatchers, + scope = scope, ) } @@ -40,12 +40,12 @@ internal object StakingBalanceSupplierModule { @Singleton fun provideP2PEthPoolBalancesStore( persistenceStore: DataStore>>, - dispatchers: CoroutineDispatcherProvider, + scope: AppCoroutineScope, ): P2PEthPoolBalancesStore { return DefaultP2PEthPoolBalancesStore( runtimeStore = RuntimeSharedStore(), persistenceStore = persistenceStore, - dispatchers = dispatchers, + scope = scope, ) } diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt index d43261002e..c78065a255 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt @@ -4,14 +4,12 @@ import androidx.datastore.core.DataStore import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingBalanceConverter import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.StatusSource import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.addOrReplace -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull @@ -33,11 +31,9 @@ internal typealias WalletIdWithP2PEthPoolResponses = Map, private val persistenceStore: DataStore, - dispatchers: CoroutineDispatcherProvider, + private val scope: AppCoroutineScope, ) : P2PEthPoolBalancesStore { - private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) - init { scope.launch { val cachedData = persistenceStore.data.firstOrNull() ?: return@launch diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt index 16c5404a01..d26c1dee88 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt @@ -4,14 +4,12 @@ import androidx.datastore.core.DataStore import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.token.converter.StakingBalanceConverter +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.StatusSource import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.addOrReplace -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull @@ -33,11 +31,9 @@ internal typealias WalletIdWithStakingBalances = Map, private val persistenceStore: DataStore, - dispatchers: CoroutineDispatcherProvider, + private val scope: AppCoroutineScope, ) : StakeKitBalancesStore { - private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) - init { scope.launch { val cachedStatuses = persistenceStore.data.firstOrNull() ?: return@launch diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreGetMethodTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreGetMethodTest.kt index b3716181f1..ff524dd229 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreGetMethodTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreGetMethodTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.staking.store import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.data.staking.toDomain @@ -8,7 +9,6 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.wallet.UserWalletId import com.tangem.test.core.getEmittedValues -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import kotlinx.coroutines.test.runTest import org.junit.Test @@ -23,7 +23,7 @@ internal class StakingBalancesStoreGetMethodTest { private val store = DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) @Test diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreInitializationTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreInitializationTest.kt index 5ccbe2c608..32884813fa 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreInitializationTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreInitializationTest.kt @@ -2,13 +2,13 @@ package com.tangem.data.staking.store import androidx.datastore.core.DataStore import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.data.staking.toDomain import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.emptyFlow @@ -30,7 +30,7 @@ internal class StakingBalancesStoreInitializationTest { DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null) @@ -44,7 +44,7 @@ internal class StakingBalancesStoreInitializationTest { DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptyMap>()) @@ -66,7 +66,7 @@ internal class StakingBalancesStoreInitializationTest { DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) val expected = mapOf(userWalletId to setOf(wrapper.toDomain())) diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreUpdateMethodsTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreUpdateMethodsTest.kt index 602f2b4dc8..7f9ae0cdba 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreUpdateMethodsTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreUpdateMethodsTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.staking.store import com.google.common.truth.Truth +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.data.staking.toDomain @@ -10,7 +11,6 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest import org.junit.Test @@ -26,7 +26,7 @@ internal class StakingBalancesStoreUpdateMethodsTest { private val store = DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, - dispatchers = TestingCoroutineDispatcherProvider(), + scope = TestAppCoroutineScope(), ) @Test diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt index c3eb76035e..32c5120681 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt @@ -2,6 +2,7 @@ package com.tangem.data.pay import com.tangem.common.card.FirmwareVersion import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked @@ -9,7 +10,6 @@ import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.hot.sdk.model.HotWalletId -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.sync.Mutex @@ -17,15 +17,14 @@ import kotlinx.coroutines.sync.withLock import javax.inject.Inject internal class DefaultTangemPayEligibilityManager @Inject constructor( - dispatchers: CoroutineDispatcherProvider, private val userWalletsListRepository: UserWalletsListRepository, + private val coroutineScope: AppCoroutineScope, private val onboardingRepository: OnboardingRepository, ) : TangemPayEligibilityManager { private var cachedEligibleWallets: List? = null private var eligibleWalletsDeferred: Deferred>? = null private val loadMutex = Mutex() - private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default) init { resetDataWhenWalletsUpdate() 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 565fdbe700..c1842bbec1 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 @@ -18,6 +18,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.mapWithStringKeyTypes +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher @@ -38,8 +39,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -112,6 +111,7 @@ internal interface TangemPayDataModule { @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, + scope: AppCoroutineScope, ): PaymentAccountStatusesStore { return PaymentAccountStatusesStore( runtimeStore = RuntimeSharedStore(), @@ -122,9 +122,9 @@ internal interface TangemPayDataModule { defaultValue = emptyMap(), ), produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = scope, ), - dispatchers = dispatchers, + scope = scope, ) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt index 61f222a421..0c0b28df99 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt @@ -19,6 +19,7 @@ import com.tangem.datasource.api.pay.models.response.FreezeUnfreezeCardResponse import com.tangem.datasource.api.pay.models.response.OrderResponse.Result.Status import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance @@ -45,9 +46,9 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( private val storage: TangemPayStorage, private val cardFrozenStateStore: TangemPayCardFrozenStateStore, private val errorConverter: TangemPayErrorConverter, + private val pollingScope: AppCoroutineScope, ) : TangemPayCardDetailsRepository { - private val pollingScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val pollingJobs = mutableMapOf() private val storePollingMutex = Mutex() diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt index 7bf21432a7..c88fd2fd38 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt @@ -9,6 +9,7 @@ import com.tangem.datasource.api.pay.models.request.WithdrawDataRequest import com.tangem.datasource.api.pay.models.request.WithdrawRequest import com.tangem.datasource.api.pay.models.response.WithdrawResponse import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.TangemPayWithdrawExchangeState @@ -46,9 +47,9 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor( private val tangemPayStorage: TangemPayStorage, private val swapRepository: SwapRepository, private val orderRepository: CustomerOrderRepository, + private val withdrawPollingScope: AppCoroutineScope, ) : TangemPayWithdrawRepository { - private val withdrawPollingScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val withdrawPollingJobs = mutableMapOf() private val withdrawPollingMutex = Mutex() diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt index 5b8866a09a..4eec265d04 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt @@ -4,11 +4,9 @@ import androidx.datastore.core.DataStore import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.PaymentAccountStatus -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull @@ -28,11 +26,9 @@ internal typealias WalletIdWithPaymentStatusDM = Map, private val persistenceDataStore: DataStore, - dispatchers: CoroutineDispatcherProvider, + private val scope: AppCoroutineScope, ) { - private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) - init { scope.launch { try { 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 b0012d93a3..8a0c2b7d45 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,7 +17,6 @@ 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.walletconnect.WalletConnectStore import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier @@ -34,6 +33,7 @@ import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -80,8 +80,8 @@ internal object WalletConnectDataModule { @Provides @Singleton - fun sdkDelegate(wcScope: WcScope, store: WalletConnectStore): WcPairSdkDelegate = WcPairSdkDelegate( - scope = wcScope, + fun sdkDelegate(appScope: AppCoroutineScope, store: WalletConnectStore): WcPairSdkDelegate = WcPairSdkDelegate( + scope = appScope, store = store, ) @@ -93,7 +93,7 @@ internal object WalletConnectDataModule { getWallets: GetWalletsUseCase, wcNetworksConverter: WcNetworksConverter, analytics: AnalyticsEventHandler, - wcScope: WcScope, + appScope: AppCoroutineScope, ): DefaultWcSessionsManager { return DefaultWcSessionsManager( store = store, @@ -101,7 +101,7 @@ internal object WalletConnectDataModule { getWallets = getWallets, wcNetworksConverter = wcNetworksConverter, analytics = analytics, - scope = wcScope, + scope = appScope, ) } @@ -109,10 +109,6 @@ 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 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 a9d9030333..926afee1d3 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,13 +6,13 @@ 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 com.tangem.utils.coroutines.AppCoroutineScope import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose @@ -22,7 +22,7 @@ import kotlin.coroutines.resume import kotlin.time.Duration.Companion.seconds internal class WcPairSdkDelegate( - private val scope: WcScope, + private val scope: AppCoroutineScope, private val store: WalletConnectStore, ) : WcSdkObserver { 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 96a3982807..0d9625fc6f 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 @@ -15,6 +15,7 @@ import com.tangem.domain.walletconnect.model.WcSession import com.tangem.domain.walletconnect.model.WcSessionDTO import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* @@ -32,7 +33,7 @@ internal class DefaultWcSessionsManager( private val dispatchers: CoroutineDispatcherProvider, private val wcNetworksConverter: WcNetworksConverter, private val analytics: AnalyticsEventHandler, - private val scope: WcScope, + private val scope: AppCoroutineScope, ) : WcSessionsManager, WcSdkObserver { private val onSessionDelete = Channel(capacity = Channel.BUFFERED) 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 deleted file mode 100644 index d394ce320d..0000000000 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcScope.kt +++ /dev/null @@ -1,13 +0,0 @@ -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/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt index 62668755ef..039de11480 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt @@ -2,6 +2,7 @@ package com.tangem.data.wallets.hot import com.tangem.common.core.TangemSdkError import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.settings.repositories.LegacySettingsRepository @@ -11,10 +12,7 @@ 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 com.tangem.utils.coroutines.runSuspendCatching -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject @@ -27,11 +25,9 @@ class DefaultHotWalletAccessor @Inject constructor( private val hotWalletPasswordRequester: HotWalletPasswordRequester, private val walletsRepository: WalletsRepository, private val legacySettingsRepository: LegacySettingsRepository, - dispatchers: CoroutineDispatcherProvider, + private val scope: AppCoroutineScope, ) : HotWalletAccessor { - private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) - private val contextualUnlockHotWallet: ConcurrentHashMap = ConcurrentHashMap() override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List): List = diff --git a/domain/account/status/build.gradle.kts b/domain/account/status/build.gradle.kts index 5d47d084f1..28daf7df24 100644 --- a/domain/account/status/build.gradle.kts +++ b/domain/account/status/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(deps.kotlin.datetime) implementation(deps.kotlin.serialization) implementation(deps.timber) + implementation(deps.kermit) implementation(tangemDeps.blockchain) diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusListProducerFactoryModule.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusListProducerFactoryModule.kt index 30ae5b36ea..f7e4215b7b 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusListProducerFactoryModule.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusListProducerFactoryModule.kt @@ -1,7 +1,6 @@ package com.tangem.domain.account.status.di import com.tangem.domain.account.status.producer.* -import com.tangem.domain.core.flow.FlowProducerScope import com.tangem.domain.core.flow.FlowProducerTools import dagger.Binds import dagger.Module @@ -28,8 +27,4 @@ internal interface AccountStatusListProducerFactoryModule { @Binds @Singleton fun bindDefaultFlowProducerTools(impl: DefaultFlowProducerTools): FlowProducerTools - - @Binds - @Singleton - fun bindFlowProducerScope(impl: DefaultFlowProducerAppScope): FlowProducerScope } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt index 1f7bea813c..90ea428eda 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt @@ -8,6 +8,7 @@ import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier @@ -26,8 +27,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 @@ -104,6 +103,7 @@ internal object AccountStatusUseCaseModule { cryptoCurrencyMetadataCleaner: CryptoCurrencyMetadataCleaner, expressServiceFetcher: ExpressServiceFetcher, dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): ManageCryptoCurrenciesUseCase { return ManageCryptoCurrenciesUseCase( singleAccountStatusListSupplier = singleAccountStatusListSupplier, @@ -114,7 +114,7 @@ internal object AccountStatusUseCaseModule { cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, cryptoCurrencyMetadataCleaner = cryptoCurrencyMetadataCleaner, expressServiceFetcher = expressServiceFetcher, - parallelUpdatingScope = CoroutineScope(SupervisorJob() + dispatchers.default), + parallelUpdatingScope = appScope, dispatchers = dispatchers, ) } @@ -126,14 +126,14 @@ internal object AccountStatusUseCaseModule { multiQuoteStatusFetcher: MultiQuoteStatusFetcher, multiStakingBalanceFetcher: MultiStakingBalanceFetcher, stakingIdFactory: StakingIdFactory, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): CryptoCurrencyBalanceFetcher { return CryptoCurrencyBalanceFetcher( multiNetworkStatusFetcher = multiNetworkStatusFetcher, multiQuoteStatusFetcher = multiQuoteStatusFetcher, multiStakingBalanceFetcher = multiStakingBalanceFetcher, stakingIdFactory = stakingIdFactory, - parallelUpdatingScope = CoroutineScope(SupervisorJob() + dispatchers.default), + parallelUpdatingScope = appScope, ) } diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultFlowProducerTools.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultFlowProducerTools.kt index dfe0ea8bf4..e42901b839 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultFlowProducerTools.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultFlowProducerTools.kt @@ -1,54 +1,19 @@ package com.tangem.domain.account.status.producer +import co.touchlab.kermit.Logger import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.domain.core.flow.FlowProducer -import com.tangem.domain.core.flow.FlowProducerScope import com.tangem.domain.core.flow.FlowProducerTools +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* -import timber.log.Timber import javax.inject.Inject -import kotlin.coroutines.CoroutineContext - -class DefaultFlowProducerAppScope @Inject constructor( - dispatchers: CoroutineDispatcherProvider, - private val analyticsExceptionHandler: AnalyticsExceptionHandler, -) : FlowProducerScope { - - private val tag = "FlowProducerScope" - - override val coroutineContext: CoroutineContext = SupervisorJob() + - dispatchers.default + - CoroutineName(tag) + - CoroutineExceptionHandler { context, throwable -> - @Suppress("NullableToStringCall") - val coroutineName = context[CoroutineName]?.name.toString() - logError(throwable, coroutineName) - } - - private fun logError(throwable: Throwable, coroutineName: String) { - Timber.tag("FlowProducerExceptionHandler").e( - throwable, - "CoroutineName $coroutineName", - ) - val event = ExceptionAnalyticsEvent( - exception = throwable, - params = mapOf( - "source" to tag, - "coroutineName" to coroutineName, - ), - ) - analyticsExceptionHandler.sendException(event) - } -} class DefaultFlowProducerTools @Inject constructor( - private val scope: FlowProducerScope, + private val scope: AppCoroutineScope, + private val dispatchers: CoroutineDispatcherProvider, private val analyticsExceptionHandler: AnalyticsExceptionHandler, ) : FlowProducerTools { @@ -72,6 +37,7 @@ class DefaultFlowProducerTools @Inject constructor( } return upstream + .flowOn(dispatchers.default) .distinctUntilChanged() .shareIn( scope = scope, @@ -89,8 +55,8 @@ class DefaultFlowProducerTools @Inject constructor( private fun logError(cause: Throwable, flowProducerName: String, attempt: Long) { val tag = "FlowProducerRetryWhen" - Timber.tag(tag) - .e(cause, "flowProducerName $flowProducerName attempt $attempt") + Logger.withTag(tag) + .e("flowProducerName $flowProducerName attempt $attempt", cause) val event = ExceptionAnalyticsEvent( exception = cause, diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt index 2774fa49fe..5bb104ed00 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt @@ -8,6 +8,7 @@ import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.core.utils.eitherOn import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.express.models.ExpressAsset @@ -51,7 +52,7 @@ class ManageCryptoCurrenciesUseCase( private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, private val cryptoCurrencyMetadataCleaner: CryptoCurrencyMetadataCleaner, private val expressServiceFetcher: ExpressServiceFetcher, - private val parallelUpdatingScope: CoroutineScope, + private val parallelUpdatingScope: AppCoroutineScope, private val dispatchers: CoroutineDispatcherProvider, ) { 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 cf4c5e0474..3801a90e65 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,7 +1,6 @@ package com.tangem.domain.core.flow import arrow.core.Option -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow @@ -37,8 +36,6 @@ interface FlowProducer { } } -interface FlowProducerScope : CoroutineScope - interface FlowProducerTools { fun shareInProducer(flow: Flow, flowProducer: FlowProducer, withRetryWhen: Boolean = true): SharedFlow diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt index 05bc7a7445..b8b6b2cba7 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt @@ -20,6 +20,7 @@ import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin 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.utils.coroutines.AppCoroutineScope import com.tangem.domain.demo.DemoTransactionSender import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.network.Network @@ -31,7 +32,6 @@ import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.error.parseWrappedError import com.tangem.domain.transaction.models.EventTransactionTypeDto import com.tangem.domain.walletmanager.WalletManagersFacade -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -43,7 +43,7 @@ class SendTransactionUseCase( private val transactionRepository: TransactionRepository, private val walletManagersFacade: WalletManagersFacade, private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, - private val parallelUpdatingScope: CoroutineScope, + private val parallelUpdatingScope: AppCoroutineScope, private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner, private val pushNotificationsRepository: PushNotificationsRepository, ) { diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTracker.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTracker.kt index 299a8556cf..8693bae476 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTracker.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTracker.kt @@ -4,7 +4,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.yield.supply.YieldSupplyRepository -import kotlinx.coroutines.CoroutineScope +import com.tangem.utils.coroutines.AppCoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -26,7 +26,7 @@ import java.util.concurrent.ConcurrentHashMap class YieldSupplyPendingTracker( private val yieldSupplyRepository: YieldSupplyRepository, private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, - private val coroutineScope: CoroutineScope, + private val coroutineScope: AppCoroutineScope, ) { private data class TrackedKey( diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt index 81ee349867..e1c0349515 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt @@ -2,6 +2,7 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.TestAppCoroutineScope import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId @@ -36,7 +37,7 @@ class YieldSupplyPendingTrackerTest { useCase = YieldSupplyPendingTracker( yieldSupplyRepository = yieldSupplyRepository, singleNetworkStatusFetcher = singleNetworkStatusFetcher, - coroutineScope = testScope, + coroutineScope = TestAppCoroutineScope(testScope), ) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendBalanceUpdater.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendBalanceUpdater.kt index 0281f5bb8f..c8d81e1c83 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendBalanceUpdater.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendBalanceUpdater.kt @@ -1,17 +1,20 @@ package com.tangem.features.send.v2.common +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.nft.RefreshAllNFTUseCase import com.tangem.domain.tokens.FetchPendingTransactionsUseCase import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase -import com.tangem.domain.models.wallet.UserWallet import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter -import com.tangem.utils.coroutines.DelayedWork import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.* +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Suppress("LongParameterList") internal class SendBalanceUpdater @AssistedInject constructor( @@ -20,7 +23,7 @@ internal class SendBalanceUpdater @AssistedInject constructor( private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter, private val refreshAllNFTUseCase: RefreshAllNFTUseCase, - @DelayedWork private val coroutineScope: CoroutineScope, + private val coroutineScope: AppCoroutineScope, @Assisted private val userWallet: UserWallet, @Assisted private val cryptoCurrency: CryptoCurrency, ) { 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 805dafd774..d148b80650 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 @@ -40,6 +40,7 @@ import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase @@ -101,7 +102,6 @@ import com.tangem.utils.extensions.orZero import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -151,7 +151,7 @@ internal class StakingModel @Inject constructor( private val paramsInterceptorHolder: ParamsInterceptorHolder, private val shareManager: ShareManager, private val urlOpener: UrlOpener, - @DelayedWork private val coroutineScope: CoroutineScope, + private val coroutineScope: AppCoroutineScope, private val innerRouter: InnerStakingRouter, private val messageSender: UiMessageSender, private val giveApprovalFeatureToggles: GiveApprovalFeatureToggles, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt index 4c8bc0ceae..2128c11c3d 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt @@ -1,5 +1,6 @@ package com.tangem.features.staking.impl.presentation.state.helpers +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.staking.FetchActionsUseCase @@ -10,7 +11,6 @@ import com.tangem.domain.tokens.FetchCurrencyStatusUseCase import com.tangem.domain.tokens.FetchPendingTransactionsUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter -import com.tangem.utils.coroutines.DelayedWork import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -24,7 +24,7 @@ internal class StakingBalanceUpdater @AssistedInject constructor( private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter, private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, private val fetchStakingYieldBalanceUseCase: FetchStakingYieldBalanceUseCase, - @DelayedWork private val coroutineScope: CoroutineScope, + private val coroutineScope: AppCoroutineScope, @Assisted private val userWallet: UserWallet, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @Assisted private val integration: StakingIntegration, diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountListSortingSaver.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountListSortingSaver.kt index 52d5a6dbfc..f698a67127 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountListSortingSaver.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountListSortingSaver.kt @@ -2,12 +2,10 @@ package com.tangem.feature.walletsettings.utils import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.account.usecase.ApplyAccountListSortingUseCase +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.account.AccountId import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.* import timber.log.Timber import javax.inject.Inject @@ -27,15 +25,13 @@ import kotlin.time.Duration.Companion.seconds internal class AccountListSortingSaver @Inject constructor( private val applyAccountListSortingUseCase: ApplyAccountListSortingUseCase, private val analyticsEventHandler: AnalyticsEventHandler, - dispatchers: CoroutineDispatcherProvider, + private val coroutineScope: AppCoroutineScope, ) { /** Flow to hold the account IDs for sorting, with an initial null value. */ val accountsOrderFlow: StateFlow?> private field = MutableStateFlow?>(value = null) - private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.io) - init { accountsOrderFlow .filterNotNull() 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 a8c5f4b3f5..4a6d0d0c5a 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 @@ -21,6 +21,7 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.libs.blockchain_sdk.BuildConfig import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -28,8 +29,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -75,12 +74,12 @@ internal object BlockchainSDKFactoryModule { fun provideChangedBlockchainProvidersResponseDataStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, + appScope: AppCoroutineScope, ): DataStore { return DataStoreFactory.create( serializer = BlockchainProvidersResponseSerializer(moshi), produceFile = { context.dataStoreFile("changed_providers") }, - scope = CoroutineScope(dispatchers.io + SupervisorJob()), + scope = appScope, ) } From a8f4cce51e81b369c5e10386d3bdd8c443f5616e Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Mar 2026 20:18:53 +0500 Subject: [PATCH 33/60] Updated on 2026-08-14 --- .../tangem/core/ui/ds/image/TangemIconUM.kt | 3 +- .../core/ui/ds/message/TangemMessage.kt | 2 +- .../core/ui/ds/message/TangemMessageEffect.kt | 49 +++++++++++-------- .../core/ui/ds/message/TangemMessageUM.kt | 9 +++- .../com/tangem/core/ui/res/TangemTheme.kt | 7 +++ .../page/message/TangemMessageStory.kt | 6 ++- .../state/model/WalletNotificationUM.kt | 13 +++-- 7 files changed, 62 insertions(+), 27 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt index a1f0c98b5b..f6ec4b82e1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest @@ -74,7 +75,7 @@ fun TangemIcon(tangemIconUM: TangemIconUM, modifier: Modifier = Modifier) { tint = tangemIconUM.tintReference(), ) is TangemIconUM.Image -> Image( - imageVector = ImageVector.vectorResource(tangemIconUM.imageRes), + painter = painterResource(tangemIconUM.imageRes), contentDescription = null, modifier = modifier, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt index 86d955f2d9..01edf7ce54 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt @@ -66,7 +66,7 @@ fun TangemMessage( Alignment.Top }, ) - .size(TangemTheme.dimens2.x7), + .size(messageUM.iconSize), ) } }, diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessageEffect.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessageEffect.kt index 03b00e793a..8b11ea19ec 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessageEffect.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessageEffect.kt @@ -6,10 +6,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent @@ -25,6 +22,7 @@ import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.haze.hazeForegroundEffectTangem import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.res.LocalIsInDarkTheme +import com.tangem.core.ui.res.LocalMessageEffectAnimation import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.utils.toPx @@ -33,6 +31,10 @@ import dev.chrisbanes.haze.HazeTint import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +data class MessageEffectAnimation( + val offsetState: State, +) + /** * Different visual effects for [Tangem message component]. */ @@ -222,7 +224,6 @@ enum class TangemMessageEffect(val isAnimatable: Boolean) { } } - // todo redesign replace with proper color when design is ready @Suppress("MagicNumber") fun fallbackColor(isInDarkTheme: Boolean): Color { return when (this) { @@ -242,12 +243,16 @@ internal fun Modifier.messageEffectBackground( contentColor: Color, ): Modifier { val isInDarkTheme = LocalIsInDarkTheme.current - val borderGradientColors = remember { messageEffect.getBorderGradient(isInDarkTheme) } - val gradientColors = remember { messageEffect.getColorGradient(isInDarkTheme) } - val gradientTint = remember { messageEffect.getGradientTint(isInDarkTheme) } + val borderGradientColors = remember(messageEffect, isInDarkTheme) { messageEffect.getBorderGradient(isInDarkTheme) } + val gradientColors = remember(messageEffect, isInDarkTheme) { messageEffect.getColorGradient(isInDarkTheme) } + val gradientTint = remember(messageEffect, isInDarkTheme) { messageEffect.getGradientTint(isInDarkTheme) } - val angle by rememberAnimationAngle(messageEffect.isAnimatable) - val brush = remember { Brush.sweepGradient(messageEffect.getColorGradient(isInDarkTheme)) } + val angle by if (messageEffect.isAnimatable) { + LocalMessageEffectAnimation.current.offsetState + } else { + remember { mutableFloatStateOf(0f) } + } + val brush = remember(gradientColors) { Brush.sweepGradient(gradientColors) } val padding = 1.dp.toPx() return this @@ -278,12 +283,14 @@ internal fun Modifier.messageEffectBackground( } .conditionalCompose(gradientColors.isNotEmpty()) { drawWithContent { - rotate(angle) { - drawCircle( - brush = brush, - radius = size.width, - blendMode = BlendMode.SrcIn, - ) + if (messageEffect.isAnimatable) { + rotate(angle) { + drawCircle( + brush = brush, + radius = size.width, + blendMode = BlendMode.SrcIn, + ) + } } drawRect( color = contentColor, @@ -296,9 +303,9 @@ internal fun Modifier.messageEffectBackground( } @Composable -private fun rememberAnimationAngle(isAnimatable: Boolean) = if (isAnimatable) { +internal fun rememberMessageEffectAnimationAngle(): MessageEffectAnimation { val infiniteTransition = rememberInfiniteTransition() - infiniteTransition.animateFloat( + val offsetState = infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable( @@ -306,8 +313,10 @@ private fun rememberAnimationAngle(isAnimatable: Boolean) = if (isAnimatable) { repeatMode = RepeatMode.Restart, ), ) -} else { - remember { mutableFloatStateOf(0f) } + + return remember(offsetState) { + MessageEffectAnimation(offsetState) + } } // region Preview diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessageUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessageUM.kt index 3015a07e46..acaabc9b54 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessageUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessageUM.kt @@ -1,6 +1,8 @@ package com.tangem.core.ui.ds.message import androidx.annotation.DrawableRes +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.tangem.core.ui.ds.button.* import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.TextReference @@ -10,12 +12,16 @@ import kotlinx.collections.immutable.persistentListOf /** * Data model representing the properties of a Tangem message. * + * @param id Unique identifier for the message. * @param title TextReference for the message title. * @param subtitle TextReference for the message subtitle. * @param messageEffect TangemMessageEffect defining the visual effect of the message. + * @param iconUM Optional TangemIconUM representing the icon to be displayed in the message. + * @param iconSize Dp value defining the size of the icon (default is 28.dp). * @param isCentered Boolean indicating whether the icon is centered. * @param buttonsUM ImmutableList of TangemMessageButtonUM representing the buttons in the message. - * @param onCloseClick Lambda to be invoked when the close button is clicked (optional + * @param onClick Lambda to be invoked when the message is clicked (optional). + * @param onCloseClick Lambda to be invoked when the close button is clicked (optional). */ data class TangemMessageUM( val id: String, @@ -23,6 +29,7 @@ data class TangemMessageUM( val subtitle: TextReference, val messageEffect: TangemMessageEffect = TangemMessageEffect.None, val iconUM: TangemIconUM? = null, + val iconSize: Dp = 28.dp, val isCentered: Boolean = false, val buttonsUM: ImmutableList = persistentListOf(), val onClick: (() -> Unit)? = null, diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index 46b8751e71..cb9dc458ca 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -23,6 +23,8 @@ import com.tangem.core.ui.components.powersaving.rememberPowerSavingState import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHostState import com.tangem.core.ui.components.text.BladeAnimation import com.tangem.core.ui.components.text.rememberBladeAnimation +import com.tangem.core.ui.ds.message.MessageEffectAnimation +import com.tangem.core.ui.ds.message.rememberMessageEffectAnimationAngle import com.tangem.core.ui.haptic.DefaultHapticManager import com.tangem.core.ui.haptic.HapticManager import com.tangem.core.ui.haptic.VibratorHapticManager @@ -128,6 +130,7 @@ fun TangemTheme( LocalBladeAnimation provides rememberBladeAnimation(), LocalSystemBarsIconsController provides systemBarsIconsController, LocalPowerSavingState provides rememberPowerSavingState(), + LocalMessageEffectAnimation provides rememberMessageEffectAnimationAngle(), ) { CompositionLocalProvider( LocalTangemShimmer provides TangemShimmer, @@ -433,6 +436,10 @@ val LocalPowerSavingState = compositionLocalOf { error("No PowerSavingState provided") } +val LocalMessageEffectAnimation = compositionLocalOf { + error("No MessageEffectAnimation provided") +} + /** * Determines whether the dark theme should be used based on the given [AppThemeMode]. * diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/message/TangemMessageStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/message/TangemMessageStory.kt index f43e8ec903..8ee2be7006 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/message/TangemMessageStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/message/TangemMessageStory.kt @@ -1,4 +1,5 @@ @file:Suppress("MagicNumber", "LongMethod") + package com.tangem.feature.tester.presentation.storybook.page.message import androidx.compose.foundation.background @@ -16,7 +17,10 @@ import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.ds.button.TangemButtonType import com.tangem.core.ui.ds.image.TangemIconUM -import com.tangem.core.ui.ds.message.* +import com.tangem.core.ui.ds.message.TangemMessage +import com.tangem.core.ui.ds.message.TangemMessageButtonUM +import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.ds.message.TangemMessageUM import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageStory diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt index 9d24a0c3da..3a61317174 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt @@ -1,6 +1,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.model import androidx.annotation.DrawableRes +import androidx.compose.ui.unit.dp import com.tangem.core.ui.ds.button.TangemButtonType import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.message.TangemMessageButtonUM @@ -196,7 +197,6 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t onClick = onConfirmClick, ), ), - ), type = WalletNotificationType.Critical, ) @@ -379,8 +379,14 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t title = resourceReference(R.string.notification_one_plus_one_title), subtitle = resourceReference(R.string.notification_one_plus_one_text), messageEffect = TangemMessageEffect.Magic, - onCloseClick = onCloseClick, + iconUM = TangemIconUM.Image(R.drawable.img_one_plus_one_promo), + iconSize = 54.dp, buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(R.string.common_later), + type = TangemButtonType.PrimaryInverse, + onClick = onCloseClick, + ), TangemMessageButtonUM( text = resourceReference(R.string.notification_one_plus_one_button), type = TangemButtonType.Primary, @@ -452,7 +458,8 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t id = "PushNotificationsNotification", title = resourceReference(R.string.user_push_notification_banner_title), subtitle = resourceReference(R.string.user_push_notification_banner_subtitle), - onCloseClick = onCloseClick, + iconUM = TangemIconUM.Image(R.drawable.img_push_reminder), + iconSize = 54.dp, messageEffect = TangemMessageEffect.Magic, buttonsUM = persistentListOf( TangemMessageButtonUM( From a2ec212c2e1576e34d858e472382ff294418303a Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Mar 2026 12:33:04 +0400 Subject: [PATCH 34/60] Updated on 2026-08-14 --- .../tap/di/domain/TokensDomainModule.kt | 18 ---- .../tap/di/domain/YieldSupplyDomainModule.kt | 14 ++-- .../tangem/data/tokens/di/TokensDataModule.kt | 3 - .../repository/DefaultCurrenciesRepository.kt | 20 ----- .../AccountCryptoCurrencyStatusFinder.kt | 2 +- .../status/utils/CryptoCurrencyOperations.kt | 32 +++++++ .../domain/tokens/GetCryptoCurrencyUseCase.kt | 73 ---------------- .../BaseCurrencyStatusOperations.kt | 83 +------------------ .../tokens/repository/CurrenciesRepository.kt | 13 --- domain/yield-supply/build.gradle.kts | 1 + .../YieldSupplyGetCurrentFeeUseCase.kt | 18 ++-- .../usecase/YieldSupplyGetMaxFeeUseCase.kt | 18 ++-- .../usecase/YieldSupplyMinAmountUseCase.kt | 18 ++-- .../supply/YieldSupplyMinAmountUseCaseTest.kt | 29 ++++--- .../YieldSupplyGetCurrentFeeUseCaseTest.kt | 62 +++++++------- .../model/OnrampSuccessComponentModel.kt | 23 ++--- .../DefaultSellRedirectDeepLinkHandler.kt | 22 ++++- .../DefaultTokenDetailsDeepLinkHandler.kt | 14 ++-- 18 files changed, 156 insertions(+), 307 deletions(-) delete mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt 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 c389af965c..07920010a7 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 @@ -3,18 +3,15 @@ package com.tangem.tap.di.domain import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher -import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.networks.repository.NetworksRepository import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.promo.PromoRepository -import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher -import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.staking.single.SingleStakingBalanceFetcher import com.tangem.domain.staking.single.SingleStakingBalanceSupplier @@ -73,15 +70,6 @@ internal object TokensDomainModule { ) } - @Provides - @Singleton - fun provideGetCryptoCurrencyUseCase( - currenciesRepository: CurrenciesRepository, - multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - ): GetCryptoCurrencyUseCase { - return GetCryptoCurrencyUseCase(currenciesRepository, multiWalletCryptoCurrenciesSupplier) - } - @Provides @Singleton fun provideGetCryptoCurrencyActionsUseCase( @@ -199,23 +187,17 @@ internal object TokensDomainModule { @Singleton fun provideBaseCurrencyStatusOperations( currenciesRepository: CurrenciesRepository, - quotesRepository: QuotesRepository, singleNetworkStatusSupplier: SingleNetworkStatusSupplier, - multiNetworkStatusSupplier: MultiNetworkStatusSupplier, singleQuoteStatusSupplier: SingleQuoteStatusSupplier, singleStakingBalanceSupplier: SingleStakingBalanceSupplier, - multiStakingBalanceSupplier: MultiStakingBalanceSupplier, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, stakingIdFactory: StakingIdFactory, ): BaseCurrencyStatusOperations { return BaseCurrencyStatusOperations( currenciesRepository = currenciesRepository, - quotesRepository = quotesRepository, singleNetworkStatusSupplier = singleNetworkStatusSupplier, - multiNetworkStatusSupplier = multiNetworkStatusSupplier, singleQuoteStatusSupplier = singleQuoteStatusSupplier, singleStakingBalanceSupplier = singleStakingBalanceSupplier, - multiStakingBalanceSupplier = multiStakingBalanceSupplier, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, stakingIdFactory = stakingIdFactory, ) diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt index fbc69fcf80..79220d1566 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -1,10 +1,10 @@ package com.tangem.tap.di.domain +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.blockaid.BlockAidGasEstimate import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.quotes.QuotesRepository -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.transaction.error.FeeErrorResolver import com.tangem.domain.yield.supply.YieldSupplyErrorResolver @@ -144,12 +144,12 @@ internal object YieldSupplyDomainModule { fun provideYieldSupplyMinAmountUseCase( feeRepository: FeeRepository, quotesRepository: QuotesRepository, - currenciesRepository: CurrenciesRepository, + singleAccountListSupplier: SingleAccountListSupplier, ): YieldSupplyMinAmountUseCase { return YieldSupplyMinAmountUseCase( feeRepository = feeRepository, quotesRepository = quotesRepository, - currenciesRepository = currenciesRepository, + singleAccountListSupplier = singleAccountListSupplier, ) } @@ -158,12 +158,12 @@ internal object YieldSupplyDomainModule { fun provideYieldSupplyGetCurrentFeeUseCase( feeRepository: FeeRepository, quotesRepository: QuotesRepository, - currenciesRepository: CurrenciesRepository, + singleAccountListSupplier: SingleAccountListSupplier, ): YieldSupplyGetCurrentFeeUseCase { return YieldSupplyGetCurrentFeeUseCase( feeRepository = feeRepository, quotesRepository = quotesRepository, - currenciesRepository = currenciesRepository, + singleAccountListSupplier = singleAccountListSupplier, ) } @@ -172,12 +172,12 @@ internal object YieldSupplyDomainModule { fun provideYieldSupplyGetMaxFeeUseCase( yieldSupplyRepository: YieldSupplyRepository, quotesRepository: QuotesRepository, - currenciesRepository: CurrenciesRepository, + singleAccountListSupplier: SingleAccountListSupplier, ): YieldSupplyGetMaxFeeUseCase { return YieldSupplyGetMaxFeeUseCase( yieldSupplyRepository = yieldSupplyRepository, quotesRepository = quotesRepository, - currenciesRepository = currenciesRepository, + singleAccountListSupplier = singleAccountListSupplier, ) } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt index e0630ac246..161b3fdd48 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt @@ -12,7 +12,6 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.TokenReceiveWarningActionStore import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.express.ExpressServiceFetcher -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository @@ -40,7 +39,6 @@ internal object TokensDataModule { expressServiceFetcher: ExpressServiceFetcher, excludedBlockchains: ExcludedBlockchains, cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, - multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, ): CurrenciesRepository { return DefaultCurrenciesRepository( tangemTechApi = tangemTechApi, @@ -51,7 +49,6 @@ internal object TokensDataModule { dispatchers = dispatchers, excludedBlockchains = excludedBlockchains, cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, - multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, ) } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index 9e5a67df59..d50a1e412b 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -25,8 +25,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.models.wallet.requireColdWallet -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletmanager.WalletManagersFacade @@ -46,7 +44,6 @@ internal class DefaultCurrenciesRepository( private val expressServiceFetcher: ExpressServiceFetcher, private val dispatchers: CoroutineDispatcherProvider, private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, - private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, excludedBlockchains: ExcludedBlockchains, ) : CurrenciesRepository { @@ -121,23 +118,6 @@ internal class DefaultCurrenciesRepository( } } - override suspend fun getNetworkCoin( - userWalletId: UserWalletId, - networkId: Network.ID, - derivationPath: Network.DerivationPath, - ): CryptoCurrency.Coin { - return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId), - ) - .orEmpty() - .find { currency -> - currency is CryptoCurrency.Coin && - currency.network.id.rawId == networkId.rawId && - currency.network.derivationPath == derivationPath - } as? CryptoCurrency.Coin - ?: error("Unable to find coin for network ID: $networkId") - } - override suspend fun isSendBlockedByPendingTransactions( userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt index abe7c314d1..24da2b65e0 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt @@ -255,7 +255,7 @@ internal object AccountCryptoCurrencyStatusFinder { // region AccountList helpers - private fun AccountList.getExpectedAccounts(network: Network?): List { + internal fun AccountList.getExpectedAccounts(network: Network?): List { return getExpectedAccounts(rawNetworkId = network?.rawId, derivationPath = network?.derivationPath) } diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyOperations.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyOperations.kt index 7fdc8d9cce..0538977657 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyOperations.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyOperations.kt @@ -1,12 +1,17 @@ package com.tangem.domain.account.status.utils +import arrow.core.None import arrow.core.Option +import arrow.core.raise.catch +import arrow.core.raise.option import arrow.core.toOption import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.status.utils.AccountCryptoCurrencyOperations.getAccountCryptoCurrency +import com.tangem.domain.account.status.utils.AccountCryptoCurrencyStatusFinder.getExpectedAccounts import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import timber.log.Timber /** * Extension functions for retrieving [CryptoCurrency] from an [AccountList] or [Account.CryptoPortfolio]. @@ -48,6 +53,33 @@ object CryptoCurrencyOperations { return getCryptoCurrency(currencyId = cryptoCurrency.id, network = cryptoCurrency.network) } + fun AccountList?.getCryptoCurrency(currencyIdValue: String): Option = option { + val currencyId = catch( + block = { CryptoCurrency.ID.fromValue(currencyIdValue) }, + catch = { throwable -> + Timber.e("Error on converting currencyId: $throwable") + raise(None) + }, + ) + + return getCryptoCurrency(currencyId = currencyId, network = null) + } + + fun AccountList.getCoin(currency: CryptoCurrency): Option { + return getCoin(network = currency.network) + } + + fun AccountList.getCoin(network: Network): Option { + return getExpectedAccounts(network = network) + .flatMap { account -> + (account as? Account.CryptoPortfolio) + ?.cryptoCurrencies.orEmpty() + .filterIsInstance() + } + .firstOrNull { currency -> currency.network.id == network.id } + .toOption() + } + /** * Retrieves the [CryptoCurrency] for the specified [currencyId] and [network] from this [AccountList]. * 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 deleted file mode 100644 index 15b876dd8f..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.tangem.domain.tokens - -import arrow.core.Either -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.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, -) { - - /** - * Returns specific cryptocurrency for a given user wallet. - * - * !!! Important Use only [CryptoCurrency.ID.value] as cryptoCurrencyId - * - * @param userWallet The user's wallet. - * @param cryptoCurrencyId The ID of the cryptocurrency. - * @return An [Either] representing success (Right) or an error (Left) in fetching the status. - */ - suspend operator fun invoke( - userWallet: UserWallet, - cryptoCurrencyId: String, - ): Either { - return either { - if (userWallet.isMultiCurrency) { - getCurrency(userWallet.walletId, cryptoCurrencyId) - } else { - getPrimaryCurrency(userWallet.walletId) - } - } - } - - /** - * Returns the primary cryptocurrency for a given user wallet. - * - * @param userWalletId The ID of the user's wallet. - * @return An [Either] representing success (Right) or an error (Left) in fetching the status. - */ - suspend operator fun invoke(userWalletId: UserWalletId): Either { - return either { getPrimaryCurrency(userWalletId) } - } - - private suspend fun Raise.getCurrency( - userWalletId: UserWalletId, - id: String, - ): CryptoCurrency { - return catch( - block = { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.firstOrNull { it.id.value == id } - ?: error("Unable to find currency with ID: $id") - }, - catch = { raise(CurrencyStatusError.DataError(it)) }, - ) - } - - private suspend fun Raise.getPrimaryCurrency(userWalletId: UserWalletId): CryptoCurrency { - return catch( - block = { currenciesRepository.getSingleCurrencyWalletPrimaryCurrency(userWalletId) }, - catch = { raise(CurrencyStatusError.DataError(it)) }, - ) - } -} \ 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 a443a459eb..5714da190b 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 @@ -1,22 +1,19 @@ package com.tangem.domain.tokens.operations -import arrow.core.* +import arrow.core.Either +import arrow.core.left import arrow.core.raise.* +import arrow.core.right 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.staking.StakingBalance 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.networks.single.SingleNetworkStatusProducer import com.tangem.domain.networks.single.SingleNetworkStatusSupplier -import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.quotes.single.SingleQuoteStatusProducer import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.staking.StakingIdFactory -import com.tangem.domain.staking.multi.MultiStakingBalanceProducer -import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier import com.tangem.domain.staking.single.SingleStakingBalanceProducer import com.tangem.domain.staking.single.SingleStakingBalanceSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer @@ -36,12 +33,9 @@ import kotlinx.coroutines.flow.firstOrNull @Suppress("LargeClass", "LongParameterList") class BaseCurrencyStatusOperations( private val currenciesRepository: CurrenciesRepository, - private val quotesRepository: QuotesRepository, - private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, private val singleStakingBalanceSupplier: SingleStakingBalanceSupplier, - private val multiStakingBalanceSupplier: MultiStakingBalanceSupplier, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val stakingIdFactory: StakingIdFactory, ) { @@ -105,42 +99,6 @@ class BaseCurrencyStatusOperations( } } - suspend fun getCurrenciesStatusesSync(userWalletId: UserWalletId): Either> { - return either { - catch( - block = { - val nonEmptyCurrencies = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.toNonEmptyListOrNull() - ?: return emptyList().right() - - val (_, currenciesIds) = getIds(nonEmptyCurrencies) - val rawIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet() - - val quotes = quotesRepository.getMultiQuoteSyncOrNull(currenciesIds = rawIds)?.right() - - val networkStatuses = multiNetworkStatusSupplier( - params = MultiNetworkStatusProducer.Params(userWalletId = userWalletId), - ) - .firstOrNull() - .orEmpty() - .right() - - val stakingBalances = getStakingBalancesSync(userWalletId, nonEmptyCurrencies) - - return currencyStatusProxyCreator.createCurrenciesStatuses( - currencies = nonEmptyCurrencies, - maybeQuotes = quotes, - maybeNetworkStatuses = networkStatuses, - maybeStakingBalances = stakingBalances, - ) - }, - catch = { raise(Error.DataError(it)) }, - ) - } - } - private suspend fun Raise.getMultiCurrencyWalletCurrency( userWalletId: UserWalletId, currencyId: CryptoCurrency.ID, @@ -173,28 +131,6 @@ class BaseCurrencyStatusOperations( .bind() } - private suspend fun getStakingBalancesSync( - userWalletId: UserWalletId, - cryptoCurrencies: List, - ): Either> = either { - val stakingIds = cryptoCurrencies.mapNotNull { cryptoCurrency -> - stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = cryptoCurrency) - .getOrNull() - } - - ensure(stakingIds.isNotEmpty()) { Error.EmptyStakingBalances } - - val balances = multiStakingBalanceSupplier.getSyncOrNull( - params = MultiStakingBalanceProducer.Params(userWalletId = userWalletId), - ) - .orEmpty() - .filter { it.stakingId in stakingIds } - - ensure(balances.isNotEmpty()) { Error.EmptyStakingBalances } - - balances - } - private suspend fun getStakingBalanceSync( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, @@ -215,17 +151,4 @@ class BaseCurrencyStatusOperations( ensureNotNull(yieldBalance) { Error.EmptyStakingBalances } } - - private fun getIds(currencies: List): Pair, NonEmptySet> { - val currencyIdToNetworkId = currencies.associate { currency -> - currency.id to currency.network - } - val currenciesIds = currencyIdToNetworkId.keys.toNonEmptySetOrNull() - val networks = currencyIdToNetworkId.values.toNonEmptySetOrNull() - - requireNotNull(currenciesIds) { "Currencies IDs cannot be empty" } - requireNotNull(networks) { "Networks IDs cannot be empty" } - - return networks to currenciesIds - } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt index a7d60f9374..53f4a03da9 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt @@ -57,19 +57,6 @@ interface CurrenciesRepository { id: CryptoCurrency.ID, ): CryptoCurrency - /** - * Get the coin for a specific network. - * - * @param userWalletId The unique identifier of the user wallet. - * @param networkId The unique identifier of the network. - * @param derivationPath currency derivation path. - */ - suspend fun getNetworkCoin( - userWalletId: UserWalletId, - networkId: Network.ID, - derivationPath: Network.DerivationPath, - ): CryptoCurrency.Coin - /** * Determines whether the currency sending is blocked by network pending transaction * diff --git a/domain/yield-supply/build.gradle.kts b/domain/yield-supply/build.gradle.kts index 58724d5cd7..e8a423d566 100644 --- a/domain/yield-supply/build.gradle.kts +++ b/domain/yield-supply/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(projects.core.ui) /** Domain */ + implementation(projects.domain.account.status) implementation(projects.domain.models) implementation(projects.domain.yieldSupply.models) implementation(projects.domain.transaction.models) diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt index 30daafec70..35cb09af2c 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt @@ -2,13 +2,15 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either import arrow.core.Either.Companion.catch +import arrow.core.getOrElse import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCoin +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.yield.supply.YieldSupplyConst.YIELD_SUPPLY_EVM_CONSTANT_GAS_LIMIT import com.tangem.domain.yield.supply.fixFee @@ -22,7 +24,7 @@ import java.math.RoundingMode class YieldSupplyGetCurrentFeeUseCase( private val feeRepository: FeeRepository, private val quotesRepository: QuotesRepository, - private val currenciesRepository: CurrenciesRepository, + private val singleAccountListSupplier: SingleAccountListSupplier, ) { suspend operator fun invoke( @@ -34,11 +36,13 @@ class YieldSupplyGetCurrentFeeUseCase( val fiatRate = cryptoCurrencyStatus.value.fiatRate ?: error("Fiat rate is missing") require(fiatRate > BigDecimal.ZERO) { "Fiat rate for token must be > 0" } - val nativeCryptoCurrency = currenciesRepository.getNetworkCoin( - userWalletId = userWalletId, - networkId = cryptoCurrencyStatus.currency.network.id, - derivationPath = cryptoCurrencyStatus.currency.network.derivationPath, - ) + val accountStatusList = singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId) + ?: error("Account status list is missing: $userWalletId") + + val nativeCryptoCurrency = accountStatusList.getCoin(cryptoCurrencyStatus.currency) + .getOrElse { + error("Unable to find coin for network ID: ${cryptoCurrencyStatus.currency.network.id}") + } val quotes = quotesRepository.getMultiQuoteSyncOrNull(setOfNotNull(nativeCryptoCurrency.id.rawCurrencyId)) diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCase.kt index 516452538c..c3dcf2911f 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCase.kt @@ -2,13 +2,15 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either import arrow.core.Either.Companion.catch +import arrow.core.getOrElse +import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCoin +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee import java.math.BigDecimal @@ -28,7 +30,7 @@ import java.math.RoundingMode class YieldSupplyGetMaxFeeUseCase( private val yieldSupplyRepository: YieldSupplyRepository, private val quotesRepository: QuotesRepository, - private val currenciesRepository: CurrenciesRepository, + private val singleAccountListSupplier: SingleAccountListSupplier, ) { suspend operator fun invoke( @@ -41,11 +43,13 @@ class YieldSupplyGetMaxFeeUseCase( val fiatRate = cryptoCurrencyStatus.value.fiatRate ?: error("Fiat rate is missing") require(fiatRate > BigDecimal.ZERO) { "Fiat rate for token must be > 0" } - val nativeCryptoCurrency = currenciesRepository.getNetworkCoin( - userWalletId = userWalletId, - networkId = cryptoCurrencyStatus.currency.network.id, - derivationPath = cryptoCurrencyStatus.currency.network.derivationPath, - ) + val accountStatusList = singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId) + ?: error("Account status list is missing: $userWalletId") + + val nativeCryptoCurrency = accountStatusList.getCoin(cryptoCurrencyStatus.currency) + .getOrElse { + error("Unable to find coin for network ID: ${cryptoCurrencyStatus.currency.network.id}") + } val quotes = quotesRepository.getMultiQuoteSyncOrNull(setOfNotNull(nativeCryptoCurrency.id.rawCurrencyId)) diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyMinAmountUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyMinAmountUseCase.kt index ccb1f53f0d..a5efdca334 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyMinAmountUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyMinAmountUseCase.kt @@ -2,11 +2,13 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either import arrow.core.Either.Companion.catch +import arrow.core.getOrElse +import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCoin +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.yield.supply.YieldSupplyConst.YIELD_SUPPLY_EVM_CONSTANT_GAS_LIMIT import com.tangem.domain.yield.supply.fixFee @@ -24,7 +26,7 @@ import java.math.RoundingMode class YieldSupplyMinAmountUseCase( private val feeRepository: FeeRepository, private val quotesRepository: QuotesRepository, - private val currenciesRepository: CurrenciesRepository, + private val singleAccountListSupplier: SingleAccountListSupplier, ) { suspend operator fun invoke( @@ -36,11 +38,13 @@ class YieldSupplyMinAmountUseCase( val fiatRate = cryptoCurrencyStatus.value.fiatRate ?: error("Fiat rate is missing") require(fiatRate > BigDecimal.ZERO) { "Fiat rate for token must be > 0" } - val nativeCryptoCurrency = currenciesRepository.getNetworkCoin( - userWalletId = userWalletId, - networkId = cryptoCurrencyStatus.currency.network.id, - derivationPath = cryptoCurrencyStatus.currency.network.derivationPath, - ) + val accountStatusList = singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId) + ?: error("Account status list is missing: $userWalletId") + + val nativeCryptoCurrency = accountStatusList.getCoin(cryptoCurrencyStatus.currency) + .getOrElse { + error("Unable to find coin for network ID: ${cryptoCurrencyStatus.currency.network.id}") + } val quotes = quotesRepository.getMultiQuoteSyncOrNull(setOfNotNull(nativeCryptoCurrency.id.rawCurrencyId)) diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt index 5bb7f32a1a..423c697e4f 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt @@ -4,6 +4,8 @@ import com.google.common.truth.Truth import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -13,7 +15,6 @@ import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase import io.mockk.coEvery @@ -29,12 +30,12 @@ class YieldSupplyMinAmountUseCaseTest { private val feeRepository: FeeRepository = mockk(relaxed = true) private val quotesRepository: QuotesRepository = mockk(relaxed = true) - private val currenciesRepository: CurrenciesRepository = mockk(relaxed = true) + private val singleAccountListSupplier: SingleAccountListSupplier = mockk(relaxed = true) private val useCase = YieldSupplyMinAmountUseCase( feeRepository = feeRepository, quotesRepository = quotesRepository, - currenciesRepository = currenciesRepository, + singleAccountListSupplier = singleAccountListSupplier, ) @Test @@ -67,12 +68,11 @@ class YieldSupplyMinAmountUseCaseTest { coEvery { feeRepository.getEthereumFeeWithoutGas(userWallet.walletId, token) } returns fee coEvery { - currenciesRepository.getNetworkCoin( - userWalletId = userWallet.walletId, - networkId = token.network.id, - derivationPath = token.network.derivationPath, - ) - } returns nativeCoin + singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId) + } returns AccountList.empty( + userWalletId = userWallet.walletId, + cryptoCurrencies = listOf(nativeCoin, token), + ) val nativeFiatRate = BigDecimal("0.20353756561552608") coEvery { @@ -147,12 +147,11 @@ class YieldSupplyMinAmountUseCaseTest { coEvery { feeRepository.getEthereumFeeWithoutGas(userWallet.walletId, token) } returns fee coEvery { - currenciesRepository.getNetworkCoin( - userWalletId = userWallet.walletId, - networkId = token.network.id, - derivationPath = token.network.derivationPath, - ) - } returns nativeCoin + singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId) + } returns AccountList.empty( + userWalletId = userWallet.walletId, + cryptoCurrencies = listOf(nativeCoin, token), + ) coEvery { quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!)) diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCaseTest.kt index 54977eb022..711754e926 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCaseTest.kt @@ -4,6 +4,8 @@ import arrow.core.Either import com.google.common.truth.Truth.assertThat import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -12,7 +14,6 @@ import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.utils.convertToSdkAmount import io.mockk.coEvery @@ -30,7 +31,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { private val feeRepository: FeeRepository = mockk() private val quotesRepository: QuotesRepository = mockk() - private val currenciesRepository: CurrenciesRepository = mockk() + private val singleAccountListSupplier: SingleAccountListSupplier = mockk() private lateinit var useCase: YieldSupplyGetCurrentFeeUseCase @@ -41,7 +42,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { useCase = YieldSupplyGetCurrentFeeUseCase( feeRepository = feeRepository, quotesRepository = quotesRepository, - currenciesRepository = currenciesRepository, + singleAccountListSupplier = singleAccountListSupplier, ) } @@ -65,12 +66,11 @@ class YieldSupplyGetCurrentFeeUseCaseTest { coEvery { feeRepository.getEthereumFeeWithoutGas(userWalletId, token) } returns feeWithoutGas coEvery { - currenciesRepository.getNetworkCoin( - userWalletId = userWalletId, - networkId = token.network.id, - derivationPath = token.network.derivationPath, - ) - } returns nativeCoin + singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId) + } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(nativeCoin, token), + ) coEvery { quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!)) } returns setOf( @@ -117,12 +117,11 @@ class YieldSupplyGetCurrentFeeUseCaseTest { coEvery { feeRepository.getEthereumFeeWithoutGas(userWalletId, token) } returns feeWithoutGas coEvery { - currenciesRepository.getNetworkCoin( - userWalletId = userWalletId, - networkId = token.network.id, - derivationPath = token.network.derivationPath, - ) - } returns nativeCoin + singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId) + } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(nativeCoin, token), + ) coEvery { quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!)) } returns setOf( @@ -186,12 +185,11 @@ class YieldSupplyGetCurrentFeeUseCaseTest { amount = BigDecimal.ZERO.convertToSdkAmount(cryptoStatus), ) coEvery { - currenciesRepository.getNetworkCoin( - userWalletId = userWalletId, - networkId = token.network.id, - derivationPath = token.network.derivationPath, - ) - } returns nativeCoin + singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId) + } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(nativeCoin, token), + ) coEvery { quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!)) } returns null val result = useCase(userWalletId, cryptoStatus) @@ -216,12 +214,11 @@ class YieldSupplyGetCurrentFeeUseCaseTest { amount = BigDecimal.ZERO.convertToSdkAmount(cryptoStatus), ) coEvery { - currenciesRepository.getNetworkCoin( - userWalletId = userWalletId, - networkId = token.network.id, - derivationPath = token.network.derivationPath, - ) - } returns nativeCoin + singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId) + } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(nativeCoin, token), + ) coEvery { quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!)) } returns emptySet() val result = useCase(userWalletId, cryptoStatus) @@ -246,12 +243,11 @@ class YieldSupplyGetCurrentFeeUseCaseTest { amount = BigDecimal.ZERO.convertToSdkAmount(cryptoStatus), ) coEvery { - currenciesRepository.getNetworkCoin( - userWalletId = userWalletId, - networkId = token.network.id, - derivationPath = token.network.derivationPath, - ) - } returns nativeCoin + singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId) + } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(nativeCoin, token), + ) coEvery { quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!)) } returns setOf( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt index 15b4eed93a..3fc7167dfc 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.onramp.success.model import arrow.core.getOrElse +import arrow.core.toOption import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -11,6 +12,8 @@ import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCryptoCurrency +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.onramp.GetOnrampStatusUseCase @@ -20,7 +23,6 @@ import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent import com.tangem.domain.onramp.model.OnrampStatus import com.tangem.domain.onramp.model.cache.OnrampTransaction import com.tangem.domain.onramp.model.error.OnrampError -import com.tangem.domain.tokens.GetCryptoCurrencyUseCase import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.onramp.component.OnrampSuccessComponent @@ -48,7 +50,7 @@ internal class OnrampSuccessComponentModel @Inject constructor( private val urlOpener: UrlOpener, private val getOnrampTransactionUseCase: GetOnrampTransactionUseCase, private val getOnrampStatusUseCase: GetOnrampStatusUseCase, - private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase, + private val singleAccountListSupplier: SingleAccountListSupplier, private val onrampRemoveTransactionUseCase: OnrampRemoveTransactionUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val analyticsEventHandler: AnalyticsEventHandler, @@ -97,14 +99,15 @@ internal class OnrampSuccessComponentModel @Inject constructor( showErrorAlert(OnrampError.DomainError("UserWallet not found")) return@launch } - cryptoCurrency = getCryptoCurrencyUseCase( - userWallet = userWallet, - cryptoCurrencyId = transaction.toCurrencyId, - ).getOrElse { - Timber.e("Crypto currency not found") - showErrorAlert(OnrampError.DomainError(null)) - return@launch - } + + cryptoCurrency = singleAccountListSupplier.getSyncOrNull(transaction.userWalletId) + ?.getCryptoCurrency(currencyIdValue = transaction.toCurrencyId)?.getOrNull() + .toOption() + .getOrElse { + Timber.e("Crypto currency not found") + showErrorAlert(OnrampError.DomainError(null)) + return@launch + } startStatusUpdateTask(transaction) }, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/DefaultSellRedirectDeepLinkHandler.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/DefaultSellRedirectDeepLinkHandler.kt index 402cd981c5..71f276e7e4 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/DefaultSellRedirectDeepLinkHandler.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/DefaultSellRedirectDeepLinkHandler.kt @@ -1,11 +1,16 @@ package com.tangem.features.send.v2.deeplink +import arrow.core.Option import arrow.core.getOrElse +import arrow.core.raise.option import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseBigDecimalOrNull -import com.tangem.domain.tokens.GetCryptoCurrencyUseCase +import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCryptoCurrency +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import dagger.assisted.Assisted @@ -21,7 +26,7 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor( @Assisted queryParams: Map, appRouter: AppRouter, getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, - getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase, + private val singleAccountListSupplier: SingleAccountListSupplier, ) : SellRedirectDeepLinkHandler { init { @@ -51,8 +56,8 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor( } scope.launch { - val cryptoCurrency = getCryptoCurrencyUseCase(userWallet, currencyId).getOrElse { error -> - Timber.e("Error on getting cryptoCurrency: $error") + val cryptoCurrency = getCryptoCurrency(userWallet.walletId, currencyId).getOrElse { + Timber.e("Error on getting cryptoCurrency: $currencyId") return@launch } // Convert using universal parser to account for regional separators @@ -72,6 +77,15 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor( ) } + private suspend fun getCryptoCurrency(userWalletId: UserWalletId, currencyId: String): Option = + option { + val accountStatusList = singleAccountListSupplier.getSyncOrNull(userWalletId) + + ensureNotNull(accountStatusList) + + return accountStatusList.getCryptoCurrency(currencyIdValue = currencyId) + } + @AssistedFactory interface Factory : SellRedirectDeepLinkHandler.Factory { override fun create( 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 64c0be0735..81b15eb645 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 @@ -9,6 +9,7 @@ import com.tangem.common.routing.deeplink.DeeplinkConst.TRANSACTION_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -18,9 +19,6 @@ 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.GetCryptoCurrencyUseCase -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.wallet.WalletBalanceFetcher import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.SelectWalletUseCase @@ -42,7 +40,6 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( @Assisted private val isFromOnNewIntent: Boolean, private val appRouter: AppRouter, private val selectWalletUseCase: SelectWalletUseCase, - private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase, private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, private val tokenDetailsDeepLinkActionTrigger: TokenDetailsDeepLinkActionTrigger, private val walletDeepLinkActionTrigger: WalletDeepLinkActionTrigger, @@ -50,7 +47,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( private val getUserWalletUseCase: GetUserWalletUseCase, private val walletBalanceFetcher: WalletBalanceFetcher, private val tangemPayFeatureToggles: TangemPayFeatureToggles, - private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, + private val singleAccountListSupplier: SingleAccountListSupplier, ) : TokenDetailsDeepLinkHandler { init { @@ -152,13 +149,12 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( isNetwork && isCurrency && isCorrectDerivation } } else { - getCryptoCurrencyUseCase(userWalletId = userWallet.walletId).getOrNull() + singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId) + ?.mainAccount?.cryptoCurrencies?.first() } private suspend fun getCryptoCurrencies(userWalletId: UserWalletId): List? { - return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId), - )?.toList() + return singleAccountListSupplier.getSyncOrNull(userWalletId)?.flattenCurrencies() } @AssistedFactory From e22331ec90e54e3c8be72aa5a4de90eba6f470b9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Mar 2026 15:28:52 +0400 Subject: [PATCH 35/60] Updated on 2026-08-14 --- .../tap/di/domain/TokensDomainModule.kt | 36 ---- domain/account/status/build.gradle.kts | 1 + .../status/di/AccountStatusUseCaseModule.kt | 13 ++ ...tFeePaidCryptoCurrencyStatusSyncUseCase.kt | 23 ++- .../tokens/error/AddCustomTokenError.kt | 6 - .../mapper/CurrencyStatusErrorMappers.kt | 17 -- .../error/mapper/TokenListErrorMappers.kt | 17 -- .../BaseCurrencyStatusOperations.kt | 154 ------------------ .../CurrenciesStatusesOperations.kt | 21 --- .../utils/CurrencyStatusProxyCreator.kt | 97 ----------- .../features/send/v2/send/model/SendModel.kt | 2 +- .../send/v2/sendnft/model/NFTSendModel.kt | 2 +- .../impl/presentation/model/StakingModel.kt | 1 + .../sendviaswap/model/SendWithSwapModel.kt | 2 +- .../feature/swap/domain/SwapInteractorImpl.kt | 1 + .../tangem/feature/swap/model/SwapModel.kt | 2 +- .../approve/model/YieldSupplyApproveModel.kt | 2 +- .../model/YieldSupplyStartEarningModel.kt | 2 +- .../model/YieldSupplyStopEarningModel.kt | 2 +- 19 files changed, 38 insertions(+), 363 deletions(-) rename domain/{tokens/src/main/kotlin/com/tangem/domain/tokens => account/status/src/main/java/com/tangem/domain/account/status/usecase}/GetFeePaidCryptoCurrencyStatusSyncUseCase.kt (54%) delete mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/AddCustomTokenError.kt delete mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/CurrencyStatusErrorMappers.kt delete mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/TokenListErrorMappers.kt delete mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt delete mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt delete mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/utils/CurrencyStatusProxyCreator.kt 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 07920010a7..9dd18523ac 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 @@ -5,18 +5,14 @@ import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.repository.NetworksRepository import com.tangem.domain.networks.single.SingleNetworkStatusFetcher -import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.promo.PromoRepository import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher -import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.staking.single.SingleStakingBalanceFetcher -import com.tangem.domain.staking.single.SingleStakingBalanceSupplier import com.tangem.domain.tokens.* -import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository @@ -88,18 +84,6 @@ internal object TokensDomainModule { ) } - @Provides - @Singleton - fun provideGetFeePaidCryptoCurrencyStatusSyncUseCase( - currenciesRepository: CurrenciesRepository, - currencyStatusOperations: BaseCurrencyStatusOperations, - ): GetFeePaidCryptoCurrencyStatusSyncUseCase { - return GetFeePaidCryptoCurrencyStatusSyncUseCase( - currenciesRepository = currenciesRepository, - currencyStatusOperations = currencyStatusOperations, - ) - } - @Provides @Singleton fun provideGetMinimumTransactionAmountSyncUseCase( @@ -183,26 +167,6 @@ internal object TokensDomainModule { return GetCurrencyCheckUseCase(currencyChecksRepository, dispatchers) } - @Provides - @Singleton - fun provideBaseCurrencyStatusOperations( - currenciesRepository: CurrenciesRepository, - singleNetworkStatusSupplier: SingleNetworkStatusSupplier, - singleQuoteStatusSupplier: SingleQuoteStatusSupplier, - singleStakingBalanceSupplier: SingleStakingBalanceSupplier, - multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - stakingIdFactory: StakingIdFactory, - ): BaseCurrencyStatusOperations { - return BaseCurrencyStatusOperations( - currenciesRepository = currenciesRepository, - singleNetworkStatusSupplier = singleNetworkStatusSupplier, - singleQuoteStatusSupplier = singleQuoteStatusSupplier, - singleStakingBalanceSupplier = singleStakingBalanceSupplier, - multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - stakingIdFactory = stakingIdFactory, - ) - } - @Provides @Singleton fun provideWalletBalanceFetcher( diff --git a/domain/account/status/build.gradle.kts b/domain/account/status/build.gradle.kts index 28daf7df24..d22d8549d9 100644 --- a/domain/account/status/build.gradle.kts +++ b/domain/account/status/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { api(projects.domain.referral) api(projects.domain.staking) api(projects.domain.tokens) + api(projects.domain.tokens.models) api(projects.domain.walletManager) api(projects.domain.wallets) diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt index 90ea428eda..e2b9696c89 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt @@ -3,6 +3,7 @@ package com.tangem.domain.account.status.di import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.usecase.* import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner @@ -157,6 +158,18 @@ internal object AccountStatusUseCaseModule { ) } + @Provides + @Singleton + fun provideGetFeePaidCryptoCurrencyStatusSyncUseCase( + currenciesRepository: CurrenciesRepository, + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + ): GetFeePaidCryptoCurrencyStatusSyncUseCase { + return GetFeePaidCryptoCurrencyStatusSyncUseCase( + currenciesRepository = currenciesRepository, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + ) + } + @Provides @Singleton fun provideCryptoCurrencyMetadataCleaner( diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetFeePaidCryptoCurrencyStatusSyncUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetFeePaidCryptoCurrencyStatusSyncUseCase.kt similarity index 54% rename from domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetFeePaidCryptoCurrencyStatusSyncUseCase.kt rename to domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetFeePaidCryptoCurrencyStatusSyncUseCase.kt index 77bb61fb5e..21a209863f 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetFeePaidCryptoCurrencyStatusSyncUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetFeePaidCryptoCurrencyStatusSyncUseCase.kt @@ -1,17 +1,19 @@ -package com.tangem.domain.tokens +package com.tangem.domain.account.status.usecase import arrow.core.Either import arrow.core.raise.either +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCoinStatus +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.FeePaidCurrency -import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations import com.tangem.domain.tokens.repository.CurrenciesRepository class GetFeePaidCryptoCurrencyStatusSyncUseCase( - internal val currenciesRepository: CurrenciesRepository, - private val currencyStatusOperations: BaseCurrencyStatusOperations, + private val currenciesRepository: CurrenciesRepository, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, ) { suspend operator fun invoke( @@ -25,12 +27,17 @@ class GetFeePaidCryptoCurrencyStatusSyncUseCase( return either { when (feePaidCurrency) { is FeePaidCurrency.Coin -> { - currencyStatusOperations.getNetworkCoinSync(userWalletId, network.id, network.derivationPath) - .getOrNull() + val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull(userWalletId) + + accountStatusList?.getCoinStatus(network)?.getOrNull() } is FeePaidCurrency.Token -> { - currencyStatusOperations.getCurrencyStatusSync(userWalletId, feePaidCurrency.tokenId) - .getOrNull() + val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull(userWalletId) + + accountStatusList.getCryptoCurrencyStatus( + currencyId = feePaidCurrency.tokenId, + network = network, + ).getOrNull() } is FeePaidCurrency.SameCurrency, is FeePaidCurrency.FeeResource, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/AddCustomTokenError.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/AddCustomTokenError.kt deleted file mode 100644 index fcbb456b66..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/AddCustomTokenError.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.domain.tokens.error - -enum class AddCustomTokenError { - FIELD_IS_EMPTY, - INVALID_CONTRACT_ADDRESS, -} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/CurrencyStatusErrorMappers.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/CurrencyStatusErrorMappers.kt deleted file mode 100644 index 6fc01f0c95..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/CurrencyStatusErrorMappers.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.domain.tokens.error.mapper - -import com.tangem.domain.tokens.error.CurrencyStatusError -import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations - -internal fun CurrenciesStatusesOperations.Error.mapToCurrencyError(): CurrencyStatusError { - return when (this) { - is CurrenciesStatusesOperations.Error.DataError -> CurrencyStatusError.DataError(this.cause) - is CurrenciesStatusesOperations.Error.EmptyStakingBalances, - is CurrenciesStatusesOperations.Error.EmptyNetworksStatuses, - is CurrenciesStatusesOperations.Error.EmptyQuotes, - is CurrenciesStatusesOperations.Error.EmptyCurrencies, - is CurrenciesStatusesOperations.Error.EmptyAddresses, - is CurrenciesStatusesOperations.Error.UnableToCreateCurrencyStatus, - -> CurrencyStatusError.UnableToCreateCurrency - } -} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/TokenListErrorMappers.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/TokenListErrorMappers.kt deleted file mode 100644 index 51e08ac450..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/mapper/TokenListErrorMappers.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.domain.tokens.error.mapper - -import com.tangem.domain.tokens.error.TokenListError -import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations - -internal fun CurrenciesStatusesOperations.Error.mapToTokenListError(): TokenListError { - return when (this) { - is CurrenciesStatusesOperations.Error.DataError -> TokenListError.DataError(this.cause) - is CurrenciesStatusesOperations.Error.EmptyNetworksStatuses, - is CurrenciesStatusesOperations.Error.EmptyQuotes, - is CurrenciesStatusesOperations.Error.EmptyCurrencies, - is CurrenciesStatusesOperations.Error.EmptyAddresses, - is CurrenciesStatusesOperations.Error.UnableToCreateCurrencyStatus, - is CurrenciesStatusesOperations.Error.EmptyStakingBalances, - -> TokenListError.EmptyTokens - } -} \ 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 deleted file mode 100644 index 5714da190b..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.tangem.domain.tokens.operations - -import arrow.core.Either -import arrow.core.left -import arrow.core.raise.* -import arrow.core.right -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.staking.StakingBalance -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.networks.single.SingleNetworkStatusProducer -import com.tangem.domain.networks.single.SingleNetworkStatusSupplier -import com.tangem.domain.quotes.single.SingleQuoteStatusProducer -import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier -import com.tangem.domain.staking.StakingIdFactory -import com.tangem.domain.staking.single.SingleStakingBalanceProducer -import com.tangem.domain.staking.single.SingleStakingBalanceSupplier -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.tokens.utils.CurrencyStatusProxyCreator -import kotlinx.coroutines.flow.firstOrNull - -/** - * Base operations for working with currency status - * - * @property currenciesRepository repository for currencies - * -[REDACTED_AUTHOR] - */ -@Suppress("LargeClass", "LongParameterList") -class BaseCurrencyStatusOperations( - private val currenciesRepository: CurrenciesRepository, - private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, - private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, - private val singleStakingBalanceSupplier: SingleStakingBalanceSupplier, - private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val stakingIdFactory: StakingIdFactory, -) { - - private val currencyStatusProxyCreator = CurrencyStatusProxyCreator() - - suspend fun getNetworkCoinSync( - userWalletId: UserWalletId, - networkId: Network.ID, - derivationPath: Network.DerivationPath, - ): Either { - val currency = recover( - block = { getNetworkCoin(userWalletId, networkId, derivationPath) }, - recover = { return it.left() }, - ) - - return getCurrencyStatusSync(userWalletId, currency.id) - } - - suspend fun getCurrencyStatusSync( - userWalletId: UserWalletId, - cryptoCurrencyId: CryptoCurrency.ID, - isSingleWalletWithTokens: Boolean = false, - ): Either { - return either { - catch( - block = { - val currency = if (isSingleWalletWithTokens) { - currenciesRepository.getSingleCurrencyWalletWithCardCurrency(userWalletId, cryptoCurrencyId) - } else { - getMultiCurrencyWalletCurrency(userWalletId, cryptoCurrencyId) - } - - val quote = cryptoCurrencyId.rawCurrencyId?.let { rawId -> - singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawId)) - .firstOrNull() - } - ?.right() - ?: Error.EmptyQuotes.left() - - val networkStatuses = singleNetworkStatusSupplier( - params = SingleNetworkStatusProducer.Params( - userWalletId = userWalletId, - network = currency.network, - ), - ) - .firstOrNull() - .right() - - val stakingBalances = getStakingBalanceSync(userWalletId, currency) - - return currencyStatusProxyCreator.createCurrencyStatus( - currency = currency, - maybeQuoteStatus = quote, - maybeNetworkStatus = networkStatuses, - maybeStakingBalance = stakingBalances, - ) - }, - catch = { raise(Error.DataError(it)) }, - ) - } - } - - private suspend fun Raise.getMultiCurrencyWalletCurrency( - userWalletId: UserWalletId, - currencyId: CryptoCurrency.ID, - ): CryptoCurrency { - return Either.catch { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.firstOrNull { it.id == currencyId } - ?: error("Unable to find currency with ID: $currencyId") - } - .mapLeft(Error::DataError) - .bind() - } - - private suspend fun Raise.getNetworkCoin( - userWalletId: UserWalletId, - networkId: Network.ID, - derivationPath: Network.DerivationPath, - ): CryptoCurrency { - return Either.catch { - 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() - } - - private suspend fun getStakingBalanceSync( - userWalletId: UserWalletId, - cryptoCurrency: CryptoCurrency, - ): Either = either { - val stakingId = stakingIdFactory.create(userWalletId, cryptoCurrency) - .mapLeft { - val exception = IllegalStateException("$it") - Error.DataError(exception) - } - .bind() - - val yieldBalance = singleStakingBalanceSupplier.getSyncOrNull( - params = SingleStakingBalanceProducer.Params( - userWalletId = userWalletId, - stakingId = stakingId, - ), - ) - - ensureNotNull(yieldBalance) { Error.EmptyStakingBalances } - } -} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt deleted file mode 100644 index a8f25caebe..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.domain.tokens.operations - -class CurrenciesStatusesOperations { - - sealed class Error { - - data object EmptyCurrencies : Error() - - data object EmptyQuotes : Error() - - data object EmptyNetworksStatuses : Error() - - data object EmptyAddresses : Error() - - data object UnableToCreateCurrencyStatus : Error() - - data class DataError(val cause: Throwable) : Error() - - data object EmptyStakingBalances : Error() - } -} \ No newline at end of file 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 deleted file mode 100644 index 59937cd45f..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/utils/CurrencyStatusProxyCreator.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.tangem.domain.tokens.utils - -import arrow.core.Either -import arrow.core.NonEmptyList -import arrow.core.raise.either -import arrow.core.raise.recover -import arrow.core.toNonEmptySetOrNull -import arrow.core.toOption -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.StakingBalance -import com.tangem.domain.models.staking.StakingID -import com.tangem.domain.staking.model.StakingIntegrationID -import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory -import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error - -/** - * Proxy creator of [CryptoCurrencyStatus]. Used [CryptoCurrencyStatusFactory] to create statuses. - * -[REDACTED_AUTHOR] - */ -class CurrencyStatusProxyCreator { - - fun createCurrencyStatus( - currency: CryptoCurrency, - maybeQuoteStatus: Either, - maybeNetworkStatus: Either, - maybeStakingBalance: Either?, - ): Either = either { - val networkStatus = maybeNetworkStatus.bind() - val quote = recover( - block = { maybeQuoteStatus.bind() }, - recover = { null }, - ) - val stakingBalance = maybeStakingBalance?.getOrNull() - - createCurrencyStatus( - currency = currency, - quoteStatus = quote, - networkStatus = networkStatus, - stakingBalance = stakingBalance, - ) - } - - fun createCurrenciesStatuses( - currencies: NonEmptyList, - maybeQuotes: Either>?, - maybeNetworkStatuses: Either>, - maybeStakingBalances: Either>, - ): Either> = either { - val networksStatuses = maybeNetworkStatuses.bind().toNonEmptySetOrNull() - val quoteStatuses: Set? = maybeQuotes?.getOrNull()?.ifEmpty { null } - - val stakingBalances = maybeStakingBalances.getOrNull() - - currencies.map { currency -> - val quote = quoteStatuses?.firstOrNull { it.rawCurrencyId == currency.id.rawCurrencyId } - val networkStatus = networksStatuses?.firstOrNull { it.network == currency.network } - val address = networkStatus.getAddress() - - val supportedIntegration = StakingIntegrationID.create(currencyId = currency.id)?.value - - val stakingBalance = if (supportedIntegration != null && address != null) { - val stakingId = StakingID(integrationId = supportedIntegration, address = address) - - stakingBalances?.firstOrNull { it.stakingId == stakingId } - ?: StakingBalance.Error(stakingId = stakingId) - } else { - null - } - - createCurrencyStatus( - currency = currency, - quoteStatus = quote, - networkStatus = networkStatus, - stakingBalance = stakingBalance, - ) - } - } - - private fun createCurrencyStatus( - currency: CryptoCurrency, - quoteStatus: QuoteStatus?, - networkStatus: NetworkStatus?, - stakingBalance: StakingBalance?, - ): CryptoCurrencyStatus { - return CryptoCurrencyStatusFactory.create( - currency = currency, - maybeNetworkStatus = networkStatus.toOption(), - maybeQuoteStatus = quoteStatus.toOption(), - maybeStakingBalance = stakingBalance.toOption(), - ) - } -} \ No newline at end of file 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 16449c2fc5..5174229910 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 @@ -33,7 +33,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase -import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase 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 4d84c0b4a2..54b8e11901 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 @@ -27,7 +27,7 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.transaction.error.GetFeeError 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 d148b80650..27bf32458e 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 @@ -61,6 +61,7 @@ import com.tangem.domain.staking.model.stakekit.action.StakingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.* import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.* diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt index dc868296e8..039d065657 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt @@ -17,7 +17,7 @@ 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.swap.models.SwapDirection -import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.v2.api.entity.FeeSelectorUM 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 1856211b21..0489edb872 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 @@ -38,6 +38,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayWithdrawExchangeState import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.* import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck 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 1d1951f503..aca9ef968e 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 @@ -69,7 +69,7 @@ import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayWithdrawUseCase -import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase import com.tangem.domain.transaction.error.GetFeeError diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt index f817548872..08cbe91674 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt @@ -20,7 +20,7 @@ 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.isHotWallet -import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index f66d505e7f..0e7ff956e2 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -20,7 +20,7 @@ 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.models.wallet.isHotWallet -import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt index dd0b697ccd..fbee3f5bb8 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt @@ -18,7 +18,7 @@ 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.isHotWallet -import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.yield.supply.INCREASE_GAS_LIMIT_FOR_SUPPLY From 158fd355def5ad2f26bdecc8e00b16dba006b75a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 13 Mar 2026 11:01:35 +0400 Subject: [PATCH 36/60] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- .../tap/di/domain/TokensDomainModule.kt | 22 --- .../GeneratedEnvironmentConfigConverter.kt | 2 +- .../utils/CryptoCurrencyBalanceFetcher.kt | 16 ++ .../tokens/FetchCurrencyStatusUseCase.kt | 161 ------------------ .../state/helpers/StakingBalanceUpdater.kt | 10 +- .../feature/swap/domain/SwapInteractorImpl.kt | 12 +- .../DefaultTokenDetailsDeepLinkHandler.kt | 8 +- .../tokendetails/model/TokenDetailsModel.kt | 7 +- 9 files changed, 40 insertions(+), 200 deletions(-) delete mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 827750b5a0..636d7a8aa0 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 827750b5a0ef7202120b0df0ae903d70e18e3d32 +Subproject commit 636d7a8aa0e330e9b95e91d85f23ad15ac6d913f 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 9dd18523ac..f4503ac3d3 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 @@ -11,7 +11,6 @@ import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher import com.tangem.domain.staking.repositories.StakingRepository -import com.tangem.domain.staking.single.SingleStakingBalanceFetcher import com.tangem.domain.tokens.* import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository @@ -29,7 +28,6 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -@Suppress("TooManyFunctions", "LargeClass") internal object TokensDomainModule { @Provides @@ -46,26 +44,6 @@ internal object TokensDomainModule { return DefaultTokensFeatureToggles(featureTogglesManager = featureTogglesManager) } - @Provides - @Singleton - fun provideFetchCurrencyStatusUseCase( - currenciesRepository: CurrenciesRepository, - singleNetworkStatusFetcher: SingleNetworkStatusFetcher, - multiQuoteStatusFetcher: MultiQuoteStatusFetcher, - singleStakingBalanceFetcher: SingleStakingBalanceFetcher, - multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - stakingIdFactory: StakingIdFactory, - ): FetchCurrencyStatusUseCase { - return FetchCurrencyStatusUseCase( - currenciesRepository = currenciesRepository, - singleNetworkStatusFetcher = singleNetworkStatusFetcher, - multiQuoteStatusFetcher = multiQuoteStatusFetcher, - singleStakingBalanceFetcher = singleStakingBalanceFetcher, - multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - stakingIdFactory = stakingIdFactory, - ) - } - @Provides @Singleton fun provideGetCryptoCurrencyActionsUseCase( diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt index fb57e24aee..28644f5c6b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt @@ -51,7 +51,7 @@ internal object GeneratedEnvironmentConfigConverter { bffStaticTokenDev = GeneratedEnvironmentConfig.bffStaticTokenDev, gaslessTxApiKeyDev = GeneratedEnvironmentConfig.gaslessTxApiKeyDev, gaslessTxApiKey = GeneratedEnvironmentConfig.gaslessTxApiKey, - customerIoCdpApiKey = GeneratedEnvironmentConfig.CustomerIO.appApiKey, + customerIoCdpApiKey = GeneratedEnvironmentConfig.CustomerIO.androidApiKey, surveySparrowToken = GeneratedEnvironmentConfig.SurveySparrow.apiKey, ) } diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyBalanceFetcher.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyBalanceFetcher.kt index d9e1e38b0d..367d8411cf 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyBalanceFetcher.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyBalanceFetcher.kt @@ -35,6 +35,10 @@ class CryptoCurrencyBalanceFetcher( private val mutex = Mutex() + operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency) { + invoke(userWalletId = userWalletId, currencies = listOf(currency)) + } + operator fun invoke(userWalletId: UserWalletId, currencies: List) { if (currencies.isEmpty()) return @@ -45,6 +49,18 @@ class CryptoCurrencyBalanceFetcher( } } + suspend fun invokeAndAwait(userWalletId: UserWalletId, currency: CryptoCurrency) { + invokeAndAwait(userWalletId = userWalletId, currencies = listOf(currency)) + } + + suspend fun invokeAndAwait(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) return + + mutex.withLock { + refreshBalances(userWalletId, currencies) + } + } + private suspend fun refreshBalances(userWalletId: UserWalletId, currencies: List) { coroutineScope { val results = listOf( 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 deleted file mode 100644 index a1598a3f5f..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.tangem.domain.tokens - -import arrow.core.Either -import arrow.core.getOrElse -import arrow.core.raise.Raise -import arrow.core.raise.catch -import arrow.core.raise.either -import arrow.core.right -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.networks.single.SingleNetworkStatusFetcher -import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher -import com.tangem.domain.staking.StakingIdFactory -import com.tangem.domain.staking.single.SingleStakingBalanceFetcher -import com.tangem.domain.tokens.error.CurrencyStatusError -import com.tangem.domain.tokens.repository.CurrenciesRepository -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope - -/** - * Use case responsible for fetching currency status information, including network status - * and quotes for a given cryptocurrency. It provides methods to fetch currency status either - * by providing a specific currency ID or fetching the status of the primary currency. - * - * @param currenciesRepository The repository for retrieving currency-related data. - */ -// TODO: Add tests -@Suppress("LongParameterList") -class FetchCurrencyStatusUseCase( - private val currenciesRepository: CurrenciesRepository, - private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, - private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, - private val singleStakingBalanceFetcher: SingleStakingBalanceFetcher, - private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val stakingIdFactory: StakingIdFactory, -) { - - /** - * Fetches the status of a specific cryptocurrency for a given user wallet. - * - * @param userWalletId The ID of the user's wallet. - * @param id The ID of the cryptocurrency. - * @return An [Either] representing success (Right) or an error (Left) in fetching the status. - */ - suspend operator fun invoke(userWalletId: UserWalletId, id: CryptoCurrency.ID): Either { - return either { - val currency = getCurrency(userWalletId, id) - - return@either coroutineScope { - val fetchStatus = async { - fetchNetworkStatus(userWalletId = userWalletId, network = currency.network) - } - - val fetchQuote = async { fetchQuote(currencyId = currency.id) } - - val fetchStakingBalance = async { - fetchStakingBalance(userWalletId = userWalletId, cryptoCurrency = currency) - } - - awaitAll(fetchStatus, fetchQuote, fetchStakingBalance).summarizeResult() - } - } - } - - /** - * Fetches the status of the primary cryptocurrency for a given user wallet. - * - * @param userWalletId The ID of the user's wallet. - * @param refresh Indicates whether to force a refresh of the status data. - * @return An [Either] representing success (Right) or an error (Left) in fetching the status. - */ - suspend operator fun invoke( - userWalletId: UserWalletId, - refresh: Boolean = false, - ): Either { - return either { - val currency = getPrimaryCurrency(userWalletId, refresh) - - return@either coroutineScope { - val fetchStatus = async { - fetchNetworkStatus(userWalletId = userWalletId, network = currency.network) - } - - val fetchQuote = async { fetchQuote(currencyId = currency.id) } - - awaitAll(fetchStatus, fetchQuote).summarizeResult() - } - } - } - - private suspend fun Raise.getCurrency( - userWalletId: UserWalletId, - id: CryptoCurrency.ID, - ): CryptoCurrency { - return catch( - block = { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.firstOrNull { it.id == id } - ?: error("Unable to find currency with ID: $id") - }, - ) { - raise(CurrencyStatusError.DataError(it)) - } - } - - private suspend fun Raise.getPrimaryCurrency( - userWalletId: UserWalletId, - refresh: Boolean = false, - ): CryptoCurrency { - return catch({ currenciesRepository.getSingleCurrencyWalletPrimaryCurrency(userWalletId, refresh) }) { - raise(CurrencyStatusError.DataError(it)) - } - } - - private suspend fun fetchNetworkStatus(userWalletId: UserWalletId, network: Network): Either { - return singleNetworkStatusFetcher( - params = SingleNetworkStatusFetcher.Params(userWalletId = userWalletId, network = network), - ) - } - - private suspend fun fetchQuote(currencyId: CryptoCurrency.ID): Either { - return multiQuoteStatusFetcher( - params = MultiQuoteStatusFetcher.Params( - currenciesIds = setOfNotNull(currencyId.rawCurrencyId), - appCurrencyId = null, - ), - ) - } - - private suspend fun fetchStakingBalance( - userWalletId: UserWalletId, - cryptoCurrency: CryptoCurrency, - ): Either = either { - val stakingId = stakingIdFactory.create( - userWalletId = userWalletId, - currencyId = cryptoCurrency.id, - network = cryptoCurrency.network, - ) - .getOrElse { - when (it) { - is StakingIdFactory.Error.UnableToGetAddress -> raise(IllegalStateException("$it")) - StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() - } - - return@either - } - - singleStakingBalanceFetcher( - params = SingleStakingBalanceFetcher.Params(userWalletId = userWalletId, stakingId = stakingId), - ) - .bind() - } - - private fun List>.summarizeResult(): Either { - return firstOrNull { it.isLeft() } ?: Unit.right() - } -} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt index 2128c11c3d..aea51e584a 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt @@ -1,16 +1,16 @@ package com.tangem.features.staking.impl.presentation.state.helpers -import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.staking.FetchActionsUseCase import com.tangem.domain.staking.FetchStakingYieldBalanceUseCase import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus -import com.tangem.domain.tokens.FetchCurrencyStatusUseCase import com.tangem.domain.tokens.FetchPendingTransactionsUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter +import com.tangem.utils.coroutines.AppCoroutineScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -22,7 +22,7 @@ internal class StakingBalanceUpdater @AssistedInject constructor( private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, private val fetchActionsUseCase: FetchActionsUseCase, private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter, - private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, private val fetchStakingYieldBalanceUseCase: FetchStakingYieldBalanceUseCase, private val coroutineScope: AppCoroutineScope, @Assisted private val userWallet: UserWallet, @@ -83,9 +83,9 @@ internal class StakingBalanceUpdater @AssistedInject constructor( private suspend fun fetchCurrencyStatus(delayMillis: Long = 0L) { delay(delayMillis) - fetchCurrencyStatusUseCase( + cryptoCurrencyBalanceFetcher.invokeAndAwait( userWalletId = userWallet.walletId, - id = cryptoCurrencyStatus.currency.id, + currency = cryptoCurrencyStatus.currency, ) } 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 0489edb872..3b7b8442ab 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 @@ -21,6 +21,8 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.appcurrency.repository.AppCurrencyRepository @@ -38,8 +40,10 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayWithdrawExchangeState import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher -import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase -import com.tangem.domain.tokens.* +import com.tangem.domain.tokens.GetAssetRequirementsUseCase +import com.tangem.domain.tokens.GetCurrencyCheckUseCase +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -75,7 +79,7 @@ import java.math.RoundingMode internal class SwapInteractorImpl @AssistedInject constructor( private val repository: SwapRepository, private val allowPermissionsHandler: AllowPermissionsHandler, - private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, private val sendTransactionUseCase: SendTransactionUseCase, private val createTransactionUseCase: CreateTransactionUseCase, private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, @@ -438,7 +442,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) { allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress) - fetchCurrencyStatusUseCase(userWalletId = userWalletId, id = fromToken.currency.id) + cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currency = fromToken.currency) } return if (isAllowedToSpend && isBalanceWithoutFeeEnough) { provider to loadDexSwapData( 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 81b15eb645..20cf144c6d 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 @@ -18,7 +18,7 @@ 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.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.tokens.wallet.WalletBalanceFetcher import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.SelectWalletUseCase @@ -40,7 +40,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( @Assisted private val isFromOnNewIntent: Boolean, private val appRouter: AppRouter, private val selectWalletUseCase: SelectWalletUseCase, - private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, private val tokenDetailsDeepLinkActionTrigger: TokenDetailsDeepLinkActionTrigger, private val walletDeepLinkActionTrigger: WalletDeepLinkActionTrigger, private val analyticsEventHandler: AnalyticsEventHandler, @@ -122,9 +122,9 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( userWallet is UserWallet.Cold && userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() when { - isMultiCurrency -> fetchCurrencyStatusUseCase.invoke( + isMultiCurrency -> cryptoCurrencyBalanceFetcher( userWalletId = userWallet.walletId, - id = cryptoCurrency.id, + currency = cryptoCurrency, ) !isMultiCurrency -> walletBalanceFetcher( params = WalletBalanceFetcher.Params( 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 d897836afb..a8626464c7 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 @@ -35,6 +35,7 @@ import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -110,7 +111,7 @@ import javax.inject.Inject internal class TokenDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, - private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, private val getExploreUrlUseCase: GetExploreUrlUseCase, private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, private val isCryptoCurrencyCoinCouldHideUseCase: IsCryptoCurrencyCoinCouldHideUseCase, @@ -838,7 +839,9 @@ internal class TokenDetailsModel @Inject constructor( modelScope.launch(dispatchers.main) { listOf( - async { fetchCurrencyStatusUseCase(userWalletId = userWalletId, id = cryptoCurrency.id) }, + async { + cryptoCurrencyBalanceFetcher.invokeAndAwait(userWalletId = userWalletId, currency = cryptoCurrency) + }, async { updateTxHistory() subscribeOnExpressTransactionsUpdates() From 473aeee67516c7a6d4effa69e4e6d607920ecd53 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 13 Mar 2026 12:51:12 +0300 Subject: [PATCH 37/60] Updated on 2026-08-14 --- .../tap/di/domain/QrScanningDomainModule.kt | 13 +- .../qrscanning/di/QrScanningDataModule.kt | 10 +- .../parser/Bip321PaymentUriParser.kt | 54 ++++ .../parser/Eip681PaymentUriParser.kt | 149 +++++++++ .../qrscanning/parser/PaymentUriParser.kt | 20 ++ .../parser/QrContentClassifierParser.kt | 52 +-- .../qrscanning/Bip321PaymentUriParserTest.kt | 269 +++++++++++++++ .../qrscanning/Eip681PaymentUriParserTest.kt | 305 ++++++++++++++++++ .../qrscanning/QrContentClassifierTest.kt | 191 ++++------- domain/qr-scanning/build.gradle.kts | 1 + .../qrscanning/models/ClassifiedQrContent.kt | 2 +- .../domain/qrscanning/models/QrSendTarget.kt | 44 +++ .../usecases/ClassifyQrCodeUseCase.kt | 13 - .../usecases/ResolveQrSendTargetsUseCase.kt | 153 +++++++++ domain/wallets/build.gradle.kts | 5 + .../features/send/v2/send/model/SendModel.kt | 66 ++-- .../destination/model/SendDestinationModel.kt | 2 +- .../wallet/child/wallet/model/WalletModel.kt | 53 +-- 18 files changed, 1176 insertions(+), 226 deletions(-) create mode 100644 data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt create mode 100644 data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt create mode 100644 data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt create mode 100644 data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt create mode 100644 data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt create mode 100644 domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt delete mode 100644 domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt create mode 100644 domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt index 25a32f91ed..45044583d2 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt @@ -1,10 +1,11 @@ package com.tangem.tap.di.domain +import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository -import com.tangem.domain.qrscanning.usecases.ClassifyQrCodeUseCase import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase +import com.tangem.domain.qrscanning.usecases.ResolveQrSendTargetsUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -35,7 +36,13 @@ internal object QrScanningDomainModule { @Provides @Singleton - fun provideClassifyQrCodeUseCase(repository: QrScanningEventsRepository): ClassifyQrCodeUseCase { - return ClassifyQrCodeUseCase(repository) + fun provideResolveQrSendTargetsUseCase( + multiAccountListSupplier: MultiAccountListSupplier, + qrScanningEventsRepository: QrScanningEventsRepository, + ): ResolveQrSendTargetsUseCase { + return ResolveQrSendTargetsUseCase( + multiAccountListSupplier = multiAccountListSupplier, + qrScanningEventsRepository = qrScanningEventsRepository, + ) } } \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt index 9fd1b269d8..62ff974e39 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt @@ -1,5 +1,7 @@ package com.tangem.data.qrscanning.di +import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser +import com.tangem.data.qrscanning.parser.Eip681PaymentUriParser import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository @@ -16,10 +18,14 @@ internal object QrScanningDataModule { @Provides @Singleton fun provideQrScanningEventsRepository(): QrScanningEventsRepository { + val blockchainDataProvider = QrContentClassifierParser.DefaultBlockchainDataProvider() return DefaultQrScanningEventsRepository( qrContentClassifierParser = QrContentClassifierParser( - QrContentClassifierParser.DefaultBlockchainDataProvider - (), + blockchainDataProvider = blockchainDataProvider, + paymentUriParsers = setOf( + Eip681PaymentUriParser(blockchainDataProvider), + Bip321PaymentUriParser(blockchainDataProvider), + ), ), ) } diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt new file mode 100644 index 0000000000..fc33495e08 --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt @@ -0,0 +1,54 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent + +internal class Bip321PaymentUriParser( + private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider, +) : PaymentUriParser { + + override fun parse( + qrCode: String, + coins: List, + allCurrencies: List, + ): PaymentUriParser.ParseResult { + val schemeAndRest = extractSchemeAndRest(qrCode, coins) + ?: return PaymentUriParser.ParseResult.NotRecognized + val (matchingCoins, withoutScheme) = schemeAndRest + + val parsed = QrSentUriParser().parse(withoutScheme) + ?: return PaymentUriParser.ParseResult.RecognizedButNoMatch + + val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() + val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds } + if (matchingCurrencies.isEmpty()) return PaymentUriParser.ParseResult.RecognizedButNoMatch + + return PaymentUriParser.ParseResult.Success( + ClassifiedQrContent.PaymentUri( + address = parsed.address, + amount = parsed.amount, + memo = parsed.memo, + matchingCurrencies = matchingCurrencies, + ), + ) + } + + private fun extractSchemeAndRest( + qrCode: String, + coins: List, + ): Pair, String>? { + for (coin in coins) { + val schemes = blockchainDataProvider.getShareSchemes(coin.network) + for (scheme in schemes) { + if (qrCode.startsWith(scheme, ignoreCase = true)) { + val withoutScheme = qrCode.removeRange(0, scheme.length) + val allMatchingCoins = coins.filter { c -> + blockchainDataProvider.getShareSchemes(c.network).any { it.equals(scheme, ignoreCase = true) } + } + return allMatchingCoins to withoutScheme + } + } + } + return null + } +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt new file mode 100644 index 0000000000..3bc83d2a7c --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt @@ -0,0 +1,149 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import java.math.BigDecimal +import java.math.MathContext + +internal class Eip681PaymentUriParser( + private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider, +) : PaymentUriParser { + + override fun parse( + qrCode: String, + coins: List, + allCurrencies: List, + ): PaymentUriParser.ParseResult { + if (!qrCode.startsWith(SCHEME)) return PaymentUriParser.ParseResult.NotRecognized + + val withoutScheme = qrCode.removePrefix(SCHEME) + val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized + + val matchingCoins = findMatchingCoins(parsed.chainId, coins) + if (matchingCoins.isEmpty()) return PaymentUriParser.ParseResult.RecognizedButNoMatch + + val result = if (parsed.functionName == FUNCTION_TRANSFER) { + resolveErc20Transfer(parsed, matchingCoins, allCurrencies) + } else { + resolveNativeTransfer(parsed, matchingCoins) + } + return if (result != null) { + PaymentUriParser.ParseResult.Success(result) + } else { + PaymentUriParser.ParseResult.RecognizedButNoMatch + } + } + + private fun resolveNativeTransfer( + parsed: Eip681Result, + matchingCoins: List, + ): ClassifiedQrContent.PaymentUri? { + val valueWei = parsed.params[PARAM_VALUE]?.toBigDecimalOrNull() + + if (matchingCoins.isEmpty()) return null + + val decimals = matchingCoins.first().decimals + val amount = valueWei?.fromSmallestUnit(decimals) + + return ClassifiedQrContent.PaymentUri( + address = parsed.targetAddress, + amount = amount, + memo = null, + matchingCurrencies = matchingCoins, + ) + } + + private fun resolveErc20Transfer( + parsed: Eip681Result, + matchingCoins: List, + allCurrencies: List, + ): ClassifiedQrContent.PaymentUri? { + val recipient = parsed.params[PARAM_ADDRESS] ?: return null + val contractAddress = parsed.targetAddress + + val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() + + val tokens = allCurrencies.filterIsInstance() + + val token = tokens + .firstOrNull { token -> + token.network.id in matchingNetworkIds && + token.contractAddress.equals(contractAddress, ignoreCase = true) + } ?: return null + + val rawAmount = parsed.params[PARAM_UINT256]?.toBigDecimalOrNull() + val amount = rawAmount?.fromSmallestUnit(token.decimals) + + return ClassifiedQrContent.PaymentUri( + address = recipient, + amount = amount, + memo = null, + matchingCurrencies = listOf(token), + ) + } + + private fun findMatchingCoins(chainId: Long?, coins: List): List { + if (chainId == null) { + return coins.filter { coin -> + blockchainDataProvider.getShareSchemes(coin.network).any { it.startsWith(SCHEME) } + } + } + return coins.filter { coin -> + blockchainDataProvider.getChainId(coin.network) == chainId + } + } + + private fun parseEip681(withoutScheme: String): Eip681Result? { + val match = URI_REGEX.matchEntire(withoutScheme) ?: return null + + val targetAddress = match.groupValues[GROUP_ADDRESS].ifBlank { return null } + val pathChainId = match.groupValues[GROUP_CHAIN_ID].toLongOrNull() + val functionName = match.groupValues[GROUP_FUNCTION].ifBlank { null } + val queryString = match.groupValues[GROUP_QUERY] + + val params = parseQueryParams(queryString) + val chainId = pathChainId ?: params[PARAM_CHAIN_ID]?.toLongOrNull() + + return Eip681Result( + targetAddress = targetAddress, + chainId = chainId, + functionName = functionName, + params = params, + ) + } + + private fun parseQueryParams(query: String): Map { + if (query.isBlank()) return emptyMap() + return query.split('&').mapNotNull { param -> + val parts = param.split('=', limit = 2) + if (parts.size == 2) parts[0] to parts[1] else null + }.toMap() + } + + private fun BigDecimal.fromSmallestUnit(decimals: Int): BigDecimal { + if (decimals == 0) return this + return this.divide(BigDecimal.TEN.pow(decimals), MathContext.DECIMAL128) + } + + private data class Eip681Result( + val targetAddress: String, + val chainId: Long?, + val functionName: String?, + val params: Map, + ) + + private companion object { + // ethereum:
[@][/][?] + val URI_REGEX = Regex("""^([^@/?]+)(?:@(\d+))?(?:/([^?]+))?(?:\?(.+))?$""") + const val SCHEME = "ethereum:" + const val FUNCTION_TRANSFER = "transfer" + const val PARAM_VALUE = "value" + const val PARAM_ADDRESS = "address" + const val PARAM_UINT256 = "uint256" + const val PARAM_CHAIN_ID = "chainId" + const val GROUP_ADDRESS = 1 + const val GROUP_CHAIN_ID = 2 + const val GROUP_FUNCTION = 3 + const val GROUP_QUERY = 4 + } +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt new file mode 100644 index 0000000000..8ade342f92 --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt @@ -0,0 +1,20 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent + +internal interface PaymentUriParser { + + fun parse(qrCode: String, coins: List, allCurrencies: List): ParseResult + + sealed class ParseResult { + /** URI format not recognized by this parser. */ + data object NotRecognized : ParseResult() + + /** URI format recognized but no matching currencies found. */ + data object RecognizedButNoMatch : ParseResult() + + /** Successfully parsed with matching currencies. */ + data class Success(val content: ClassifiedQrContent.PaymentUri) : ParseResult() + } +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt index 7a881c2a6f..161619037d 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt @@ -8,7 +8,7 @@ import java.net.URLDecoder internal class QrContentClassifierParser( private val blockchainDataProvider: BlockchainDataProvider, - private val paymentUriParser: QrSentUriParser = QrSentUriParser(), + private val paymentUriParsers: Set, ) { fun parse(qrCode: String, userCurrencies: List): ClassifiedQrContent { @@ -23,14 +23,19 @@ internal class QrContentClassifierParser( val coins = userCurrencies.filterIsInstance() val uniqueCoins = coins.distinctBy { it.network.id } - val paymentUri = tryParsePaymentUri(qrCode, uniqueCoins) - if (paymentUri != null) return paymentUri - - val matchingCurrencies = uniqueCoins.filter { coin -> - blockchainDataProvider.validateAddress(coin.network, qrCode) + when (val paymentUriResult = tryParsePaymentUri(qrCode, uniqueCoins, userCurrencies)) { + is PaymentUriParser.ParseResult.Success -> return paymentUriResult.content + is PaymentUriParser.ParseResult.RecognizedButNoMatch -> return ClassifiedQrContent.Unknown(qrCode) + is PaymentUriParser.ParseResult.NotRecognized -> Unit } - if (matchingCurrencies.isNotEmpty()) { + val matchingNetworkIds = uniqueCoins + .filter { coin -> blockchainDataProvider.validateAddress(coin.network, qrCode) } + .map { it.network.id } + .toSet() + + if (matchingNetworkIds.isNotEmpty()) { + val matchingCurrencies = userCurrencies.filter { it.network.id in matchingNetworkIds } return ClassifiedQrContent.PlainAddress( address = qrCode, matchingCurrencies = matchingCurrencies, @@ -40,29 +45,21 @@ internal class QrContentClassifierParser( return ClassifiedQrContent.Unknown(qrCode) } - private fun tryParsePaymentUri(qrCode: String, coins: List): ClassifiedQrContent.PaymentUri? { - return coins.firstNotNullOfOrNull { coin -> - val matchedScheme = blockchainDataProvider.getShareSchemes(coin.network) - .sortedByDescending { it.length } - .firstOrNull { qrCode.startsWith(it) } - ?: return@firstNotNullOfOrNull null - - val withoutScheme = qrCode.removePrefix(matchedScheme) - val parsed = paymentUriParser.parse(withoutScheme) ?: return@firstNotNullOfOrNull null - - ClassifiedQrContent.PaymentUri( - currency = coin, - address = parsed.address, - amount = parsed.amount, - memo = parsed.memo, - ) - } + private fun tryParsePaymentUri( + qrCode: String, + coins: List, + allCurrencies: List, + ): PaymentUriParser.ParseResult { + return paymentUriParsers.firstNotNullOfOrNull { parser -> + parser.parse(qrCode, coins, allCurrencies).takeUnless { it is PaymentUriParser.ParseResult.NotRecognized } + } ?: PaymentUriParser.ParseResult.NotRecognized } private fun isDAppWcUrl(qrCode: String): Boolean { if (!qrCode.startsWith(HTTP_PREFIX) && !qrCode.startsWith(HTTPS_PREFIX)) return false - val uriParam = paymentUriParser.extractParameters(qrCode)[PARAM_URI] ?: return false + val uriParser = QrSentUriParser() + val uriParam = uriParser.extractParameters(qrCode)[PARAM_URI] ?: return false val decodedUri = runCatching { URLDecoder.decode( uriParam, QrSentUriParser.CHARSET_UTF8, @@ -73,6 +70,7 @@ internal class QrContentClassifierParser( internal interface BlockchainDataProvider { fun getShareSchemes(network: Network): List fun validateAddress(network: Network, address: String): Boolean + fun getChainId(network: Network): Long? } internal class DefaultBlockchainDataProvider : BlockchainDataProvider { @@ -83,6 +81,10 @@ internal class QrContentClassifierParser( override fun validateAddress(network: Network, address: String): Boolean { return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false) } + + override fun getChainId(network: Network): Long? { + return runCatching { network.toBlockchain().getChainId()?.toLong() }.getOrNull() + } } private companion object { diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt new file mode 100644 index 0000000000..63a91fe88e --- /dev/null +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt @@ -0,0 +1,269 @@ +package com.tangem.data.qrscanning + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser +import com.tangem.data.qrscanning.parser.PaymentUriParser +import com.tangem.data.qrscanning.parser.QrContentClassifierParser +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import java.math.BigDecimal + +internal class Bip321PaymentUriParserTest { + + private val blockchainDataProvider = mockk { + every { getShareSchemes(any()) } returns emptyList() + } + private val parser = Bip321PaymentUriParser(blockchainDataProvider) + + // region Basic parsing + + @Test + fun `bitcoin URI with address and amount`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(result.amount!!.compareTo(BigDecimal("0.5"))).isEqualTo(0) + assertThat(result.memo).isNull() + assertThat(result.matchingCurrencies).containsExactly(bitcoinCoin) + } + + @Test + fun `bitcoin URI with address only`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(result.amount).isNull() + assertThat(result.memo).isNull() + } + + @Test + fun `bitcoin URI with amount and message`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.23&message=Donation", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(result.amount!!.compareTo(BigDecimal("1.23"))).isEqualTo(0) + assertThat(result.memo).isEqualTo("Donation") + } + + @Test + fun `bitcoin URI with label and message`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?label=Satoshi&message=Payment", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.memo).isEqualTo("Payment") + } + + // endregion + + // region Scheme matching + + @Test + fun `litecoin URI matches litecoin coin`() { + every { blockchainDataProvider.getShareSchemes(litecoinCoin.network) } returns listOf("litecoin:") + + val result = parser.parse( + qrCode = "litecoin:LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj?amount=10", + coins = listOf(litecoinCoin), + allCurrencies = listOf(litecoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj") + assertThat(result.amount!!.compareTo(BigDecimal("10"))).isEqualTo(0) + assertThat(result.matchingCurrencies).containsExactly(litecoinCoin) + } + + @Test + fun `no matching scheme returns NotRecognized`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "dogecoin:DAddress?amount=100", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `ethereum scheme matches as Success`() { + every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") + + val result = parser.parse( + qrCode = "ethereum:0xRecipient?value=1000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ).asSuccess() + + assertThat(result).isNotNull() + } + + @Test + fun `case insensitive scheme matching`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "Bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + } + + // endregion + + // region Includes tokens on matching network + + @Test + fun `includes tokens on matching network`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val btcToken = buildToken("bitcoin", "RUNE", "contractAddr") + + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin, btcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.matchingCurrencies).containsExactly(bitcoinCoin, btcToken) + } + + // endregion + + // region Edge cases + + @Test + fun `empty qr code returns NotRecognized`() { + val result = parser.parse( + qrCode = "", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `plain address returns NotRecognized`() { + val result = parser.parse( + qrCode = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `bitcoin URI with memo param`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&memo=TestMemo", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.memo).isEqualTo("TestMemo") + } + + // endregion + + // region Helpers + + private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { + return (this as? PaymentUriParser.ParseResult.Success)?.content + } + + private val bitcoinCoin = buildCoin("bitcoin", decimals = 8) + private val litecoinCoin = buildCoin("litecoin", decimals = 8) + private val ethereumCoin = buildCoin("ethereum", decimals = 18) + + private fun buildCoin(rawNetworkId: String, decimals: Int): CryptoCurrency.Coin { + return CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId), + ), + network = buildNetwork(rawNetworkId), + name = rawNetworkId, + symbol = rawNetworkId.take(3).uppercase(), + decimals = decimals, + iconUrl = null, + isCustom = false, + ) + } + + private fun buildToken(rawNetworkId: String, symbol: String, contractAddress: String): CryptoCurrency.Token { + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress), + ), + network = buildNetwork(rawNetworkId), + name = symbol, + symbol = symbol, + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = contractAddress, + ) + } + + private fun buildNetwork(rawNetworkId: String): Network { + return Network( + id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), + backendId = rawNetworkId, + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + // endregion +} \ No newline at end of file diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt new file mode 100644 index 0000000000..b270ef58c1 --- /dev/null +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt @@ -0,0 +1,305 @@ +package com.tangem.data.qrscanning + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.qrscanning.parser.Eip681PaymentUriParser +import com.tangem.data.qrscanning.parser.PaymentUriParser +import com.tangem.data.qrscanning.parser.QrContentClassifierParser +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import java.math.BigDecimal + +internal class Eip681PaymentUriParserTest { + + private val blockchainDataProvider = mockk { + every { getShareSchemes(any()) } returns emptyList() + every { getChainId(any()) } returns null + } + private val parser = Eip681PaymentUriParser(blockchainDataProvider) + + // region Native transfer + + @Test + fun `native transfer with chain_id and value`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val result = parser.parse( + qrCode = "ethereum:0xRecipient@1?value=1500000000000000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("0xRecipient") + assertThat(result.amount!!.compareTo(BigDecimal("1.5"))).isEqualTo(0) + assertThat(result.memo).isNull() + assertThat(result.matchingCurrencies).containsExactly(ethereumCoin) + } + + @Test + fun `native transfer without value`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val result = parser.parse( + qrCode = "ethereum:0xRecipient@1", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("0xRecipient") + assertThat(result.amount).isNull() + } + + @Test + fun `native transfer without chain_id falls back to scheme matching`() { + every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") + + val result = parser.parse( + qrCode = "ethereum:0xRecipient?value=1000000000000000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("0xRecipient") + assertThat(result.amount!!.compareTo(BigDecimal("1"))).isEqualTo(0) + } + + @Test + fun `native transfer includes only coins, not tokens`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + val result = parser.parse( + qrCode = "ethereum:0xRecipient@1?value=1000000000000000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin, usdcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.matchingCurrencies).containsExactly(ethereumCoin) + } + + // endregion + + // region ERC-20 transfer + + @Test + fun `ERC-20 transfer with contract, recipient and amount`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + val result = parser.parse( + qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xRecipient&uint256=1000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin, usdcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("0xRecipient") + assertThat(result.amount!!.compareTo(BigDecimal("1"))).isEqualTo(0) + assertThat(result.matchingCurrencies).containsExactly(usdcToken) + } + + @Test + fun `ERC-20 transfer without amount`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + val result = parser.parse( + qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xRecipient", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin, usdcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("0xRecipient") + assertThat(result.amount).isNull() + assertThat(result.matchingCurrencies).containsExactly(usdcToken) + } + + @Test + fun `ERC-20 transfer with unknown token returns RecognizedButNoMatch`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val result = parser.parse( + qrCode = "ethereum:0xUnknownContract@1/transfer?address=0xRecipient&uint256=1000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedButNoMatch::class.java) + } + + @Test + fun `ERC-20 transfer without address param returns RecognizedButNoMatch`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + val result = parser.parse( + qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?uint256=1000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin, usdcToken), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedButNoMatch::class.java) + } + + // endregion + + @Test + fun `ERC-20 transfer with chainId as query param`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val usdtToken = buildToken("ethereum", "USDT", "0xdAC17F958D2ee523a2206206994597C13D831ec7") + + val result = parser.parse( + qrCode = "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7/transfer?address=0x3D709aC89d780312677519c3AfC13f390C819531&uint256=30000000&chainId=1", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin, usdtToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("0x3D709aC89d780312677519c3AfC13f390C819531") + assertThat(result.amount!!.compareTo(BigDecimal("30"))).isEqualTo(0) + assertThat(result.matchingCurrencies).containsExactly(usdtToken) + } + + // region Chain ID matching + + @Test + fun `chain_id mismatch returns RecognizedButNoMatch`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val result = parser.parse( + qrCode = "ethereum:0xRecipient@137?value=1000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedButNoMatch::class.java) + } + + @Test + fun `chain_id matches correct network among multiple`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + every { blockchainDataProvider.getChainId(polygonCoin.network) } returns 137L + + val result = parser.parse( + qrCode = "ethereum:0xRecipient@137?value=1000000000000000000", + coins = listOf(ethereumCoin, polygonCoin), + allCurrencies = listOf(ethereumCoin, polygonCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.matchingCurrencies).containsExactly(polygonCoin) + } + + // endregion + + // region Non-ethereum schemes + + @Test + fun `non-ethereum scheme returns NotRecognized`() { + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `empty qr code returns NotRecognized`() { + val result = parser.parse( + qrCode = "", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `ethereum scheme with blank address returns NotRecognized`() { + val result = parser.parse( + qrCode = "ethereum:?value=1000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + // endregion + + // region Helpers + + private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { + return (this as? PaymentUriParser.ParseResult.Success)?.content + } + + private val bitcoinCoin = buildCoin("bitcoin", decimals = 8) + private val ethereumCoin = buildCoin("ethereum", decimals = 18) + private val polygonCoin = buildCoin("polygon", decimals = 18) + + private fun buildCoin(rawNetworkId: String, decimals: Int): CryptoCurrency.Coin { + return CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId), + ), + network = buildNetwork(rawNetworkId), + name = rawNetworkId, + symbol = rawNetworkId.take(3).uppercase(), + decimals = decimals, + iconUrl = null, + isCustom = false, + ) + } + + private fun buildToken(rawNetworkId: String, symbol: String, contractAddress: String): CryptoCurrency.Token { + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress), + ), + network = buildNetwork(rawNetworkId), + name = symbol, + symbol = symbol, + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = contractAddress, + ) + } + + private fun buildNetwork(rawNetworkId: String): Network { + return Network( + id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), + backendId = rawNetworkId, + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + // endregion +} \ No newline at end of file diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt index b4dc4c9976..c8b289207f 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.qrscanning import com.google.common.truth.Truth.assertThat +import com.tangem.data.qrscanning.parser.PaymentUriParser import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -15,8 +16,15 @@ internal class QrContentClassifierTest { private val blockchainDataProvider = mockk { every { getShareSchemes(any()) } returns emptyList() every { validateAddress(any(), any()) } returns false + every { getChainId(any()) } returns null } - private val classifier = QrContentClassifierParser(blockchainDataProvider) + private val paymentUriParser = mockk { + every { parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.NotRecognized + } + private val classifier = QrContentClassifierParser( + blockchainDataProvider = blockchainDataProvider, + paymentUriParsers = setOf(paymentUriParser), + ) // region WalletConnect @@ -69,91 +77,37 @@ internal class QrContentClassifierTest { // endregion - // region PaymentUri + // region PaymentUri delegation @Test - fun `Bitcoin BIP-021 URI with amount is parsed`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + fun `PaymentUri is returned when parser matches`() { + val expectedUri = ClassifiedQrContent.PaymentUri( + address = "0xRecipient", + amount = BigDecimal("1.5"), + memo = null, + matchingCurrencies = listOf(ethereumCoin), + ) + every { paymentUriParser.parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.Success(expectedUri) - val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5" - val result = classifier.parse(qr, listOf(bitcoinCoin, ethereumCoin)) + val result = classifier.parse("ethereum:0xRecipient@1?value=1500000000000000000", listOf(ethereumCoin)) + + assertThat(result).isEqualTo(expectedUri) + } + + @Test + fun `PaymentUri takes priority over plain address match`() { + val expectedUri = ClassifiedQrContent.PaymentUri( + address = "0xRecipient", + amount = null, + memo = null, + matchingCurrencies = listOf(ethereumCoin), + ) + every { paymentUriParser.parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.Success(expectedUri) + every { blockchainDataProvider.validateAddress(ethereumCoin.network, any()) } returns true + + val result = classifier.parse("ethereum:0xRecipient", listOf(ethereumCoin)) assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) - val paymentUri = result as ClassifiedQrContent.PaymentUri - assertThat(paymentUri.currency).isEqualTo(bitcoinCoin) - assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") - assertThat(paymentUri.amount).isEqualTo(BigDecimal("0.5")) - assertThat(paymentUri.memo).isNull() - } - - @Test - fun `Bitcoin URI without params returns address only`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - - val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" - val result = classifier.parse(qr, listOf(bitcoinCoin)) - - assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) - val paymentUri = result as ClassifiedQrContent.PaymentUri - assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") - assertThat(paymentUri.amount).isNull() - assertThat(paymentUri.memo).isNull() - } - - @Test - fun `Bitcoin URI with message param is parsed as memo`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - - val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.0&message=test%20memo" - val result = classifier.parse(qr, listOf(bitcoinCoin)) - - val paymentUri = result as ClassifiedQrContent.PaymentUri - assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.0")) - assertThat(paymentUri.memo).isEqualTo("test memo") - } - - @Test - fun `URI with memo parameter is parsed`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - - val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?memo=hello" - val result = classifier.parse(qr, listOf(bitcoinCoin)) - - val paymentUri = result as ClassifiedQrContent.PaymentUri - assertThat(paymentUri.memo).isEqualTo("hello") - } - - @Test - fun `Ethereum ERC-681 URI with chain_id and function is parsed`() { - every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") - - val qr = "ethereum:0x1234567890abcdef1234567890abcdef12345678@1/transfer?amount=1.5" - val result = classifier.parse(qr, listOf(ethereumCoin)) - - assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) - val paymentUri = result as ClassifiedQrContent.PaymentUri - assertThat(paymentUri.address).isEqualTo("0x1234567890abcdef1234567890abcdef12345678") - assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.5")) - } - - @Test - fun `URI scheme not matching user currencies falls through`() { - val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" - val result = classifier.parse(qr, listOf(ethereumCoin)) - - assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) - } - - @Test - fun `Longest matching scheme is preferred`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns - listOf("bitcoin:", "bitcoin://") - - val qr = "bitcoin://1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" - val result = classifier.parse(qr, listOf(bitcoinCoin)) - - val paymentUri = result as ClassifiedQrContent.PaymentUri - assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") } // endregion @@ -186,6 +140,20 @@ internal class QrContentClassifierTest { assertThat(plain.matchingCurrencies).hasSize(2) } + @Test + fun `PlainAddress includes tokens on matching networks`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + + val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + val result = classifier.parse(address, listOf(ethereumCoin, usdcToken)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin, usdcToken) + } + // endregion // region Unknown @@ -212,55 +180,15 @@ internal class QrContentClassifierTest { assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) } - // endregion - - // region Edge cases - @Test - fun `Tokens are filtered out, only Coins are used`() { - val token = CryptoCurrency.Token( - id = CryptoCurrency.ID( - prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, - body = CryptoCurrency.ID.Body.NetworkId("ethereum"), - suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), - ), - network = buildNetwork("ethereum"), - name = "USDT", - symbol = "USDT", - decimals = 6, - iconUrl = null, - isCustom = false, - contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", - ) + fun `Tokens alone without Coins cannot match addresses`() { + val token = buildToken("ethereum", "USDT", "0xdAC17F958D2ee523a2206206994597C13D831ec7") val result = classifier.parse("0x1234", listOf(token)) assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) } - @Test - fun `Duplicate coins with same network are deduplicated`() { - val address = "0x1234567890abcdef1234567890abcdef12345678" - every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true - - val result = classifier.parse(address, listOf(ethereumCoin, ethereumCoin)) - - assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) - val plain = result as ClassifiedQrContent.PlainAddress - assertThat(plain.matchingCurrencies).hasSize(1) - } - - @Test - fun `Payment URI takes priority over plain address match`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - every { blockchainDataProvider.validateAddress(bitcoinCoin.network, any()) } returns true - - val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1" - val result = classifier.parse(qr, listOf(bitcoinCoin)) - - assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) - } - // endregion // region Helpers @@ -285,6 +213,23 @@ internal class QrContentClassifierTest { ) } + private fun buildToken(rawNetworkId: String, symbol: String, contractAddress: String): CryptoCurrency.Token { + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress), + ), + network = buildNetwork(rawNetworkId), + name = symbol, + symbol = symbol, + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = contractAddress, + ) + } + private fun buildNetwork(rawNetworkId: String): Network { return Network( id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), diff --git a/domain/qr-scanning/build.gradle.kts b/domain/qr-scanning/build.gradle.kts index e50263fe06..564ec92b53 100644 --- a/domain/qr-scanning/build.gradle.kts +++ b/domain/qr-scanning/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { /** Domain */ api(projects.domain.models) + implementation(projects.domain.account) implementation(projects.domain.qrScanning.models) implementation(projects.domain.tokens.models) diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt index 4c482b56a5..dab8a60259 100644 --- a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt @@ -8,10 +8,10 @@ sealed class ClassifiedQrContent { data class WalletConnect(val uri: String) : ClassifiedQrContent() data class PaymentUri( - val currency: CryptoCurrency, val address: String, val amount: BigDecimal?, val memo: String?, + val matchingCurrencies: List, ) : ClassifiedQrContent() data class PlainAddress( diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt new file mode 100644 index 0000000000..d802a5f2ec --- /dev/null +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt @@ -0,0 +1,44 @@ +package com.tangem.domain.qrscanning.models + +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import java.math.BigDecimal + +sealed class QrSendTarget { + + /** Single match — navigate directly to Send */ + data class Single( + val userWalletId: UserWalletId, + val currency: CryptoCurrency, + val address: String, + val amount: BigDecimal?, + val memo: String?, + ) : QrSendTarget() + + /** Multiple matches — data for bottom sheet selection */ + data class Multiple( + val address: String, + val amount: BigDecimal?, + val memo: String?, + val walletGroups: List, + ) : QrSendTarget() { + + data class WalletGroup( + val userWalletId: UserWalletId, + val walletName: String, + val accounts: List, + ) + + data class AccountGroup( + val accountId: AccountId, + val accountName: AccountName, + val currencies: List, + ) + } + + data class WalletConnect(val uri: String) : QrSendTarget() + + data class Unknown(val raw: String) : QrSendTarget() +} \ No newline at end of file diff --git a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt deleted file mode 100644 index 16180975e0..0000000000 --- a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.domain.qrscanning.usecases - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.qrscanning.models.ClassifiedQrContent -import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository - -class ClassifyQrCodeUseCase( - private val repository: QrScanningEventsRepository, -) { - operator fun invoke(qrCode: String, userCurrencies: List): ClassifiedQrContent { - return repository.classify(qrCode, userCurrencies) - } -} \ No newline at end of file diff --git a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt new file mode 100644 index 0000000000..2d436c4ceb --- /dev/null +++ b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt @@ -0,0 +1,153 @@ +package com.tangem.domain.qrscanning.usecases + +import com.tangem.domain.account.supplier.MultiAccountListSupplier +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.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository +import java.math.BigDecimal +import com.tangem.domain.qrscanning.models.QrSendTarget + +class ResolveQrSendTargetsUseCase( + private val multiAccountListSupplier: MultiAccountListSupplier, + private val qrScanningEventsRepository: QrScanningEventsRepository, +) { + + suspend operator fun invoke(qrCode: String): QrSendTarget { + val allAccountLists = multiAccountListSupplier.getSyncOrNull(Unit).orEmpty() + + val currencyEntries = allAccountLists.flatMap { accountList -> + accountList.accounts + .filterIsInstance() + .flatMap { account -> + account.cryptoCurrencies.map { currency -> + currency to CurrencyLocation( + userWalletId = accountList.userWalletId, + walletName = accountList.userWalletId.stringValue, + accountId = account.accountId, + accountName = account.accountName, + ) + } + } + } + + val allCurrencies = currencyEntries.map { it.first } + val currencyLocations = currencyEntries.groupBy( + keySelector = { it.first.id }, + valueTransform = { it.second }, + ) + + val classified = qrScanningEventsRepository.classify(qrCode, allCurrencies) + + return resolve(classified, currencyLocations) + } + + private fun resolve( + classified: ClassifiedQrContent, + currencyLocations: Map>, + ): QrSendTarget { + return when (classified) { + is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri) + is ClassifiedQrContent.Unknown -> QrSendTarget.Unknown(classified.raw) + is ClassifiedQrContent.PlainAddress -> resolveAddressTarget( + address = classified.address, + amount = null, + memo = null, + matchingCurrencies = classified.matchingCurrencies, + currencyLocations = currencyLocations, + ) + is ClassifiedQrContent.PaymentUri -> resolveAddressTarget( + address = classified.address, + amount = classified.amount, + memo = classified.memo, + matchingCurrencies = classified.matchingCurrencies, + currencyLocations = currencyLocations, + ) + } + } + + private fun resolveAddressTarget( + address: String, + amount: BigDecimal?, + memo: String?, + matchingCurrencies: List, + currencyLocations: Map>, + ): QrSendTarget { + val walletGroups = buildWalletGroups(matchingCurrencies, currencyLocations) + + val singleGroup = walletGroups.singleOrNull() + val singleCurrency = singleGroup?.accounts?.singleOrNull()?.currencies?.singleOrNull() + + return if (singleGroup != null && singleCurrency != null) { + QrSendTarget.Single( + userWalletId = singleGroup.userWalletId, + currency = singleCurrency, + address = address, + amount = amount, + memo = memo, + ) + } else { + QrSendTarget.Multiple( + address = address, + amount = amount, + memo = memo, + walletGroups = walletGroups, + ) + } + } + + private fun buildWalletGroups( + matchingCurrencies: List, + currencyLocations: Map>, + ): List { + val walletMap = linkedMapOf() + val uniqueCurrencies = matchingCurrencies.distinctBy { it.id } + + for (currency in uniqueCurrencies) { + val locations = currencyLocations[currency.id] ?: continue + for (location in locations) { + val walletInfo = walletMap.getOrPut(location.userWalletId) { + WalletInfo(location.walletName, linkedMapOf()) + } + val accountInfo = walletInfo.accounts.getOrPut(location.accountId) { + AccountInfo(location.accountName, mutableListOf()) + } + accountInfo.currencies.add(currency) + } + } + + return walletMap.map { (walletId, walletInfo) -> + QrSendTarget.Multiple.WalletGroup( + userWalletId = walletId, + walletName = walletInfo.walletName, + accounts = walletInfo.accounts.map { (accountId, accountInfo) -> + QrSendTarget.Multiple.AccountGroup( + accountId = accountId, + accountName = accountInfo.accountName, + currencies = accountInfo.currencies, + ) + }, + ) + } + } + + private data class CurrencyLocation( + val userWalletId: UserWalletId, + val walletName: String, + val accountId: AccountId, + val accountName: AccountName, + ) + + private class WalletInfo( + val walletName: String, + val accounts: LinkedHashMap, + ) + + private class AccountInfo( + val accountName: AccountName, + val currencies: MutableList, + ) +} \ No newline at end of file diff --git a/domain/wallets/build.gradle.kts b/domain/wallets/build.gradle.kts index f11102091a..47d5fd6e1c 100644 --- a/domain/wallets/build.gradle.kts +++ b/domain/wallets/build.gradle.kts @@ -34,6 +34,11 @@ dependencies { implementation(projects.domain.notifications.models) implementation(projects.domain.demo.models) implementation(projects.domain.hotWallet) + implementation(projects.domain.qrScanning) + // endregion + + // region Domain modules + implementation(projects.domain.qrScanning.models) // endregion implementation(projects.common) 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 5174229910..db07b4492e 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 @@ -267,29 +267,45 @@ internal class SendModel @Inject constructor( private suspend fun prepareTransferTransaction(): Either { val predefinedValues = predefinedValues val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value - return if (predefinedValues is PredefinedValues.Content.Deeplink) { - val predefinedAmount = predefinedValues.amount.parseBigDecimalOrNull() - createTransferTransactionUseCase( - amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus) ?: error("Invalid amount"), - memo = predefinedValues.memo, - destination = predefinedValues.address, - userWalletId = userWallet.walletId, - network = cryptoCurrency.network, - ) - } else { - val destinationUM = uiState.value.destinationUM as? DestinationUM.Content ?: error("Invalid destination") - val amountUM = uiState.value.amountUM as? AmountState.Data ?: error("Invalid amount") - val enteredDestinationAddress = destinationUM.addressTextField.actualAddress - val enteredMemo = destinationUM.memoTextField?.value - val enteredAmount = amountUM.amountTextField.cryptoAmount.value ?: error("Invalid amount") + return when (predefinedValues) { + is PredefinedValues.Content.Deeplink -> { + val predefinedAmount = predefinedValues.amount.parseBigDecimalOrNull() + createTransferTransactionUseCase( + amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus) + ?: error("Invalid amount"), + memo = predefinedValues.memo, + destination = predefinedValues.address, + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ) + } + is PredefinedValues.Content.QrCode -> { + val predefinedAmount = predefinedValues.amount?.parseBigDecimalOrNull() + createTransferTransactionUseCase( + amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus) + ?: error("Invalid amount"), + memo = predefinedValues.memo, + destination = predefinedValues.address, + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ) + } + PredefinedValues.Empty -> { + val destinationUM = uiState.value.destinationUM as? DestinationUM.Content + ?: error("Invalid destination") + val amountUM = uiState.value.amountUM as? AmountState.Data ?: error("Invalid amount") + val enteredDestinationAddress = destinationUM.addressTextField.actualAddress + val enteredMemo = destinationUM.memoTextField?.value + val enteredAmount = amountUM.amountTextField.cryptoAmount.value ?: error("Invalid amount") - createTransferTransactionUseCase( - amount = enteredAmount.convertToSdkAmount(cryptoCurrencyStatus), - memo = enteredMemo, - destination = enteredDestinationAddress, - userWalletId = userWallet.walletId, - network = cryptoCurrency.network, - ) + createTransferTransactionUseCase( + amount = enteredAmount.convertToSdkAmount(cryptoCurrencyStatus), + memo = enteredMemo, + destination = enteredDestinationAddress, + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ) + } } } @@ -348,6 +364,12 @@ internal class SendModel @Inject constructor( memo = params.tag, transactionId = predefinedTxId, ) + } else if (predefinedAddress != null) { + PredefinedValues.Content.QrCode( + amount = predefinedAmount, + address = predefinedAddress, + memo = params.tag, + ) } else { PredefinedValues.Empty } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index e2c9a282c5..73df61850a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -96,7 +96,7 @@ internal class SendDestinationModel @Inject constructor( ), ) val params = params as? DestinationBlockParams - val predefinedValues = params?.predefinedValues as? PredefinedValues.Content.Deeplink + val predefinedValues = params?.predefinedValues as? PredefinedValues.Content if (predefinedValues?.address != null) { _uiState.update( SendDestinationPredefinedStateTransformer( 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 fcc3750444..dd76e89962 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 @@ -5,6 +5,7 @@ import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.analytics.utils.TrackingContextProxy @@ -25,13 +26,12 @@ import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.qrscanning.models.QrResultSource import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.qrscanning.models.QrSendTarget import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.model.WcPairRequest import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase -import com.tangem.domain.qrscanning.usecases.ClassifyQrCodeUseCase +import com.tangem.domain.qrscanning.usecases.ResolveQrSendTargetsUseCase import com.tangem.domain.settings.* import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase import com.tangem.domain.wallets.usecase.* @@ -40,7 +40,6 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.router.InnerWalletRouter import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent -import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.feature.wallet.presentation.wallet.analytics.utils.SelectedWalletAnalyticsSender import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory import com.tangem.feature.wallet.presentation.wallet.domain.WalletContentFetcher @@ -115,8 +114,7 @@ internal class WalletModel @Inject constructor( private val walletFeatureToggles: WalletFeatureToggles, private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val wcPairService: WcPairService, - private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val classifyQrCodeUseCase: ClassifyQrCodeUseCase, + private val resolveQrSendTargetsUseCase: ResolveQrSendTargetsUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -752,16 +750,8 @@ internal class WalletModel @Inject constructor( } private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) { - val userWalletId = stateHolder.getSelectedWalletId() - - val currencies = multiWalletCryptoCurrenciesSupplier - .getSyncOrNull(MultiWalletCryptoCurrenciesProducer.Params(userWalletId)) - ?.toList() - .orEmpty() - val classified = classifyQrCodeUseCase(qrCode, currencies) - - when (classified) { - is ClassifiedQrContent.WalletConnect -> { + when (val target = resolveQrSendTargetsUseCase(qrCode)) { + is QrSendTarget.WalletConnect -> { val source = when (resultSource) { QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD QrResultSource.CAMERA, @@ -770,34 +760,25 @@ internal class WalletModel @Inject constructor( } wcPairService.pair( WcPairRequest( - userWalletId = userWalletId, - uri = classified.uri, + userWalletId = stateHolder.getSelectedWalletId(), + uri = target.uri, source = source, ), ) } - is ClassifiedQrContent.PaymentUri -> { + is QrSendTarget.Single -> { innerWalletRouter.openSend( - userWalletId = userWalletId, - currency = classified.currency, - address = classified.address, - amount = classified.amount?.toPlainString(), - tag = classified.memo, + userWalletId = target.userWalletId, + currency = target.currency, + address = target.address, + amount = target.amount?.parseBigDecimal(target.currency.decimals), + tag = target.memo, ) } - is ClassifiedQrContent.PlainAddress -> { - if (classified.matchingCurrencies.size == 1) { - innerWalletRouter.openSend( - userWalletId = userWalletId, - currency = classified.matchingCurrencies.first(), - address = classified.address, - amount = null, - tag = null, - ) - } - // TODO: [REDACTED_TASK_KEY] Network selection bottom sheet for multiple network matches + is QrSendTarget.Multiple -> { + // TODO: [REDACTED_TASK_KEY] Bottom sheet: Wallets (dropdown) → Accounts → Tokens } - is ClassifiedQrContent.Unknown -> { + is QrSendTarget.Unknown -> { // TODO: [REDACTED_TASK_KEY] Error handling for unsupported and invalid QR codes } } From fc329a75d9872c936ee1d92f4c1e5d95870b4898 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 13 Mar 2026 10:54:00 +0100 Subject: [PATCH 38/60] Updated on 2026-08-14 --- .../components/TokenMarketInformationBlock.kt | 90 +++++++++++++++++ .../detailed/components/ListedOnBlock.kt | 98 ++++++++++++++++--- .../ui/market/detailed/state/ListedOnUM.kt | 4 + 3 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/TokenMarketInformationBlock.kt diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/TokenMarketInformationBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/TokenMarketInformationBlock.kt new file mode 100644 index 0000000000..ffe3b5771e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/TokenMarketInformationBlock.kt @@ -0,0 +1,90 @@ +package com.tangem.features.feed.ui.components + +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.Shape +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.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TokenMarketInformationBlock( + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(TangemTheme.dimens2.x6), + contentPadding: Dp = TangemTheme.dimens2.x4, + title: (@Composable () -> Unit), + content: (@Composable BoxScope.() -> Unit)? = null, +) { + Column( + modifier = modifier + .clip(shape) + .background(color = TangemTheme.colors2.surface.level3) + .padding(contentPadding), + horizontalAlignment = Alignment.Start, + ) { + title() + + if (content != null) { + Box(modifier = Modifier.fillMaxWidth()) { + content(this) + } + } + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenMarketInformationBlockPreview() { + TangemThemePreviewRedesign { + TokenMarketInformationBlock( + title = { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Listed on", + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + ) + + SpacerWMax() + + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_24), + tint = TangemTheme.colors2.markers.iconGray, + contentDescription = null, + ) + } + }, + content = { + Box( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level2) + .padding(12.dp), + ) { + Text( + text = "Smth", + color = TangemTheme.colors2.text.neutral.primary, + ) + } + }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt index 7836a63094..3305077d9f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt @@ -2,34 +2,33 @@ package com.tangem.features.feed.ui.market.detailed.components import android.content.res.Configuration import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -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.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag 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 androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.unit.dp import com.tangem.common.ui.R +import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.block.information.InformationBlock import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.MarketsTestTags +import com.tangem.features.feed.ui.components.TokenMarketInformationBlock import com.tangem.features.feed.ui.market.detailed.state.ListedOnUM import kotlinx.coroutines.delay @@ -42,6 +41,15 @@ import kotlinx.coroutines.delay */ @Composable internal fun ListedOnBlock(state: ListedOnUM, modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + ListedOnBlockV2(state, modifier) + } else { + ListedOnBlockV1(state, modifier) + } +} + +@Composable +private fun ListedOnBlockV1(state: ListedOnUM, modifier: Modifier = Modifier) { Box(modifier = modifier) { InformationBlock( title = { @@ -78,8 +86,55 @@ internal fun ListedOnBlock(state: ListedOnUM, modifier: Modifier = Modifier) { } } +@Composable +private fun ListedOnBlockV2(state: ListedOnUM, modifier: Modifier = Modifier) { + TokenMarketInformationBlock( + modifier = modifier.clickable(enabled = state is ListedOnUM.Content) { + (state as? ListedOnUM.Content)?.onClick?.invoke() + }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = state.description.resolveReference(), + style = TangemTheme.typography2.captionSemibold13, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + SpacerWMax() + + Icon( + modifier = Modifier.size(20.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_small_right_24), + tint = TangemTheme.colors2.markers.iconGray, + contentDescription = null, + ) + } + }, + ) +} + @Composable internal fun ListedOnBlockPlaceholder(modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + ListedOnBlockPlaceholderV2(modifier) + } else { + ListedOnBlockPlaceholderV1(modifier) + } +} + +@Composable +internal fun ListedOnBlockPlaceholderV1(modifier: Modifier = Modifier) { InformationBlock( title = { TextShimmer( @@ -98,6 +153,27 @@ internal fun ListedOnBlockPlaceholder(modifier: Modifier = Modifier) { } } +@Composable +internal fun ListedOnBlockPlaceholderV2(modifier: Modifier = Modifier) { + TokenMarketInformationBlock( + modifier = modifier, + title = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + TextShimmer( + style = TangemTheme.typography2.headingSemibold20, + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + radius = TangemTheme.dimens2.x25, + ) + TextShimmer( + style = TangemTheme.typography2.captionSemibold13, + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + radius = TangemTheme.dimens2.x25, + ) + } + }, + ) +} + @Composable private fun Description(state: ListedOnUM, modifier: Modifier = Modifier) { Text( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/ListedOnUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/ListedOnUM.kt index 4f4876f07b..0b1dc2b728 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/ListedOnUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/ListedOnUM.kt @@ -1,5 +1,6 @@ package com.tangem.features.feed.ui.market.detailed.state +import androidx.compose.runtime.Immutable import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.resourceReference @@ -11,6 +12,7 @@ import com.tangem.features.feed.impl.R * [REDACTED_AUTHOR] */ +@Immutable internal sealed interface ListedOnUM { /** Title */ @@ -20,6 +22,7 @@ internal sealed interface ListedOnUM { /** Description */ val description: TextReference + @Immutable /** Empty state. No exchanges found */ data object Empty : ListedOnUM { override val description = resourceReference(id = R.string.markets_token_details_empty_exchanges) @@ -31,6 +34,7 @@ internal sealed interface ListedOnUM { * @property onClick lambda be invoked when button is clicked * @property amount amount of exchanges */ + @Immutable data class Content( val onClick: () -> Unit, private val amount: Int, From 8d43a9eed3deba266d724aac6b4dd246c7d67c2a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 13 Mar 2026 16:33:00 +0500 Subject: [PATCH 39/60] Updated on 2026-08-14 --- .../com/tangem/scenarios/MarketsScenarios.kt | 7 +++++- .../balance/TotalBalanceUnavailableTest.kt | 5 ++-- .../tests/markets/MarketsExchangesTest.kt | 23 ++++++------------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt index b4749cd5d3..9c3b33b013 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt @@ -60,14 +60,19 @@ fun BaseTestCase.openMarketsScreen() { } } -fun BaseTestCase.openMarketsExchangesScreen(tokenName: String) { +fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAllButton: Boolean = false) { openMarketsScreen() + if (shouldClickSeeAllButton) + step("Click on 'See all' button") { + onMarketsScreen { seeAllButton.clickWithAssertion() } + } step("Click on '$tokenName' token") { onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() } waitForIdle() } step("Scroll down") { swipeVertical(SwipeDirection.UP) + swipeVertical(SwipeDirection.UP) } step("Click on 'Listed on exchanges' block") { onMarketsScreen { listedOnBlockContainer.performClick() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt index f02b33f8c2..864629a687 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt @@ -113,6 +113,7 @@ class TotalBalanceUnavailableTest : BaseTestCase() { val scenarioName = "user_tokens_api" val scenarioState = "CustomTokenAdded" val tokenTitle = "Myria" + val balance = "$3,299.18" setupHooks( additionalAfterSection = { resetWireMockScenarioState(scenarioName) @@ -134,8 +135,8 @@ class TotalBalanceUnavailableTest : BaseTestCase() { } } } - step("Assert dash sign is displayed in total balance") { - onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } + step("Assert correct balance is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(balance) } } step("Assert $tokenTitle is unreachable") { onMainScreen { tokenWithTitleAndPosition(tokenTitle, 4).assertIsUnreachable() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt index 86d9afdcee..fd470cb89d 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt @@ -8,7 +8,10 @@ import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiEnvironment -import com.tangem.scenarios.* +import com.tangem.scenarios.assertMarketsExchangesScreen +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.openMarketsExchangesScreen +import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.onMarketsExchangesScreen import com.tangem.screens.onMarketsScreen import dagger.hilt.android.testing.HiltAndroidTest @@ -26,21 +29,8 @@ class MarketsExchangesTest : BaseTestCase() { fun marketsExchangesListTest() { val tokenName = "Solana" setupHooks().run { - step("Open 'Markets' screen") { - openMarketsScreen() - } - step("Click on 'See all' button") { - onMarketsScreen { seeAllButton.clickWithAssertion() } - } - step("Click on '$tokenName' token") { - onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() } - waitForIdle() - } - step("Scroll down") { - swipeVertical(SwipeDirection.UP) - } - step("Click on 'Listed on exchanges' block") { - onMarketsScreen { listedOnBlockContainer.performClick() } + step("Open 'Markets Exhanges Screen with token: $tokenName'") { + openMarketsExchangesScreen(tokenName = tokenName, shouldClickSeeAllButton = true) } step("Assert 'Exchanges' list screen is displayed") { assertMarketsExchangesScreen() @@ -71,6 +61,7 @@ class MarketsExchangesTest : BaseTestCase() { } step("Scroll down") { swipeVertical(SwipeDirection.UP) + swipeVertical(SwipeDirection.UP) } step("Assert 'Listed on exchanges' block has title") { onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() } From 16a83cf56d88d536be5fd582aae42eb441c8e4c7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 13 Mar 2026 12:13:11 +0400 Subject: [PATCH 40/60] Updated on 2026-08-14 --- .../data/account/producer/WalletAccountListFlowFactory.kt | 2 +- .../data/account/producer/WalletAccountListFlowFactoryTest.kt | 2 +- .../data/common/currency/DefaultCardCryptoCurrencyFactory.kt | 1 + .../main/kotlin/com/tangem/data/common/di/DataCommonModule.kt | 1 + .../main/java/com/tangem/data/networks/di/NetworkDataModule.kt | 2 +- .../data/networks/multi/DefaultMultiNetworkStatusFetcher.kt | 2 +- .../data/networks/repository/DefaultNetworksRepository.kt | 2 +- .../data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt | 2 +- .../data/networks/repository/DefaultNetworksRepositoryTest.kt | 2 +- data/tokens/build.gradle.kts | 1 + .../main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt | 2 +- .../data/tokens/repository/DefaultCurrenciesRepository.kt | 2 +- .../tangem/domain/common/tokens}/CardCryptoCurrencyFactory.kt | 2 +- 13 files changed, 13 insertions(+), 10 deletions(-) rename {data/common/src/main/kotlin/com/tangem/data/common/currency => domain/common/src/main/java/com/tangem/domain/common/tokens}/CardCryptoCurrencyFactory.kt (98%) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt index fbc60cc6c6..49d1b90eec 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt @@ -3,7 +3,7 @@ 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.common.tokens.CardCryptoCurrencyFactory import com.tangem.domain.account.models.AccountList import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.common.wallets.UserWalletsListRepository diff --git a/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt index 335516f9fd..0605ae7761 100644 --- a/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt @@ -7,7 +7,7 @@ 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.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.domain.account.models.AccountList import com.tangem.domain.common.wallets.UserWalletsListRepository diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt index 8dfe637ba5..f56f43a761 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt @@ -1,6 +1,7 @@ package com.tangem.data.common.currency import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.data.common.account.WalletAccountsFetcher 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 e963742c1b..19298725b0 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt @@ -5,6 +5,7 @@ import com.tangem.data.common.account.WalletAccountsFetcher 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.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.common.quote.DefaultQuotesFetcher import com.tangem.data.common.quote.QuotesFetcher import com.tangem.data.common.wallet.DefaultWalletServerBinder diff --git a/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt index e97384ee27..f9425d8385 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt @@ -4,7 +4,7 @@ import android.content.Context import androidx.datastore.core.DataStoreFactory import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi -import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.networks.repository.DefaultNetworksRepository import com.tangem.data.networks.store.DefaultNetworksStatusesStore import com.tangem.data.networks.store.NetworksStatusesStore diff --git a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt index d94944b2a4..b039edc5ba 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt @@ -2,7 +2,7 @@ package com.tangem.data.networks.multi import arrow.core.raise.catch import arrow.core.raise.ensure -import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher import com.tangem.data.networks.store.NetworksStatusesStore import com.tangem.data.networks.store.setSourceAsCache diff --git a/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt b/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt index 0ec3c30598..4755e55248 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt @@ -1,6 +1,6 @@ package com.tangem.data.networks.repository -import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.networks.store.NetworksStatusesStore import com.tangem.data.networks.store.storeStatus import com.tangem.data.networks.utils.NetworkStatusFactory diff --git a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt index 76bdc3f68a..f02bcd3cd3 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt @@ -3,7 +3,7 @@ package com.tangem.data.networks.multi import arrow.core.Either import arrow.core.left import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher import com.tangem.data.networks.store.NetworksStatusesStore import com.tangem.data.networks.store.setSourceAsCache diff --git a/data/networks/src/test/java/com/tangem/data/networks/repository/DefaultNetworksRepositoryTest.kt b/data/networks/src/test/java/com/tangem/data/networks/repository/DefaultNetworksRepositoryTest.kt index f4045d21f9..bc28fb4244 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/repository/DefaultNetworksRepositoryTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/repository/DefaultNetworksRepositoryTest.kt @@ -4,7 +4,7 @@ import com.google.common.truth.Truth import com.tangem.blockchainsdk.models.UpdateWalletManagerResult import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.common.test.domain.walletmanager.MockUpdateWalletManagerResultFactory -import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.networks.store.NetworksStatusesStore import com.tangem.data.networks.store.storeStatus import com.tangem.data.networks.utils.NetworkStatusFactory diff --git a/data/tokens/build.gradle.kts b/data/tokens/build.gradle.kts index 1a97b297ef..96158e5dfb 100644 --- a/data/tokens/build.gradle.kts +++ b/data/tokens/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { // region Project - Domain implementation(projects.domain.account) implementation(projects.domain.card) + implementation(projects.domain.common) implementation(projects.domain.core) implementation(projects.domain.demo) implementation(projects.domain.express) diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt index 161b3fdd48..6a9b6ec68a 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt @@ -2,7 +2,7 @@ package com.tangem.data.tokens.di import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.data.common.cache.CacheRegistry -import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.tokens.repository.DefaultCurrenciesRepository import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository import com.tangem.data.tokens.repository.DefaultTokenReceiveWarningsViewedRepository diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index d50a1e412b..e61f2b466d 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -6,7 +6,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.common.cache.CacheRegistry -import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.data.common.currency.getTokenId import com.tangem.datasource.api.common.response.getOrThrow diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/CardCryptoCurrencyFactory.kt b/domain/common/src/main/java/com/tangem/domain/common/tokens/CardCryptoCurrencyFactory.kt similarity index 98% rename from data/common/src/main/kotlin/com/tangem/data/common/currency/CardCryptoCurrencyFactory.kt rename to domain/common/src/main/java/com/tangem/domain/common/tokens/CardCryptoCurrencyFactory.kt index 44ecdbb935..232903f38f 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/CardCryptoCurrencyFactory.kt +++ b/domain/common/src/main/java/com/tangem/domain/common/tokens/CardCryptoCurrencyFactory.kt @@ -1,4 +1,4 @@ -package com.tangem.data.common.currency +package com.tangem.domain.common.tokens import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network From 9fb5124c3832dc0fb21b1f04510a9d49f59d5d47 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Mar 2026 12:58:48 +0500 Subject: [PATCH 41/60] Updated on 2026-08-14 --- .../tangem/tap/features/main/MainViewModel.kt | 50 ++- .../message/MessageBottomSheet.kt | 363 ++++++++++++------ .../message/MessageBottomSheetUM.kt | 145 ++++++- .../message/MessageBottomSheetUMV2.kt | 142 ------- .../message/MessageBottomSheetV2.kt | 290 -------------- .../tangem/core/ui/message/EventMessage.kt | 69 +--- .../core/ui/message/EventMessageEffect.kt | 62 +-- .../model/AddExistingWalletImportModel.kt | 4 +- .../model/WalletHardwareBackupModel.kt | 6 +- .../onboarding/v2/visa/impl/common/Alerts.kt | 4 +- .../entity/SwapChooseTokenNetworkUM.kt | 10 +- .../model/SwapChooseTokenFactory.kt | 6 +- .../ui/SwapChooseTokenNetworkContent.kt | 22 +- .../tangempay/entity/TangemPayAddFundsUM.kt | 4 +- .../tangempay/entity/TangemPayViewPinUM.kt | 4 +- .../TangemPayAddFundsUMConverter.kt | 2 +- .../TangemPayViewPinErrorStateTransformer.kt | 4 +- .../tangempay/ui/TangemPayAddFundsContent.kt | 4 +- .../tangempay/ui/TangemPayViewPinContent.kt | 4 +- .../utils/TangemPayMessagesFactory.kt | 28 +- .../model/WalletSettingsModel.kt | 8 +- .../model/intents/TangemPayClickIntents.kt | 8 +- .../utils/WalletWarningsSingleEventSender.kt | 4 +- .../components/visa/KycRejectedComponent.kt | 4 +- .../ui/components/visa/KycRejectedModel.kt | 10 +- .../components/AlertsComponentV2.kt | 8 +- .../connections/components/WcPairComponent.kt | 4 +- .../connections/utils/WcAlertsFactory.kt | 46 +-- .../model/WcSendTransactionModel.kt | 8 +- .../transaction/routes/WcTransactionRoutes.kt | 6 +- 30 files changed, 528 insertions(+), 801 deletions(-) delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUMV2.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt 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 6768c39ec9..b8fd319359 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 @@ -10,10 +10,10 @@ import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.R import com.tangem.core.ui.coil.ImagePreloader +import com.tangem.core.ui.components.bottomsheets.message.* import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.message.BottomSheetMessage -import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.message.bottomSheetMessage import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.domain.appcurrency.FetchAppCurrenciesUseCase import com.tangem.domain.balancehiding.BalanceHidingSettings @@ -216,30 +216,28 @@ internal class MainViewModel @Inject constructor( if (!settings.isUpdateFromToast) { listenToFlipsUseCase.changeUpdateEnabled(false) - val message = BottomSheetMessage.invoke( - iconResId = R.drawable.ic_eye_off_outline_24, - title = resourceReference(R.string.balance_hidden_title), - message = resourceReference(R.string.balance_hidden_description), - onDismissRequest = ::onBottomSheetDismissed, - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.balance_hidden_got_it_button), - onClick = { - onHiddenBalanceNotificationAction(isPermanent = false) - onDismissRequest() - }, - ) - }, - secondActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.balance_hidden_do_not_show_button), - onClick = { - onHiddenBalanceNotificationAction(isPermanent = true) - onDismissRequest() - }, - ) - }, - ) + val message = bottomSheetMessage { + infoBlock { + title = resourceReference(R.string.balance_hidden_title) + body = resourceReference(R.string.balance_hidden_description) + icon(R.drawable.ic_eye_off_outline_24) + } + onDismiss { onBottomSheetDismissed() } + primaryButton { + text = resourceReference(R.string.balance_hidden_got_it_button) + onClick = { + onHiddenBalanceNotificationAction(isPermanent = false) + closeBs() + } + } + secondaryButton { + text = resourceReference(R.string.balance_hidden_do_not_show_button) + onClick = { + onHiddenBalanceNotificationAction(isPermanent = true) + closeBs() + } + } + } messageSender.send(message) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt index 7fd0a2bcb0..a3a620e80d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt @@ -1,156 +1,293 @@ package com.tangem.core.ui.components.bottomsheets.message import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.material3.Icon +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape 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 +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.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach import com.tangem.core.ui.R -import com.tangem.core.ui.components.PrimaryButton -import com.tangem.core.ui.components.SecondaryButton -import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.extensions.isNullOrEmpty +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM.Button.IconOrder +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.components.buttons.common.TangemButton +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults +import com.tangem.core.ui.components.icons.HighlightedIcon +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference 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 kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList @Composable -fun MessageBottomSheet(config: TangemBottomSheetConfig) { - TangemBottomSheet(config) { notification: MessageBottomSheetUM -> - Content(model = notification) +fun MessageBottomSheet(state: MessageBottomSheetUM, onDismissRequest: () -> Unit) { + val stateWithOnDismiss = remember(state) { + state.copy( + onDismissRequest = { + state.onDismissRequest.invoke() + onDismissRequest() + }, + ) + } + + val config = TangemBottomSheetConfig( + isShown = true, + content = stateWithOnDismiss, + onDismissRequest = stateWithOnDismiss.onDismissRequest, + ) + + TangemModalBottomSheet( + config = config, + title = { + TangemModalBottomSheetTitle( + endIconRes = R.drawable.ic_close_24, + onEndClick = stateWithOnDismiss.onDismissRequest, + ) + }, + content = { content: MessageBottomSheetUM -> MessageBottomSheetContent(content) }, + ) +} + +@Composable +fun MessageBottomSheetContent(state: MessageBottomSheetUM, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + state.elements.fastForEach { element -> + when (element) { + is MessageBottomSheetUM.InfoBlock -> { + ContentContainer( + modifier = Modifier + .heightIn(min = TangemTheme.dimens.size180) + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16) + .padding(bottom = 32.dp), + state = element, + ) + } + else -> Unit + } + } + + ButtonsContainer( + modifier = Modifier.fillMaxWidth(), + closeScope = state.closeScope, + buttons = state.elements.filterIsInstance().toPersistentList(), + ) } } @Composable -internal fun Content(model: MessageBottomSheetUM, modifier: Modifier = Modifier) { - Column( - modifier = modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(40.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box(modifier = Modifier) - - if (model.iconResId != null) { - Icon( - modifier = Modifier.size(48.dp), - painter = painterResource(model.iconResId), - tint = TangemTheme.colors.icon.primary1, - contentDescription = null, +private fun ContentContainer(state: MessageBottomSheetUM.InfoBlock, modifier: Modifier = Modifier) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + BottomSheetIconContainer(state.icon, state.iconImage) + state.title?.let { title -> + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens.spacing24), + text = title.resolveReference(), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, ) } - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (!model.title.isNullOrEmpty()) { - Text( - text = model.title.resolveReference(), - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.primary1, - textAlign = TextAlign.Center, - ) - } - + state.body?.let { body -> Text( - text = model.message.resolveReference(), + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens.spacing8), + text = body.resolveAnnotatedReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.secondary, textAlign = TextAlign.Center, ) } - - Column( - modifier = Modifier - .padding(bottom = 16.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (model.primaryAction != null) { - PrimaryButton( - modifier = Modifier.fillMaxWidth(), - text = model.primaryAction.text.resolveReference(), - enabled = model.primaryAction.isEnabled, - onClick = model.primaryAction.onClick, - ) - } - - if (model.secondaryAction != null) { - SecondaryButton( - modifier = Modifier.fillMaxWidth(), - text = model.secondaryAction.text.resolveReference(), - enabled = model.secondaryAction.isEnabled, - onClick = model.secondaryAction.onClick, - ) - } + state.chip?.let { chip -> + BottomSheetChip( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing16), + chip = chip, + ) } } } -// region Preview @Composable -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun Preview_NotificationBottomSheet( - @PreviewParameter(MessageBottomSheetUMPreviewProvider::class) params: MessageBottomSheetUM, +private fun BottomSheetIconContainer( + icon: MessageBottomSheetUM.Icon?, + iconImage: MessageBottomSheetUM.IconImage?, + modifier: Modifier = Modifier, ) { - TangemThemePreview { - MessageBottomSheet( - config = TangemBottomSheetConfig( - content = params, - isShown = true, - onDismissRequest = {}, - ), + if (icon != null) { + BottomSheetIcon(icon, modifier) + } else if (iconImage != null) { + Image( + modifier = modifier + .size(TangemTheme.dimens.size56) + .clip(CircleShape), + painter = painterResource(id = iconImage.res), + contentDescription = null, ) } } -private class MessageBottomSheetUMPreviewProvider : PreviewParameterProvider { - val balancesHiddenMessage = MessageBottomSheetUM( - iconResId = R.drawable.ic_eye_off_outline_24, - title = resourceReference(R.string.balance_hidden_title), - message = resourceReference(R.string.balance_hidden_description), - primaryAction = MessageBottomSheetUM.ActionUM( - text = resourceReference(R.string.balance_hidden_got_it_button), - onClick = {}, - ), - secondaryAction = MessageBottomSheetUM.ActionUM( - text = resourceReference(R.string.balance_hidden_do_not_show_button), - onClick = {}, - ), - ) +@Composable +private fun BottomSheetIcon(icon: MessageBottomSheetUM.Icon, modifier: Modifier = Modifier) { + val tint = when (icon.type) { + MessageBottomSheetUM.Icon.Type.Unspecified -> Color.Unspecified + MessageBottomSheetUM.Icon.Type.Accent -> TangemTheme.colors.icon.accent + MessageBottomSheetUM.Icon.Type.Informative -> TangemTheme.colors.icon.informative + MessageBottomSheetUM.Icon.Type.Attention -> TangemTheme.colors.icon.attention + MessageBottomSheetUM.Icon.Type.Warning -> TangemTheme.colors.icon.warning + } - override val values: Sequence - get() = sequenceOf( - balancesHiddenMessage, - balancesHiddenMessage.copy(title = null), - balancesHiddenMessage.copy(iconResId = null), - balancesHiddenMessage.copy(primaryAction = null), - balancesHiddenMessage.copy(secondaryAction = null), - balancesHiddenMessage.copy( - primaryAction = null, - secondaryAction = null, - ), - balancesHiddenMessage.copy( - title = null, - iconResId = null, - primaryAction = null, - secondaryAction = null, - ), - ) + val backgroundColor = when (icon.backgroundType) { + MessageBottomSheetUM.Icon.BackgroundType.Unspecified -> TangemTheme.colors.icon.informative + MessageBottomSheetUM.Icon.BackgroundType.SameAsTint -> tint + MessageBottomSheetUM.Icon.BackgroundType.Accent -> TangemTheme.colors.icon.accent + MessageBottomSheetUM.Icon.BackgroundType.Informative -> TangemTheme.colors.icon.informative + MessageBottomSheetUM.Icon.BackgroundType.Attention -> TangemTheme.colors.icon.attention + MessageBottomSheetUM.Icon.BackgroundType.Warning -> TangemTheme.colors.icon.warning + } + + HighlightedIcon( + modifier = modifier, + icon = icon.res, + iconTint = tint, + backgroundColor = backgroundColor, + ) } -// endregion Preview \ No newline at end of file + +@Composable +private fun BottomSheetChip(chip: MessageBottomSheetUM.Chip, modifier: Modifier = Modifier) { + val color = when (chip.type) { + MessageBottomSheetUM.Chip.Type.Unspecified -> TangemTheme.colors.text.primary1 + MessageBottomSheetUM.Chip.Type.Warning -> TangemTheme.colors.text.warning + } + + Text( + modifier = modifier + .background( + shape = RoundedCornerShape(TangemTheme.dimens.radius16), + color = color.copy(alpha = 0.1F), + ) + .padding(vertical = TangemTheme.dimens.spacing4, horizontal = TangemTheme.dimens.spacing12), + text = chip.text.resolveReference(), + style = TangemTheme.typography.caption1, + color = color, + ) +} + +@Suppress("LongMethod") +@Composable +private fun ButtonsContainer( + buttons: ImmutableList, + closeScope: MessageBottomSheetUM.CloseScope, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(all = TangemTheme.dimens.spacing16), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + buttons.fastForEach { button -> + val icon = button.icon?.let { iconResId -> + when (button.iconOrder) { + IconOrder.Start -> TangemButtonIconPosition.Start(iconResId) + IconOrder.End -> TangemButtonIconPosition.End(iconResId) + } + } ?: TangemButtonIconPosition.None + + TangemButton( + modifier = Modifier.fillMaxWidth(), + text = button.text?.resolveReference().orEmpty(), + icon = icon, + onClick = { button.onClick?.invoke(closeScope) }, + colors = if (button.isPrimary) { + TangemButtonsDefaults.primaryButtonColors + } else { + TangemButtonsDefaults.secondaryButtonColors + }, + enabled = true, + showProgress = false, + textStyle = TangemTheme.typography.subtitle1, + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + MessageBottomSheet( + messageBottomSheetUM { + infoBlock { + icon(R.drawable.img_knight_shield_32) { + type = MessageBottomSheetUM.Icon.Type.Attention + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint + } + title = TextReference.Str("Title Title Title") + body = TextReference.Str("Body") + chip(text = TextReference.Str("Some chip information")) + } + primaryButton { + text = TextReference.Str("Test") + icon = R.drawable.ic_tangem_24 + } + secondaryButton { + icon = R.drawable.ic_tangem_24 + text = TextReference.Str("asdasd") + onClick { + closeBs() + } + } + }, + onDismissRequest = {}, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview2() { + TangemThemePreview { + MessageBottomSheet( + messageBottomSheetUM { + infoBlock { + iconImage = MessageBottomSheetUM.IconImage(R.drawable.img_visa_notification) + title = TextReference.Str("Title Title Title") + body = TextReference.Str("Body") + chip(text = TextReference.Str("Some chip information")) + } + primaryButton { + text = TextReference.Str("Test") + icon = R.drawable.ic_tangem_24 + } + secondaryButton { + icon = R.drawable.ic_tangem_24 + text = TextReference.Str("asdasd") + onClick { + closeBs() + } + } + }, + onDismissRequest = {}, + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUM.kt index c07d11f22a..d6eed694c1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUM.kt @@ -1,20 +1,143 @@ package com.tangem.core.ui.components.bottomsheets.message import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +@Immutable data class MessageBottomSheetUM( - @DrawableRes val iconResId: Int?, - val title: TextReference?, - val message: TextReference, - val primaryAction: ActionUM?, - val secondaryAction: ActionUM?, + var elements: ImmutableList = persistentListOf(), + var onDismissRequest: () -> Unit = {}, ) : TangemBottomSheetConfigContent { - data class ActionUM( - val text: TextReference, - val isEnabled: Boolean = true, - val onClick: () -> Unit, - ) -} \ No newline at end of file + @Immutable + inner class CloseScope { + fun closeBs() { + onDismissRequest() + } + } + + val closeScope = CloseScope() + + @Immutable + sealed interface Element + + @Immutable + data class Icon( + @DrawableRes internal var res: Int, + var type: Type = Type.Unspecified, + var backgroundType: BackgroundType = BackgroundType.Unspecified, + ) : Element { + enum class Type { + Unspecified, Accent, Informative, Attention, Warning, + } + + enum class BackgroundType { + Unspecified, SameAsTint, Accent, Informative, Attention, Warning, + } + } + + @Immutable + data class IconImage(@DrawableRes internal var res: Int) : Element + + @Immutable + data class Chip( + internal var text: TextReference, + var type: Type = Type.Unspecified, + ) : Element { + enum class Type { + Unspecified, Warning + } + } + + @Immutable + data class InfoBlock( + internal var icon: Icon? = null, + internal var iconImage: IconImage? = null, + internal var chip: Chip? = null, + var title: TextReference? = null, + var body: TextReference? = null, + ) : Element + + @Immutable + data class Button( + internal var isPrimary: Boolean = false, + var text: TextReference? = null, + @DrawableRes internal var iconInternal: Int? = null, + internal var iconOrder: IconOrder = IconOrder.Start, + var onClick: (CloseScope.() -> Unit)? = null, + ) : Element { + + var icon: Int? = iconInternal + set(value) { + iconOrder = if (text == null) { + IconOrder.Start + } else { + IconOrder.End + } + field = value + } + + enum class IconOrder { + Start, End + } + } +} + +@Target(AnnotationTarget.TYPE) +@DslMarker +annotation class MessageBottomSheetDsl + +// region: DSL + +fun messageBottomSheetUM(init: @MessageBottomSheetDsl MessageBottomSheetUM.() -> Unit) = + MessageBottomSheetUM().apply(init) + +fun MessageBottomSheetUM.onDismiss(block: () -> Unit) = apply { onDismissRequest = block } + +@Suppress("NestedScopeFunctions") +fun MessageBottomSheetUM.infoBlock(init: @MessageBottomSheetDsl MessageBottomSheetUM.InfoBlock.() -> Unit) = apply { + val element = MessageBottomSheetUM.InfoBlock().apply(init) + elements = (elements + element).toPersistentList() +} + +@Suppress("NestedScopeFunctions") +fun MessageBottomSheetUM.InfoBlock.icon(@DrawableRes res: Int, init: MessageBottomSheetUM.Icon.() -> Unit = {}) = + apply { + icon = MessageBottomSheetUM.Icon(res).apply(init) + } + +fun MessageBottomSheetUM.InfoBlock.iconImage(@DrawableRes res: Int) = apply { + iconImage = MessageBottomSheetUM.IconImage(res) +} + +@Suppress("NestedScopeFunctions") +fun MessageBottomSheetUM.InfoBlock.chip(text: TextReference, init: MessageBottomSheetUM.Chip.() -> Unit = {}) = apply { + chip = MessageBottomSheetUM.Chip(text).apply(init) +} + +@Suppress("NestedScopeFunctions") +internal fun MessageBottomSheetUM.button(init: @MessageBottomSheetDsl MessageBottomSheetUM.Button.() -> Unit) = apply { + val element = MessageBottomSheetUM.Button().apply(init) + elements = (elements + element).toPersistentList() +} + +@Suppress("NestedScopeFunctions") +fun MessageBottomSheetUM.primaryButton(init: @MessageBottomSheetDsl MessageBottomSheetUM.Button.() -> Unit) = apply { + button { isPrimary = true; apply(init) } +} + +@Suppress("NestedScopeFunctions") +fun MessageBottomSheetUM.secondaryButton(init: @MessageBottomSheetDsl MessageBottomSheetUM.Button.() -> Unit) = apply { + button { isPrimary = false; apply(init) } +} + +fun MessageBottomSheetUM.Button.onClick(block: MessageBottomSheetUM.CloseScope.() -> Unit) = apply { + onClick = block +} + +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUMV2.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUMV2.kt deleted file mode 100644 index a776ddd445..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUMV2.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.tangem.core.ui.components.bottomsheets.message - -import androidx.annotation.DrawableRes -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.extensions.TextReference -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList - -@Immutable -data class MessageBottomSheetUMV2( - var elements: ImmutableList = persistentListOf(), - var onDismissRequest: () -> Unit = {}, -) : TangemBottomSheetConfigContent { - - @Immutable - inner class CloseScope { - fun closeBs() { - onDismissRequest() - } - } - - val closeScope = CloseScope() - - @Immutable - sealed interface Element - - @Immutable - data class Icon( - @DrawableRes internal var res: Int, - var type: Type = Type.Unspecified, - var backgroundType: BackgroundType = BackgroundType.Unspecified, - ) : Element { - enum class Type { - Unspecified, Accent, Informative, Attention, Warning, - } - - enum class BackgroundType { - Unspecified, SameAsTint, Accent, Informative, Attention, Warning, - } - } - - @Immutable - data class IconImage(@DrawableRes internal var res: Int) : Element - - @Immutable - data class Chip( - internal var text: TextReference, - var type: Type = Type.Unspecified, - ) : Element { - enum class Type { - Unspecified, Warning - } - } - - @Immutable - data class InfoBlock( - internal var icon: Icon? = null, - internal var iconImage: IconImage? = null, - internal var chip: Chip? = null, - var title: TextReference? = null, - var body: TextReference? = null, - ) : Element - - @Immutable - data class Button( - internal var isPrimary: Boolean = false, - var text: TextReference? = null, - @DrawableRes internal var iconInternal: Int? = null, - internal var iconOrder: IconOrder = IconOrder.Start, - var onClick: (CloseScope.() -> Unit)? = null, - ) : Element { - - var icon: Int? = iconInternal - set(value) { - iconOrder = if (text == null) { - IconOrder.Start - } else { - IconOrder.End - } - field = value - } - - enum class IconOrder { - Start, End - } - } -} - -@Target(AnnotationTarget.TYPE) -@DslMarker -annotation class MessageBottomSheetV2Dsl - -// region: DSL - -fun messageBottomSheetUM(init: @MessageBottomSheetV2Dsl MessageBottomSheetUMV2.() -> Unit) = - MessageBottomSheetUMV2().apply(init) - -fun MessageBottomSheetUMV2.onDismiss(block: () -> Unit) = MessageBottomSheetUMV2().apply { onDismissRequest = block } - -fun MessageBottomSheetUMV2.infoBlock(init: @MessageBottomSheetV2Dsl MessageBottomSheetUMV2.InfoBlock.() -> Unit) = - apply { - val element = MessageBottomSheetUMV2.InfoBlock().apply(init) - elements = (elements + element).toPersistentList() - } - -fun MessageBottomSheetUMV2.InfoBlock.icon(@DrawableRes res: Int, init: MessageBottomSheetUMV2.Icon.() -> Unit = {}) = - apply { - icon = MessageBottomSheetUMV2.Icon(res).apply(init) - } - -fun MessageBottomSheetUMV2.InfoBlock.iconImage(@DrawableRes res: Int) = apply { - iconImage = MessageBottomSheetUMV2.IconImage(res) -} - -fun MessageBottomSheetUMV2.InfoBlock.chip(text: TextReference, init: MessageBottomSheetUMV2.Chip.() -> Unit = {}) = - apply { - chip = MessageBottomSheetUMV2.Chip(text).apply(init) - } - -internal fun MessageBottomSheetUMV2.button(init: @MessageBottomSheetV2Dsl MessageBottomSheetUMV2.Button.() -> Unit) = - apply { - val element = MessageBottomSheetUMV2.Button().apply(init) - elements = (elements + element).toPersistentList() - } - -fun MessageBottomSheetUMV2.primaryButton(init: @MessageBottomSheetV2Dsl MessageBottomSheetUMV2.Button.() -> Unit) = - apply { - button { isPrimary = true; apply(init) } - } - -fun MessageBottomSheetUMV2.secondaryButton(init: @MessageBottomSheetV2Dsl MessageBottomSheetUMV2.Button.() -> Unit) = - apply { - button { isPrimary = false; apply(init) } - } - -fun MessageBottomSheetUMV2.Button.onClick(block: MessageBottomSheetUMV2.CloseScope.() -> Unit) = apply { - onClick = block -} - -// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt deleted file mode 100644 index 75ed9271df..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt +++ /dev/null @@ -1,290 +0,0 @@ -package com.tangem.core.ui.components.bottomsheets.message - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -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 -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 androidx.compose.ui.util.fastForEach -import com.tangem.core.ui.R -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2.Button.IconOrder -import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet -import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle -import com.tangem.core.ui.components.buttons.common.TangemButton -import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition -import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults -import com.tangem.core.ui.components.icons.HighlightedIcon -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resolveAnnotatedReference -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toPersistentList - -@Composable -fun MessageBottomSheetV2(state: MessageBottomSheetUMV2, onDismissRequest: () -> Unit) { - val stateWithOnDismiss = remember(state) { - state.copy( - onDismissRequest = { - state.onDismissRequest.invoke() - onDismissRequest() - }, - ) - } - - val config = TangemBottomSheetConfig( - isShown = true, - content = stateWithOnDismiss, - onDismissRequest = stateWithOnDismiss.onDismissRequest, - ) - - TangemModalBottomSheet( - config = config, - title = { - TangemModalBottomSheetTitle( - endIconRes = R.drawable.ic_close_24, - onEndClick = stateWithOnDismiss.onDismissRequest, - ) - }, - content = { content: MessageBottomSheetUMV2 -> MessageBottomSheetV2Content(content) }, - ) -} - -@Composable -fun MessageBottomSheetV2Content(state: MessageBottomSheetUMV2, modifier: Modifier = Modifier) { - Column(modifier = modifier) { - state.elements.fastForEach { element -> - when (element) { - is MessageBottomSheetUMV2.InfoBlock -> { - ContentContainer( - modifier = Modifier - .heightIn(min = TangemTheme.dimens.size180) - .fillMaxWidth() - .padding(horizontal = TangemTheme.dimens.spacing16) - .padding(bottom = 32.dp), - state = element, - ) - } - else -> Unit - } - } - - ButtonsContainer( - modifier = Modifier.fillMaxWidth(), - closeScope = state.closeScope, - buttons = state.elements.filterIsInstance().toPersistentList(), - ) - } -} - -@Composable -private fun ContentContainer(state: MessageBottomSheetUMV2.InfoBlock, modifier: Modifier = Modifier) { - Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { - BottomSheetIconContainer(state.icon, state.iconImage) - state.title?.let { title -> - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = TangemTheme.dimens.spacing24), - text = title.resolveReference(), - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - textAlign = TextAlign.Center, - ) - } - state.body?.let { body -> - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = TangemTheme.dimens.spacing8), - text = body.resolveAnnotatedReference(), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - textAlign = TextAlign.Center, - ) - } - state.chip?.let { chip -> - BottomSheetChip( - modifier = Modifier.padding(top = TangemTheme.dimens.spacing16), - chip = chip, - ) - } - } -} - -@Composable -private fun BottomSheetIconContainer( - icon: MessageBottomSheetUMV2.Icon?, - iconImage: MessageBottomSheetUMV2.IconImage?, - modifier: Modifier = Modifier, -) { - if (icon != null) { - BottomSheetIcon(icon, modifier) - } else if (iconImage != null) { - Image( - modifier = modifier - .size(TangemTheme.dimens.size56) - .clip(CircleShape), - painter = painterResource(id = iconImage.res), - contentDescription = null, - ) - } -} - -@Composable -private fun BottomSheetIcon(icon: MessageBottomSheetUMV2.Icon, modifier: Modifier = Modifier) { - val tint = when (icon.type) { - MessageBottomSheetUMV2.Icon.Type.Unspecified -> Color.Unspecified - MessageBottomSheetUMV2.Icon.Type.Accent -> TangemTheme.colors.icon.accent - MessageBottomSheetUMV2.Icon.Type.Informative -> TangemTheme.colors.icon.informative - MessageBottomSheetUMV2.Icon.Type.Attention -> TangemTheme.colors.icon.attention - MessageBottomSheetUMV2.Icon.Type.Warning -> TangemTheme.colors.icon.warning - } - - val backgroundColor = when (icon.backgroundType) { - MessageBottomSheetUMV2.Icon.BackgroundType.Unspecified -> TangemTheme.colors.icon.informative - MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint -> tint - MessageBottomSheetUMV2.Icon.BackgroundType.Accent -> TangemTheme.colors.icon.accent - MessageBottomSheetUMV2.Icon.BackgroundType.Informative -> TangemTheme.colors.icon.informative - MessageBottomSheetUMV2.Icon.BackgroundType.Attention -> TangemTheme.colors.icon.attention - MessageBottomSheetUMV2.Icon.BackgroundType.Warning -> TangemTheme.colors.icon.warning - } - - HighlightedIcon( - modifier = modifier, - icon = icon.res, - iconTint = tint, - backgroundColor = backgroundColor, - ) -} - -@Composable -private fun BottomSheetChip(chip: MessageBottomSheetUMV2.Chip, modifier: Modifier = Modifier) { - val color = when (chip.type) { - MessageBottomSheetUMV2.Chip.Type.Unspecified -> TangemTheme.colors.text.primary1 - MessageBottomSheetUMV2.Chip.Type.Warning -> TangemTheme.colors.text.warning - } - - Text( - modifier = modifier - .background( - shape = RoundedCornerShape(TangemTheme.dimens.radius16), - color = color.copy(alpha = 0.1F), - ) - .padding(vertical = TangemTheme.dimens.spacing4, horizontal = TangemTheme.dimens.spacing12), - text = chip.text.resolveReference(), - style = TangemTheme.typography.caption1, - color = color, - ) -} - -@Suppress("LongMethod") -@Composable -private fun ButtonsContainer( - buttons: ImmutableList, - closeScope: MessageBottomSheetUMV2.CloseScope, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier.padding(all = TangemTheme.dimens.spacing16), - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), - ) { - buttons.fastForEach { button -> - val icon = button.icon?.let { iconResId -> - when (button.iconOrder) { - IconOrder.Start -> TangemButtonIconPosition.Start(iconResId) - IconOrder.End -> TangemButtonIconPosition.End(iconResId) - } - } ?: TangemButtonIconPosition.None - - TangemButton( - modifier = Modifier.fillMaxWidth(), - text = button.text?.resolveReference().orEmpty(), - icon = icon, - onClick = { button.onClick?.invoke(closeScope) }, - colors = if (button.isPrimary) { - TangemButtonsDefaults.primaryButtonColors - } else { - TangemButtonsDefaults.secondaryButtonColors - }, - enabled = true, - showProgress = false, - textStyle = TangemTheme.typography.subtitle1, - ) - } - } -} - -@Preview -@Composable -private fun Preview() { - TangemThemePreview { - MessageBottomSheetV2( - messageBottomSheetUM { - infoBlock { - icon(R.drawable.img_knight_shield_32) { - type = MessageBottomSheetUMV2.Icon.Type.Attention - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint - } - title = TextReference.Str("Title Title Title") - body = TextReference.Str("Body") - chip(text = TextReference.Str("Some chip information")) - } - primaryButton { - text = TextReference.Str("Test") - icon = R.drawable.ic_tangem_24 - } - secondaryButton { - icon = R.drawable.ic_tangem_24 - text = TextReference.Str("asdasd") - onClick { - closeBs() - } - } - }, - onDismissRequest = {}, - ) - } -} - -@Preview -@Composable -private fun Preview2() { - TangemThemePreview { - MessageBottomSheetV2( - messageBottomSheetUM { - infoBlock { - iconImage = MessageBottomSheetUMV2.IconImage(R.drawable.img_visa_notification) - title = TextReference.Str("Title Title Title") - body = TextReference.Str("Body") - chip(text = TextReference.Str("Some chip information")) - } - primaryButton { - text = TextReference.Str("Test") - icon = R.drawable.ic_tangem_24 - } - secondaryButton { - icon = R.drawable.ic_tangem_24 - text = TextReference.Str("asdasd") - onClick { - closeBs() - } - } - }, - onDismissRequest = {}, - ) - } -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt index b0131e2484..1279a8dfcc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt @@ -4,8 +4,8 @@ import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import com.tangem.core.decompose.ui.UiMessage import com.tangem.core.ui.R -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetV2Dsl +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetDsl +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM import com.tangem.core.ui.components.bottomsheets.message.messageBottomSheetUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference @@ -156,65 +156,22 @@ data class GlobalLoadingMessage(val isShow: Boolean) : EventMessage /** * Shows a bottom sheet. * - * @param iconResId The icon to show in the bottom sheet. - * Optional, `null` by default. - * @param title The title of the bottom sheet. Optional, `null` by default. - * @param message The message to show in the bottom sheet. - * @param firstAction The first action to perform. Optional, `null` by default. - * @param secondAction The second action to perform. Optional, `null` by default. - * @param onDismissRequest The action to perform when the bottom sheet is dismissed. + * @param messageBottomSheetUM The content of the bottom sheet. * */ @Immutable data class BottomSheetMessage( - @DrawableRes val iconResId: Int? = null, - val title: TextReference? = null, - val message: TextReference, - val firstAction: EventMessageAction? = null, - val secondAction: EventMessageAction? = null, - val onDismissRequest: () -> Unit = {}, -) : EventMessage { - - companion object { - - /** - * Builder for [BottomSheetMessage]. - * - * @param iconResId The icon to show in the bottom sheet. Optional, `null` by default. - * @param title The title of the bottom sheet. Optional, `null` by default. - * @param message The message to show in the bottom sheet. - * @param onDismissRequest The action to perform when the bottom sheet is dismissed. - * @param firstActionBuilder The builder for the first action. Optional, `null` by default. - * @param secondActionBuilder The builder for the second action. Optional, `null` by default. - * */ - operator fun invoke( - @DrawableRes iconResId: Int?, - title: TextReference?, - message: TextReference, - onDismissRequest: () -> Unit = {}, - firstActionBuilder: (EventMessageAction.BuilderScope.() -> EventMessageAction)? = null, - secondActionBuilder: (EventMessageAction.BuilderScope.() -> EventMessageAction)? = null, - ): BottomSheetMessage { - val buttonsScope = EventMessageAction.BuilderScope(onDismissRequest) - - return BottomSheetMessage( - iconResId = iconResId, - title = title, - message = message, - onDismissRequest = onDismissRequest, - firstAction = firstActionBuilder?.invoke(buttonsScope), - secondAction = secondActionBuilder?.invoke(buttonsScope), - ) - } - } -} - -@Immutable -data class BottomSheetMessageV2( - val messageBottomSheetUMV2: MessageBottomSheetUMV2, + val messageBottomSheetUM: MessageBottomSheetUM, ) : EventMessage -fun bottomSheetMessage(init: @MessageBottomSheetV2Dsl MessageBottomSheetUMV2.() -> Unit) = - BottomSheetMessageV2(messageBottomSheetUM(init)) +/** + * Builder for [BottomSheetMessage]. + * + * @param init The builder lambda with receiver of type [MessageBottomSheetUM]. + * + * @return [BottomSheetMessage] with the specified content. + */ +fun bottomSheetMessage(init: @MessageBottomSheetDsl MessageBottomSheetUM.() -> Unit) = + BottomSheetMessage(messageBottomSheetUM(init)) /** * Represents an action button in the dialog. diff --git a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt index 75d08a8dfc..116a4e795a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt @@ -19,18 +19,11 @@ import androidx.compose.ui.window.DialogProperties import com.tangem.core.ui.components.BasicDialog import com.tangem.core.ui.components.DialogButtonUM import com.tangem.core.ui.components.SpacerHMax -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetV2 import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHostState import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.res.LocalEventMessageHandler -import com.tangem.core.ui.res.LocalRedesignEnabled -import com.tangem.core.ui.res.LocalSnackbarHostState -import com.tangem.core.ui.res.LocalTopSnackbarHostState -import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.* @Composable fun EventMessageEffect( @@ -50,7 +43,6 @@ fun EventMessageEffect( var dialogMessage: DialogMessage? by remember { mutableStateOf(value = null) } var bottomSheetMessage: BottomSheetMessage? by remember { mutableStateOf(value = null) } - var bottomSheetMessageV2: BottomSheetMessageV2? by remember { mutableStateOf(value = null) } var loadingMessage: GlobalLoadingMessage? by remember { mutableStateOf(value = null) } val isRedesignEnabled = LocalRedesignEnabled.current @@ -69,9 +61,6 @@ fun EventMessageEffect( is BottomSheetMessage -> { bottomSheetMessage = message } - is BottomSheetMessageV2 -> { - bottomSheetMessageV2 = message - } is ToastMessage -> { onShowToast(message, context) } @@ -97,18 +86,8 @@ fun EventMessageEffect( bottomSheetMessage?.let { message -> MessageBottomSheet( - message = message, - onDismissRequest = { - bottomSheetMessage = null - message.onDismissRequest() - }, - ) - } - - bottomSheetMessageV2?.let { message -> - MessageBottomSheetV2( - state = message.messageBottomSheetUMV2, - onDismissRequest = { bottomSheetMessageV2 = null }, + state = message.messageBottomSheetUM, + onDismissRequest = { bottomSheetMessage = null }, ) } @@ -141,41 +120,6 @@ private fun LoadingDialog() { } } -@Composable -private fun MessageBottomSheet(message: BottomSheetMessage, onDismissRequest: () -> Unit) { - val config = TangemBottomSheetConfig( - isShown = true, - content = MessageBottomSheetUM( - iconResId = message.iconResId, - title = message.title, - message = message.message, - primaryAction = message.firstAction?.let { action -> - MessageBottomSheetUM.ActionUM( - text = action.title, - isEnabled = action.isEnabled, - onClick = { - action.onClick() - onDismissRequest() - }, - ) - }, - secondaryAction = message.secondAction?.let { action -> - MessageBottomSheetUM.ActionUM( - text = action.title, - isEnabled = action.isEnabled, - onClick = { - action.onClick() - onDismissRequest() - }, - ) - }, - ), - onDismissRequest = onDismissRequest, - ) - - MessageBottomSheet(config) -} - @Composable private fun MessageDialog(message: DialogMessage, onDismissRequest: () -> Unit) { BasicDialog( diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt index 6b0489cab4..e2d4d75a14 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt @@ -53,8 +53,8 @@ internal class AddExistingWalletImportModel @Inject constructor( get() = bottomSheetMessage { infoBlock { icon(R.drawable.ic_passcode_lock_56) { - type = MessageBottomSheetUMV2.Icon.Type.Accent - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + type = MessageBottomSheetUM.Icon.Type.Accent + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint } title = resourceReference(R.string.common_passphrase) body = resourceReference(R.string.onboarding_bottom_sheet_passphrase_description) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/wallethardwarebackup/model/WalletHardwareBackupModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/wallethardwarebackup/model/WalletHardwareBackupModel.kt index ae34bf84a6..c432ac4cc1 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/wallethardwarebackup/model/WalletHardwareBackupModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/wallethardwarebackup/model/WalletHardwareBackupModel.kt @@ -12,7 +12,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.url.UrlOpener -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM 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 @@ -66,8 +66,8 @@ internal class WalletHardwareBackupModel @Inject constructor( bottomSheetMessage { infoBlock { icon(R.drawable.ic_passcode_lock_32) { - type = MessageBottomSheetUMV2.Icon.Type.Accent - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + type = MessageBottomSheetUM.Icon.Type.Accent + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint } title = resourceReference(R.string.hw_backup_need_finish_first) body = resourceReference(R.string.hw_backup_to_upgrade_description) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/common/Alerts.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/common/Alerts.kt index 544d28b1fb..508c5e6c1e 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/common/Alerts.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/common/Alerts.kt @@ -9,8 +9,8 @@ val unexpectedErrorAlertBS get() = bottomSheetMessage { infoBlock { icon(R.drawable.img_knight_shield_32) { - type = MessageBottomSheetUMV2.Icon.Type.Attention - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + type = MessageBottomSheetUM.Icon.Type.Attention + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint } title = resourceReference(R.string.unexpected_error_title) body = resourceReference(R.string.unexpected_error_description) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/entity/SwapChooseTokenNetworkUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/entity/SwapChooseTokenNetworkUM.kt index 44cd023b3a..7940f4a904 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/entity/SwapChooseTokenNetworkUM.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/entity/SwapChooseTokenNetworkUM.kt @@ -4,7 +4,7 @@ import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList @@ -15,18 +15,18 @@ internal data class SwapChooseTokenNetworkUM( @Immutable internal sealed class SwapChooseTokenNetworkContentUM : TangemBottomSheetConfigContent { - abstract val messageContent: MessageBottomSheetUMV2 + abstract val messageContent: MessageBottomSheetUM data class Loading( - override val messageContent: MessageBottomSheetUMV2, + override val messageContent: MessageBottomSheetUM, ) : SwapChooseTokenNetworkContentUM() data class Error( - override val messageContent: MessageBottomSheetUMV2, + override val messageContent: MessageBottomSheetUM, ) : SwapChooseTokenNetworkContentUM() data class Content( - override val messageContent: MessageBottomSheetUMV2, + override val messageContent: MessageBottomSheetUM, val swapNetworks: ImmutableList, ) : SwapChooseTokenNetworkContentUM() } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenFactory.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenFactory.kt index 7b7b1752ce..5a1d8e0eb2 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenFactory.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenFactory.kt @@ -7,12 +7,12 @@ import com.tangem.features.swap.v2.impl.R internal object SwapChooseTokenFactory { - fun getErrorMessage(tokenName: String, onDismiss: () -> Unit): MessageBottomSheetUMV2 { + fun getErrorMessage(tokenName: String, onDismiss: () -> Unit): MessageBottomSheetUM { return messageBottomSheetUM { infoBlock { icon(R.drawable.ic_alert_triangle_20) { - type = MessageBottomSheetUMV2.Icon.Type.Attention - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + type = MessageBottomSheetUM.Icon.Type.Attention + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint } title = resourceReference(R.string.express_swap_not_supported_title, wrappedList(tokenName)) body = resourceReference(R.string.express_swap_not_supported_text) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/ui/SwapChooseTokenNetworkContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/ui/SwapChooseTokenNetworkContent.kt index 555acd6d00..b4466b11a5 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/ui/SwapChooseTokenNetworkContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/ui/SwapChooseTokenNetworkContent.kt @@ -73,7 +73,7 @@ internal fun SwapChooseTokenNetworkContent(state: SwapChooseTokenNetworkContentU is SwapChooseTokenNetworkContentUM.Content -> SwapChooseTokenNetworkContentList(targetState.swapNetworks) else -> { Box { - MessageBottomSheetV2Content( + MessageBottomSheetContent( state = targetState.messageContent, modifier = Modifier .conditional(targetState is SwapChooseTokenNetworkContentUM.Loading) { @@ -180,8 +180,8 @@ private class PreviewProvider : PreviewParameterProvider, val dismiss: () -> Unit, - val errorMessage: MessageBottomSheetUMV2?, + val errorMessage: MessageBottomSheetUM?, ) internal data class TangemPayAddFundsItemUM( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayViewPinUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayViewPinUM.kt index 8ee03d3db4..79556e1a76 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayViewPinUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayViewPinUM.kt @@ -1,6 +1,6 @@ package com.tangem.features.tangempay.entity -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM internal sealed class TangemPayViewPinUM { @@ -17,7 +17,7 @@ internal sealed class TangemPayViewPinUM { ) : TangemPayViewPinUM() data class Error( - val errorMessage: MessageBottomSheetUMV2, + val errorMessage: MessageBottomSheetUM, override val onDismiss: () -> Unit, ) : TangemPayViewPinUM() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt index 4de43025ef..aa2d75e414 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt @@ -22,7 +22,7 @@ internal class TangemPayAddFundsUMConverter( dismiss = listener::onDismissAddFunds, errorMessage = TangemPayMessagesFactory.createErrorMessage( errorType = TangemPayDetailsErrorType.Receive, - ).messageBottomSheetUMV2, + ).messageBottomSheetUM, ) } else { TangemPayAddFundsUM( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayViewPinErrorStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayViewPinErrorStateTransformer.kt index abf2326617..5bd5b443ab 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayViewPinErrorStateTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayViewPinErrorStateTransformer.kt @@ -1,7 +1,7 @@ package com.tangem.features.tangempay.model.transformers import com.tangem.core.ui.R -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM 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.messageBottomSheetUM @@ -19,7 +19,7 @@ internal class TangemPayViewPinErrorStateTransformer : Transformer { - MessageBottomSheetV2Content(state.errorMessage) + MessageBottomSheetContent(state.errorMessage) } is TangemPayViewPinUM.Loading -> { PinLoadingContent() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt index 0aff2907e9..5f8a35fffc 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt @@ -4,18 +4,18 @@ import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.message.* import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.message.BottomSheetMessageV2 +import com.tangem.core.ui.message.BottomSheetMessage import com.tangem.core.ui.message.bottomSheetMessage import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType internal object TangemPayMessagesFactory { - fun createErrorMessage(errorType: TangemPayDetailsErrorType): BottomSheetMessageV2 { + fun createErrorMessage(errorType: TangemPayDetailsErrorType): BottomSheetMessage { return when (errorType) { TangemPayDetailsErrorType.Receive -> bottomSheetMessage { infoBlock { icon(R.drawable.img_attention_20) { - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Attention + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Attention } title = TextReference.Res(R.string.tangempay_card_details_receive_error_title) body = TextReference.Res(R.string.tangempay_card_details_receive_error_description) @@ -28,7 +28,7 @@ internal object TangemPayMessagesFactory { TangemPayDetailsErrorType.Withdraw -> bottomSheetMessage { infoBlock { icon(R.drawable.img_attention_20) { - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Attention + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Attention } title = TextReference.Res(R.string.tangempay_card_details_withdraw_error_title) body = TextReference.Res(R.string.tangempay_card_details_receive_error_description) @@ -41,8 +41,8 @@ internal object TangemPayMessagesFactory { TangemPayDetailsErrorType.WithdrawInProgress -> bottomSheetMessage { infoBlock { icon(R.drawable.ic_clock_24) { - type = MessageBottomSheetUMV2.Icon.Type.Informative - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Informative + type = MessageBottomSheetUM.Icon.Type.Informative + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Informative } title = TextReference.Res(R.string.tangempay_card_details_withdraw_in_progress_title) body = TextReference.Res(R.string.tangempay_card_details_withdraw_in_progress_description) @@ -55,12 +55,12 @@ internal object TangemPayMessagesFactory { } } - fun createFreezeCardMessage(onFreezeClicked: () -> Unit): BottomSheetMessageV2 { + fun createFreezeCardMessage(onFreezeClicked: () -> Unit): BottomSheetMessage { return bottomSheetMessage { infoBlock { icon(R.drawable.ic_snow_24) { - type = MessageBottomSheetUMV2.Icon.Type.Accent - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Accent + type = MessageBottomSheetUM.Icon.Type.Accent + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Accent } title = TextReference.Res(R.string.tangem_pay_freeze_card_alert_title) body = TextReference.Res(R.string.tangem_pay_freeze_card_alert_body) @@ -75,12 +75,12 @@ internal object TangemPayMessagesFactory { } } - fun createUnfreezeCardMessage(onUnfreezeClicked: () -> Unit): BottomSheetMessageV2 { + fun createUnfreezeCardMessage(onUnfreezeClicked: () -> Unit): BottomSheetMessage { return bottomSheetMessage { infoBlock { icon(R.drawable.ic_snow_24) { - type = MessageBottomSheetUMV2.Icon.Type.Accent - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Accent + type = MessageBottomSheetUM.Icon.Type.Accent + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Accent } title = TextReference.Res(R.string.tangem_pay_unfreeze_card_alert_title) body = TextReference.Res(R.string.tangem_pay_unfreeze_card_alert_body) @@ -95,11 +95,11 @@ internal object TangemPayMessagesFactory { } } - fun createWithdrawWarning(onGotItClick: () -> Unit): BottomSheetMessageV2 { + fun createWithdrawWarning(onGotItClick: () -> Unit): BottomSheetMessage { return bottomSheetMessage { infoBlock { icon(R.drawable.img_attention_20) { - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Attention + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Attention } title = TextReference.Res(R.string.tangempay_withdrawal_note_title) body = TextReference.Res(R.string.tangempay_withdrawal_note_description) 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 f174c69a07..75dac67162 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 @@ -408,8 +408,8 @@ internal class WalletSettingsModel @Inject constructor( val message = bottomSheetMessage { infoBlock { icon(R.drawable.ic_passcode_lock_32) { - type = MessageBottomSheetUMV2.Icon.Type.Accent - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + type = MessageBottomSheetUM.Icon.Type.Accent + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint } title = resourceReference(R.string.hw_backup_need_finish_first) body = resourceReference(R.string.hw_backup_to_secure_description) @@ -509,8 +509,8 @@ internal class WalletSettingsModel @Inject constructor( bottomSheetMessage { infoBlock { icon(R.drawable.ic_alert_circle_24) { - type = MessageBottomSheetUMV2.Icon.Type.Warning - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + type = MessageBottomSheetUM.Icon.Type.Warning + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint } title = resourceReference(R.string.hw_remove_wallet_notification_title) body = if (userWallet.backedUp) { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt index 0c3e58467e..473bcd8df2 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt @@ -190,8 +190,8 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( val issuingBottomSheet = bottomSheetMessage { infoBlock { icon(com.tangem.core.ui.R.drawable.ic_clock_24) { - type = MessageBottomSheetUMV2.Icon.Type.Informative - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Informative + type = MessageBottomSheetUM.Icon.Type.Informative + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Informative } title = resourceReference(R.string.tangempay_issuing_your_card) body = resourceReference(R.string.tangempay_issuing_your_card_description) @@ -209,8 +209,8 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( val issuingBottomSheet = bottomSheetMessage { infoBlock { icon(com.tangem.core.ui.R.drawable.ic_alert_24) { - type = MessageBottomSheetUMV2.Icon.Type.Warning - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Warning + type = MessageBottomSheetUM.Icon.Type.Warning + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Warning } title = resourceReference(R.string.tangempay_failed_to_issue_card) body = resourceReference(R.string.tangempay_failed_to_issue_card_support_description) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsSingleEventSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsSingleEventSender.kt index fdcdc567b2..ee14b94350 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsSingleEventSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsSingleEventSender.kt @@ -114,8 +114,8 @@ internal class WalletWarningsSingleEventSender @Inject constructor( val message = bottomSheetMessage { infoBlock { icon(R.drawable.img_knight_shield_32) { - type = MessageBottomSheetUMV2.Icon.Type.Warning - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + type = MessageBottomSheetUM.Icon.Type.Warning + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint } title = resourceReference(R.string.hw_activation_need_title) body = resourceReference(R.string.hw_activation_need_description) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedComponent.kt index 5cccb5d97a..1613ad76a5 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedComponent.kt @@ -5,7 +5,7 @@ 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.core.ui.components.bottomsheets.message.MessageBottomSheetV2 +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.models.wallet.UserWalletId import dagger.assisted.Assisted @@ -26,7 +26,7 @@ internal class KycRejectedComponent @AssistedInject constructor( @Composable override fun BottomSheet() { val state by model.uiState.collectAsStateWithLifecycle() - MessageBottomSheetV2(state = state, onDismissRequest = ::dismiss) + MessageBottomSheet(state = state, onDismissRequest = ::dismiss) } data class Params( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedModel.kt index 40e010fd56..9f9da8be1a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedModel.kt @@ -27,15 +27,15 @@ internal class KycRejectedModel @Inject constructor( private val params = paramsContainer.require() - val uiState: StateFlow + val uiState: StateFlow field = MutableStateFlow(getInitialState()) - private fun getInitialState(): MessageBottomSheetUMV2 { + private fun getInitialState(): MessageBottomSheetUM { return bottomSheetMessage { infoBlock { icon(com.tangem.core.ui.R.drawable.ic_heart_broken_32) { - type = MessageBottomSheetUMV2.Icon.Type.Warning - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Warning + type = MessageBottomSheetUM.Icon.Type.Warning + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Warning } title = resourceReference(R.string.tangempay_kyc_rejected) body = combinedReference( @@ -68,7 +68,7 @@ internal class KycRejectedModel @Inject constructor( onDismiss() } } - }.messageBottomSheetUMV2 + }.messageBottomSheetUM } fun onDismiss() { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/AlertsComponentV2.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/AlertsComponentV2.kt index db7a6f0f00..e9ebf31720 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/AlertsComponentV2.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/AlertsComponentV2.kt @@ -2,13 +2,13 @@ package com.tangem.features.walletconnect.connections.components import androidx.compose.runtime.Composable import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetV2 +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet import com.tangem.core.ui.decompose.ComposableBottomSheetComponent internal class AlertsComponentV2( appComponentContext: AppComponentContext, - private val messageUM: MessageBottomSheetUMV2, + private val messageUM: MessageBottomSheetUM, ) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { override fun dismiss() { @@ -18,6 +18,6 @@ internal class AlertsComponentV2( @Composable override fun BottomSheet() { - MessageBottomSheetV2(state = messageUM, onDismissRequest = ::dismiss) + MessageBottomSheet(state = messageUM, onDismissRequest = ::dismiss) } } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt index 1675ff7fba..23ed261f7c 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt @@ -12,7 +12,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerRouter -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.wallet.UserWalletId @@ -112,7 +112,7 @@ internal class WcPairComponent( } } - private fun createBottomSheetMessageUM(alertType: Alert): MessageBottomSheetUMV2 { + private fun createBottomSheetMessageUM(alertType: Alert): MessageBottomSheetUM { return when (alertType) { is Alert.Verified -> WcAlertsFactory.createVerifiedDomainAlert(alertType.appName) is Alert.UnknownDomain -> WcAlertsFactory.createUnknownDomainAlert(model::connectFromAlert) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcAlertsFactory.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcAlertsFactory.kt index 717b6d7a5f..3c797904a7 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcAlertsFactory.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcAlertsFactory.kt @@ -2,7 +2,7 @@ package com.tangem.features.walletconnect.connections.utils import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.message.* -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2.Icon.Type +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM.Icon.Type import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList @@ -23,16 +23,16 @@ internal object WcAlertsFactory { createUnknownErrorAlert(alertType.errorMessage, alertType.onDismiss, alertType.onRetry) } - fun createUnknownDomainAlert(activeButtonOnClick: (() -> Unit)? = null): MessageBottomSheetUMV2 { + fun createUnknownDomainAlert(activeButtonOnClick: (() -> Unit)? = null): MessageBottomSheetUM { return messageBottomSheetUM { infoBlock { icon(R.drawable.img_knight_shield_32) { - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Attention + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Attention } title = resourceReference(R.string.security_alert_title) body = resourceReference(R.string.wc_alert_domain_issues_description) chip(resourceReference(R.string.wc_alert_audit_unknown_domain)) { - type = MessageBottomSheetUMV2.Chip.Type.Unspecified + type = MessageBottomSheetUM.Chip.Type.Unspecified } } primaryButton { @@ -48,12 +48,12 @@ internal object WcAlertsFactory { } } - fun createInvalidDomainAlert(onDismiss: () -> Unit): MessageBottomSheetUMV2 { + fun createInvalidDomainAlert(onDismiss: () -> Unit): MessageBottomSheetUM { return messageBottomSheetUM { infoBlock { icon(R.drawable.ic_wallet_connect_24) { type = Type.Informative - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint } title = resourceReference(R.string.wc_errors_invalid_domain_title) body = resourceReference(R.string.wc_errors_invalid_domain_subtitle) @@ -66,17 +66,17 @@ internal object WcAlertsFactory { } } - fun createUnsafeDomainAlert(activeButtonOnClick: (() -> Unit)? = null): MessageBottomSheetUMV2 { + fun createUnsafeDomainAlert(activeButtonOnClick: (() -> Unit)? = null): MessageBottomSheetUM { return messageBottomSheetUM { infoBlock { icon(R.drawable.img_knight_shield_32) { type = Type.Warning - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Warning + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Warning } title = resourceReference(R.string.security_alert_title) body = resourceReference(R.string.wc_alert_domain_issues_description) chip(resourceReference(R.string.wc_alert_audit_malicious_domain)) { - type = MessageBottomSheetUMV2.Chip.Type.Warning + type = MessageBottomSheetUM.Chip.Type.Warning } } primaryButton { @@ -92,12 +92,12 @@ internal object WcAlertsFactory { } } - fun createUnsupportedDomainAlert(appName: String, onDismiss: () -> Unit): MessageBottomSheetUMV2 { + fun createUnsupportedDomainAlert(appName: String, onDismiss: () -> Unit): MessageBottomSheetUM { return messageBottomSheetUM { infoBlock { icon(R.drawable.ic_wallet_connect_24) { type = Type.Informative - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint } title = resourceReference(R.string.wc_alert_unsupported_dapps_title) body = resourceReference(R.string.wc_alert_unsupported_dapps_description, wrappedList(appName)) @@ -110,12 +110,12 @@ internal object WcAlertsFactory { } } - fun createUriAlreadyUsedAlert(onDismiss: () -> Unit): MessageBottomSheetUMV2 { + fun createUriAlreadyUsedAlert(onDismiss: () -> Unit): MessageBottomSheetUM { return messageBottomSheetUM { infoBlock { icon(R.drawable.ic_wallet_connect_24) { type = Type.Informative - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint } title = resourceReference(R.string.wc_uri_already_used_title) body = resourceReference(R.string.wc_uri_already_used_description) @@ -128,12 +128,12 @@ internal object WcAlertsFactory { } } - fun createTimeoutExceptionAlert(onDismiss: () -> Unit): MessageBottomSheetUMV2 { + fun createTimeoutExceptionAlert(onDismiss: () -> Unit): MessageBottomSheetUM { return messageBottomSheetUM { infoBlock { icon(R.drawable.ic_wallet_connect_24) { type = Type.Informative - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint } title = resourceReference(R.string.wc_alert_request_timeout_title) body = resourceReference(R.string.wc_alert_request_timeout_description) @@ -145,12 +145,12 @@ internal object WcAlertsFactory { } } - fun createUnsupportedChainAlert(appName: String, onDismiss: () -> Unit): MessageBottomSheetUMV2 { + fun createUnsupportedChainAlert(appName: String, onDismiss: () -> Unit): MessageBottomSheetUM { return messageBottomSheetUM { infoBlock { icon(R.drawable.ic_network_new_24) { type = Type.Informative - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint } title = resourceReference(R.string.wc_alert_unsupported_networks_title) body = resourceReference(R.string.wc_alert_unsupported_networks_description, wrappedList(appName)) @@ -167,11 +167,11 @@ internal object WcAlertsFactory { errorMessage: String?, onDismiss: () -> Unit, onRetry: () -> Unit, - ): MessageBottomSheetUMV2 { + ): MessageBottomSheetUM { return messageBottomSheetUM { infoBlock { icon(R.drawable.img_attention_20) { - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Attention + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Attention } title = resourceReference(R.string.wc_alert_unknown_error_title) body = if (errorMessage.isNullOrEmpty()) { @@ -199,8 +199,8 @@ internal object WcAlertsFactory { description: String?, activeButtonOnClick: (() -> Unit), iconType: Type, - iconBgType: MessageBottomSheetUMV2.Icon.BackgroundType, - ): MessageBottomSheetUMV2 { + iconBgType: MessageBottomSheetUM.Icon.BackgroundType, + ): MessageBottomSheetUM { return messageBottomSheetUM { infoBlock { icon(R.drawable.img_knight_shield_32) { @@ -223,11 +223,11 @@ internal object WcAlertsFactory { } } - fun createVerifiedDomainAlert(appName: String): MessageBottomSheetUMV2 { + fun createVerifiedDomainAlert(appName: String): MessageBottomSheetUM { return messageBottomSheetUM { infoBlock { icon(R.drawable.img_approvale2_20) { - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Accent + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Accent } title = resourceReference(R.string.wc_alert_verified_domain_title) body = resourceReference(R.string.wc_alert_verified_domain_description, wrappedList(appName)) 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 5153c887a2..b6c43240ab 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 @@ -20,8 +20,8 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2.Icon.Type +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM.Icon.Type import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier @@ -377,7 +377,7 @@ internal class WcSendTransactionModel @Inject constructor( description = description, onClick = ::signFromAlert, iconType = Type.Warning, - iconBgType = MessageBottomSheetUMV2.Icon.BackgroundType.Warning, + iconBgType = MessageBottomSheetUM.Icon.BackgroundType.Warning, ) stackNavigation.pushNew(WcTransactionRoutes.Alert(type)) } @@ -387,7 +387,7 @@ internal class WcSendTransactionModel @Inject constructor( description = description, onClick = ::signFromAlert, iconType = Type.Attention, - iconBgType = MessageBottomSheetUMV2.Icon.BackgroundType.Attention, + iconBgType = MessageBottomSheetUM.Icon.BackgroundType.Attention, ) stackNavigation.pushNew(WcTransactionRoutes.Alert(type)) } 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 360e5e1ee7..eeea91b22e 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 @@ -3,7 +3,7 @@ package com.tangem.features.walletconnect.transaction.routes import androidx.compose.runtime.Immutable import com.tangem.core.decompose.navigation.Route import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM import kotlinx.serialization.Serializable @Serializable @@ -31,8 +31,8 @@ internal sealed class WcTransactionRoutes : TangemBottomSheetConfigContent, Rout data class BlockAidErrorInfo( val description: String?, val onClick: () -> Unit, - val iconType: MessageBottomSheetUMV2.Icon.Type, - val iconBgType: MessageBottomSheetUMV2.Icon.BackgroundType, + val iconType: MessageBottomSheetUM.Icon.Type, + val iconBgType: MessageBottomSheetUM.Icon.BackgroundType, ) : Type() data class UnknownError( From 3b8da73b9c530e631b118449a53b002cdb345cb6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 13 Mar 2026 12:58:22 +0400 Subject: [PATCH 42/60] Updated on 2026-08-14 --- .../tap/di/domain/TokensDomainModule.kt | 15 +- .../AccountListCryptoCurrenciesFetcher.kt | 28 +- ...=> MultiWalletAccountListFetcherModule.kt} | 11 +- .../tangem/data/tokens/di/TokensDataModule.kt | 9 - .../repository/DefaultCurrenciesRepository.kt | 138 ------- .../AccountListCryptoCurrenciesFetcherTest.kt | 14 +- domain/tokens/build.gradle.kts | 4 +- ...er.kt => MultiWalletAccountListFetcher.kt} | 4 +- .../tokens/repository/CurrenciesRepository.kt | 49 --- .../tokens/wallet/BaseWalletBalanceFetcher.kt | 6 +- .../tokens/wallet/WalletBalanceFetcher.kt | 65 +++- .../implementor/MultiWalletBalanceFetcher.kt | 16 +- .../implementor/SingleWalletBalanceFetcher.kt | 20 +- .../SingleWalletWithTokenBalanceFetcher.kt | 20 +- .../tokens/wallet/WalletBalanceFetcherTest.kt | 337 ++++++++++++------ .../MultiWalletBalanceFetcherTest.kt | 24 +- .../SingleWalletBalanceFetcherTest.kt | 23 +- ...SingleWalletWithTokenBalanceFetcherTest.kt | 23 +- 18 files changed, 373 insertions(+), 433 deletions(-) rename data/tokens/src/main/kotlin/com/tangem/data/tokens/di/{MultiWalletCryptoCurrenciesFetcherModule.kt => MultiWalletAccountListFetcherModule.kt} (70%) rename domain/tokens/src/main/kotlin/com/tangem/domain/tokens/{MultiWalletCryptoCurrenciesFetcher.kt => MultiWalletAccountListFetcher.kt} (54%) 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 f4503ac3d3..d9ec3d207e 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 @@ -1,7 +1,10 @@ package com.tangem.tap.di.domain import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.repository.NetworksRepository import com.tangem.domain.networks.single.SingleNetworkStatusFetcher @@ -148,8 +151,10 @@ internal object TokensDomainModule { @Provides @Singleton fun provideWalletBalanceFetcher( - currenciesRepository: CurrenciesRepository, - multiWalletCryptoCurrenciesFetcher: MultiWalletCryptoCurrenciesFetcher, + userWalletsListRepository: UserWalletsListRepository, + cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, + expressServiceFetcher: ExpressServiceFetcher, + multiWalletAccountListFetcher: MultiWalletAccountListFetcher, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, multiQuoteStatusFetcher: MultiQuoteStatusFetcher, @@ -159,8 +164,10 @@ internal object TokensDomainModule { dispatchers: CoroutineDispatcherProvider, ): WalletBalanceFetcher { return WalletBalanceFetcher( - currenciesRepository = currenciesRepository, - multiWalletCryptoCurrenciesFetcher = multiWalletCryptoCurrenciesFetcher, + userWalletsListRepository = userWalletsListRepository, + cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + expressServiceFetcher = expressServiceFetcher, + multiWalletAccountListFetcher = multiWalletAccountListFetcher, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, multiNetworkStatusFetcher = multiNetworkStatusFetcher, multiQuoteStatusFetcher = multiQuoteStatusFetcher, diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcher.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcher.kt index 642add41ce..1e6ad0b644 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcher.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcher.kt @@ -1,35 +1,30 @@ package com.tangem.data.tokens import arrow.core.Either -import arrow.core.right import com.tangem.data.common.account.WalletAccountsFetcher -import com.tangem.datasource.api.tangemTech.models.account.flattenTokens import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.core.utils.catchOn -import com.tangem.domain.express.ExpressServiceFetcher -import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.wallet.isMultiCurrency -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher.Params +import com.tangem.domain.tokens.MultiWalletAccountListFetcher +import com.tangem.domain.tokens.MultiWalletAccountListFetcher.Params import com.tangem.utils.coroutines.CoroutineDispatcherProvider /** - * Implementation of [MultiWalletCryptoCurrenciesFetcher] that fetches crypto currencies of all accounts + * Implementation of [MultiWalletAccountListFetcher] that fetches the account list for a multi-currency wallet + * by delegating to [WalletAccountsFetcher]. * * @property userWalletsListRepository repository to get user wallets * @property walletAccountsFetcher instance of [WalletAccountsFetcher] to fetch accounts for a multi wallet - * @property expressServiceFetcher fetcher of express service - * @property dispatchers dispatchers + * @property dispatchers provider for coroutine dispatchers used to run fetch operations * [REDACTED_AUTHOR] */ internal class AccountListCryptoCurrenciesFetcher( private val userWalletsListRepository: UserWalletsListRepository, private val walletAccountsFetcher: WalletAccountsFetcher, - private val expressServiceFetcher: ExpressServiceFetcher, private val dispatchers: CoroutineDispatcherProvider, -) : MultiWalletCryptoCurrenciesFetcher { +) : MultiWalletAccountListFetcher { override suspend fun invoke(params: Params): Either { return Either.catchOn(dispatchers.default) { @@ -37,16 +32,7 @@ internal class AccountListCryptoCurrenciesFetcher( if (!userWallet.isMultiCurrency) error("${this::class.simpleName} supports only multi-currency wallet") - val response = walletAccountsFetcher.fetch(userWalletId = params.userWalletId) - - expressServiceFetcher.fetch( - userWallet = userWallet, - assetIds = response.flattenTokens().mapTo(hashSetOf()) { - ExpressAsset.ID(networkId = it.networkId, contractAddress = it.contractAddress) - }, - ) - - Unit.right() + walletAccountsFetcher.fetch(userWalletId = params.userWalletId) } } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesFetcherModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletAccountListFetcherModule.kt similarity index 70% rename from data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesFetcherModule.kt rename to data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletAccountListFetcherModule.kt index 68065f148b..68136d2160 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesFetcherModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletAccountListFetcherModule.kt @@ -3,8 +3,7 @@ package com.tangem.data.tokens.di import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.tokens.AccountListCryptoCurrenciesFetcher import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.express.ExpressServiceFetcher -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher +import com.tangem.domain.tokens.MultiWalletAccountListFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -14,20 +13,18 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal class MultiWalletCryptoCurrenciesFetcherModule { +internal class MultiWalletAccountListFetcherModule { @Singleton @Provides - fun provideMultiWalletCryptoCurrenciesFetcher( + fun provideMultiWalletAccountListFetcher( userWalletsListRepository: UserWalletsListRepository, walletAccountsFetcher: WalletAccountsFetcher, - expressServiceFetcher: ExpressServiceFetcher, dispatchers: CoroutineDispatcherProvider, - ): MultiWalletCryptoCurrenciesFetcher { + ): MultiWalletAccountListFetcher { return AccountListCryptoCurrenciesFetcher( userWalletsListRepository = userWalletsListRepository, walletAccountsFetcher = walletAccountsFetcher, - expressServiceFetcher = expressServiceFetcher, dispatchers = dispatchers, ) } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt index 6a9b6ec68a..9165cfa1b2 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt @@ -1,8 +1,6 @@ package com.tangem.data.tokens.di import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.data.common.cache.CacheRegistry -import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.tokens.repository.DefaultCurrenciesRepository import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository import com.tangem.data.tokens.repository.DefaultTokenReceiveWarningsViewedRepository @@ -11,7 +9,6 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.TokenReceiveWarningActionStore import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository @@ -34,21 +31,15 @@ internal object TokensDataModule { tangemTechApi: TangemTechApi, userWalletsListRepository: UserWalletsListRepository, walletManagersFacade: WalletManagersFacade, - cacheRegistry: CacheRegistry, dispatchers: CoroutineDispatcherProvider, - expressServiceFetcher: ExpressServiceFetcher, excludedBlockchains: ExcludedBlockchains, - cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, ): CurrenciesRepository { return DefaultCurrenciesRepository( tangemTechApi = tangemTechApi, userWalletsListRepository = userWalletsListRepository, walletManagersFacade = walletManagersFacade, - cacheRegistry = cacheRegistry, - expressServiceFetcher = expressServiceFetcher, dispatchers = dispatchers, excludedBlockchains = excludedBlockchains, - cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, ) } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index e61f2b466d..077f772d62 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -5,119 +5,33 @@ import com.tangem.blockchain.common.TransactionStatus import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.toBlockchain -import com.tangem.data.common.cache.CacheRegistry -import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.data.common.currency.getTokenId import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.domain.card.CardTypesResolver -import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.getSyncStrict -import com.tangem.domain.core.error.DataError -import com.tangem.domain.express.ExpressServiceFetcher -import com.tangem.domain.express.models.ExpressAsset 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.isMultiCurrency -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import timber.log.Timber import com.tangem.blockchain.common.FeePaidCurrency as FeePaidSdkCurrency -@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") internal class DefaultCurrenciesRepository( private val tangemTechApi: TangemTechApi, private val userWalletsListRepository: UserWalletsListRepository, private val walletManagersFacade: WalletManagersFacade, - private val cacheRegistry: CacheRegistry, - private val expressServiceFetcher: ExpressServiceFetcher, private val dispatchers: CoroutineDispatcherProvider, - private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, excludedBlockchains: ExcludedBlockchains, ) : CurrenciesRepository { private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains) - override suspend fun getSingleCurrencyWalletPrimaryCurrency( - userWalletId: UserWalletId, - refresh: Boolean, - ): CryptoCurrency { - return withContext(dispatchers.io) { - val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) - userWallet.requireColdWallet() - ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = false) - - val currency = cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard( - userWallet = userWallet, - ) - - fetchExpressAssetsByNetworkIds( - userWallet = userWallet, - cryptoCurrencies = listOf(currency), - refresh = refresh, - ) - - currency - } - } - - override suspend fun getSingleCurrencyWalletWithCardCurrencies( - userWalletId: UserWalletId, - refresh: Boolean, - ): List { - return withContext(dispatchers.io) { - val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) - val scanResponse = userWallet.requireColdWallet().scanResponse - - val currencies = if (scanResponse.cardTypesResolver.isSingleWalletWithToken()) { - cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = userWallet) - } else { - cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet = userWallet) - .run { - listOf(this) - } - } - - fetchExpressAssetsByNetworkIds( - userWallet = userWallet, - cryptoCurrencies = currencies, - refresh = refresh, - ) - - currencies - } - } - - override suspend fun getSingleCurrencyWalletWithCardCurrency( - userWalletId: UserWalletId, - id: CryptoCurrency.ID, - ): CryptoCurrency { - return withContext(dispatchers.io) { - val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) - userWallet.requireColdWallet() - ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = false) - - val currency = cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken( - userWallet = userWallet, - ) - .find { it.id == id } - requireNotNull(currency) { "Unable to find currency with provided ID: $id" } - fetchExpressAssetsByNetworkIds(userWallet, listOf(currency)) - currency - } - } - override suspend fun isSendBlockedByPendingTransactions( userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, @@ -223,56 +137,4 @@ internal class DefaultCurrenciesRepository( val blockchain = Blockchain.fromNetworkId(network.backendId) return blockchain?.isNetworkFeeZero() == true } - - override fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver? { - return (userWalletsListRepository.getSyncStrict(userWalletId) as? UserWallet.Cold)?.cardTypesResolver - } - - private suspend fun fetchExpressAssetsByNetworkIds( - userWallet: UserWallet, - cryptoCurrencies: List, - refresh: Boolean = false, - ) { - val tokens = cryptoCurrencies.mapTo(hashSetOf()) { currency -> - val tokenCurrency = currency as? CryptoCurrency.Token - ExpressAsset.ID( - networkId = currency.network.backendId, - contractAddress = tokenCurrency?.contractAddress, - ) - } - cacheRegistry.invokeOnExpire( - key = getAssetsCacheKey(userWallet.walletId), - skipCache = refresh, - block = { - coroutineScope { - launch { expressServiceFetcher.fetch(userWallet, tokens) } - } - }, - ) - } - - private fun getAssetsCacheKey(userWalletId: UserWalletId): String = "assets_cache_key_${userWalletId.stringValue}" - - private fun ensureIsCorrectUserWallet(userWallet: UserWallet, isMultiCurrencyWalletExpected: Boolean) { - val userWalletId = userWallet.walletId - - val message = when { - !userWallet.isMultiCurrency && isMultiCurrencyWalletExpected -> { - "Multi currency wallet expected, but single currency wallet was found: $userWalletId" - } - - userWallet.isMultiCurrency && !isMultiCurrencyWalletExpected -> { - "Single currency wallet expected, but multi currency wallet was found: $userWalletId" - } - - else -> null - } - - if (message != null) { - val error = DataError.UserWalletError.WrongUserWallet(message) - - Timber.e(error) - throw error - } - } } \ No newline at end of file diff --git a/data/tokens/src/test/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcherTest.kt b/data/tokens/src/test/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcherTest.kt index 3f95f270ca..ccd7edd6ae 100644 --- a/data/tokens/src/test/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcherTest.kt +++ b/data/tokens/src/test/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcherTest.kt @@ -1,15 +1,13 @@ package com.tangem.data.tokens import arrow.core.left -import arrow.core.right import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.express.ExpressServiceFetcher 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.MultiWalletCryptoCurrenciesFetcher +import com.tangem.domain.tokens.MultiWalletAccountListFetcher import com.tangem.test.core.assertEither import com.tangem.test.core.assertEitherRight import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider @@ -25,13 +23,11 @@ internal class AccountListCryptoCurrenciesFetcherTest { private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxUnitFun = true) private val walletAccountsFetcher: WalletAccountsFetcher = mockk(relaxUnitFun = true) - private val expressServiceFetcher: ExpressServiceFetcher = mockk() private val dispatchers = TestingCoroutineDispatcherProvider() private val fetcher = AccountListCryptoCurrenciesFetcher( userWalletsListRepository = userWalletsListRepository, walletAccountsFetcher = walletAccountsFetcher, - expressServiceFetcher = expressServiceFetcher, dispatchers = dispatchers, ) @@ -43,7 +39,7 @@ internal class AccountListCryptoCurrenciesFetcherTest { @Test fun `returns failure if wallet is not multi-currency`() = runTest { // Arrange - val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId) + val params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId) val mockUserWallet = mockk { every { walletId } returns userWalletId every { isMultiCurrency } returns false @@ -68,7 +64,7 @@ internal class AccountListCryptoCurrenciesFetcherTest { @Test fun `returns accounts if wallet is multi-currency`() = runTest { // Arrange - val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId) + val params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId) val mockUserWallet = mockk { every { walletId } returns userWalletId every { isMultiCurrency } returns true @@ -79,7 +75,6 @@ internal class AccountListCryptoCurrenciesFetcherTest { every { userWalletsListRepository.userWallets } returns userWalletsFlow coEvery { walletAccountsFetcher.fetch(userWalletId = params.userWalletId) } returns response - coEvery { expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = emptySet()) } returns Unit.right() // Act val actual = fetcher(params) @@ -90,14 +85,13 @@ internal class AccountListCryptoCurrenciesFetcherTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsListRepository.userWallets walletAccountsFetcher.fetch(userWalletId = params.userWalletId) - expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = emptySet()) } } @Test fun `returns error if walletAccountsFetcher returns error`() = runTest { // Arrange - val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId) + val params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId) val mockUserWallet = mockk { every { walletId } returns userWalletId every { isMultiCurrency } returns true diff --git a/domain/tokens/build.gradle.kts b/domain/tokens/build.gradle.kts index 53e21909a4..52ba7c414e 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -16,10 +16,12 @@ dependencies { /** Project - Domain */ api(projects.domain.core) + implementation(projects.domain.common) + implementation(projects.domain.card) + implementation(projects.domain.express) implementation(projects.domain.models) implementation(projects.domain.legacy) implementation(projects.domain.walletManager) - implementation(projects.domain.card) implementation(projects.domain.staking) implementation(projects.domain.visa) implementation(projects.libs.blockchainSdk) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletCryptoCurrenciesFetcher.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletAccountListFetcher.kt similarity index 54% rename from domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletCryptoCurrenciesFetcher.kt rename to domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletAccountListFetcher.kt index ac4dbf6f25..528707152c 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletCryptoCurrenciesFetcher.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletAccountListFetcher.kt @@ -4,11 +4,11 @@ import com.tangem.domain.core.flow.FlowFetcher import com.tangem.domain.models.wallet.UserWalletId /** - * Fetcher of crypto currencies for a multi-currency wallet with [UserWalletId] + * Fetcher of account list for a multi-currency wallet with [UserWalletId] * [REDACTED_AUTHOR] */ -interface MultiWalletCryptoCurrenciesFetcher : FlowFetcher { +interface MultiWalletAccountListFetcher : FlowFetcher { data class Params(val userWalletId: UserWalletId) } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt index 53f4a03da9..ba37257e3b 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt @@ -1,7 +1,5 @@ package com.tangem.domain.tokens.repository -import com.tangem.domain.card.CardTypesResolver -import com.tangem.domain.core.error.DataError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -11,52 +9,8 @@ import com.tangem.domain.tokens.model.FeePaidCurrency /** * Repository for everything related to the tokens of user wallet * */ -@Suppress("TooManyFunctions") interface CurrenciesRepository { - /** - * Retrieves the primary cryptocurrency for a specific single-currency user wallet. - * - * @param userWalletId The unique identifier of the user wallet. - * @param refresh Indicates whether to force a refresh of the status data. - * @return The primary cryptocurrency associated with the user wallet. - * @throws DataError.UserWalletError.WrongUserWallet If multi-currency user wallet - * ID provided. - */ - suspend fun getSingleCurrencyWalletPrimaryCurrency( - userWalletId: UserWalletId, - refresh: Boolean = false, - ): CryptoCurrency - - /** - * Retrieves the cryptocurrencies for a specific single-currency user wallet with tokens on the card. - * - * @param userWalletId The unique identifier of the user wallet. - * @param refresh Indicates whether to force a refresh of the status data. - * @return The primary cryptocurrency associated with the user wallet. - * @throws DataError.UserWalletError.WrongUserWallet If multi-currency user wallet - * ID provided. - */ - suspend fun getSingleCurrencyWalletWithCardCurrencies( - userWalletId: UserWalletId, - refresh: Boolean = false, - ): List - - /** - * Retrieves the cryptocurrency for a specific single-currency user old wallet - * that stores token on card - * - * @param userWalletId The unique identifier of the user wallet. - * @param id The unique identifier of the cryptocurrency to be retrieved. - * @return The cryptocurrency associated with the user wallet and ID. - * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet - * ID provided. - */ - suspend fun getSingleCurrencyWalletWithCardCurrency( - userWalletId: UserWalletId, - id: CryptoCurrency.ID, - ): CryptoCurrency - /** * Determines whether the currency sending is blocked by network pending transaction * @@ -88,7 +42,4 @@ interface CurrenciesRepository { ): CryptoCurrency.Token fun isNetworkFeeZero(userWalletId: UserWalletId, network: Network): Boolean - - @Throws - fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver? } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/BaseWalletBalanceFetcher.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/BaseWalletBalanceFetcher.kt index 89f51b885b..c9fd602d9b 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/BaseWalletBalanceFetcher.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/BaseWalletBalanceFetcher.kt @@ -1,7 +1,7 @@ package com.tangem.domain.tokens.wallet import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.UserWallet /** * Base contract for implementation of wallet's balance fetcher @@ -13,6 +13,6 @@ internal interface BaseWalletBalanceFetcher { /** Fetching sources */ val fetchingSources: Set - /** Get crypto currencies of wallet with [userWalletId] */ - suspend fun getCryptoCurrencies(userWalletId: UserWalletId): Set + /** Get crypto currencies of [userWallet] */ + suspend fun getCryptoCurrencies(userWallet: UserWallet): Set } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt index 17b46927d6..51ea9c7ea3 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt @@ -3,18 +3,24 @@ package com.tangem.domain.tokens.wallet import arrow.core.Either import arrow.core.raise.either import arrow.core.right +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.core.flow.FlowFetcher import com.tangem.domain.core.utils.catchOn +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher +import com.tangem.domain.tokens.MultiWalletAccountListFetcher import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.wallet.implementor.MultiWalletBalanceFetcher import com.tangem.domain.tokens.wallet.implementor.SingleWalletBalanceFetcher import com.tangem.domain.tokens.wallet.implementor.SingleWalletWithTokenBalanceFetcher @@ -27,7 +33,8 @@ import timber.log.Timber /** * Fetcher of wallet balance by [UserWalletId] * - * @property currenciesRepository currencies repository + * @property userWalletsListRepository user wallets list repository + * @property expressServiceFetcher express service fetcher * @property multiWalletBalanceFetcher balance fetcher of multi-currency wallet * @property singleWalletWithTokenBalanceFetcher balance fetcher of single-currency wallet with token * @property singleWalletBalanceFetcher balance fetcher of single-currency wallet @@ -40,7 +47,8 @@ import timber.log.Timber */ @Suppress("LongParameterList") class WalletBalanceFetcher internal constructor( - private val currenciesRepository: CurrenciesRepository, + private val userWalletsListRepository: UserWalletsListRepository, + private val expressServiceFetcher: ExpressServiceFetcher, private val multiWalletBalanceFetcher: BaseWalletBalanceFetcher, private val singleWalletWithTokenBalanceFetcher: BaseWalletBalanceFetcher, private val singleWalletBalanceFetcher: BaseWalletBalanceFetcher, @@ -54,8 +62,10 @@ class WalletBalanceFetcher internal constructor( /** Additional constructor without internal dependencies */ constructor( - currenciesRepository: CurrenciesRepository, - multiWalletCryptoCurrenciesFetcher: MultiWalletCryptoCurrenciesFetcher, + userWalletsListRepository: UserWalletsListRepository, + cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, + expressServiceFetcher: ExpressServiceFetcher, + multiWalletAccountListFetcher: MultiWalletAccountListFetcher, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, multiQuoteStatusFetcher: MultiQuoteStatusFetcher, @@ -64,15 +74,18 @@ class WalletBalanceFetcher internal constructor( stakingIdFactory: StakingIdFactory, dispatchers: CoroutineDispatcherProvider, ) : this( - currenciesRepository = currenciesRepository, + userWalletsListRepository = userWalletsListRepository, + expressServiceFetcher = expressServiceFetcher, multiWalletBalanceFetcher = MultiWalletBalanceFetcher( - multiWalletCryptoCurrenciesFetcher = multiWalletCryptoCurrenciesFetcher, + multiWalletAccountListFetcher = multiWalletAccountListFetcher, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, ), singleWalletWithTokenBalanceFetcher = SingleWalletWithTokenBalanceFetcher( - currenciesRepository = currenciesRepository, + cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + ), + singleWalletBalanceFetcher = SingleWalletBalanceFetcher( + cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, ), - singleWalletBalanceFetcher = SingleWalletBalanceFetcher(currenciesRepository = currenciesRepository), multiNetworkStatusFetcher = multiNetworkStatusFetcher, multiQuoteStatusFetcher = multiQuoteStatusFetcher, multiStakingBalanceFetcher = multiStakingBalanceFetcher, @@ -83,19 +96,27 @@ class WalletBalanceFetcher internal constructor( override suspend fun invoke(params: Params) = Either.catchOn(dispatchers.default) { val userWalletId = params.userWalletId - val cardTypesResolver = currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) - val fetcher = when { - cardTypesResolver == null || cardTypesResolver.isMultiwalletAllowed() -> multiWalletBalanceFetcher - cardTypesResolver.isSingleWalletWithToken() -> singleWalletWithTokenBalanceFetcher - cardTypesResolver.isSingleWallet() -> singleWalletBalanceFetcher - else -> error("Unknown type of wallet: $userWalletId") + val fetcher = when (userWallet) { + is UserWallet.Hot -> multiWalletBalanceFetcher + is UserWallet.Cold -> { + val cardTypesResolver = userWallet.cardTypesResolver + when { + cardTypesResolver.isMultiwalletAllowed() -> multiWalletBalanceFetcher + cardTypesResolver.isSingleWalletWithToken() -> singleWalletWithTokenBalanceFetcher + cardTypesResolver.isSingleWallet() -> singleWalletBalanceFetcher + else -> error("Unknown type of wallet: $userWalletId") + } + } } - val currencies = fetcher.getCryptoCurrencies(userWalletId = userWalletId).ifEmpty { + val currencies = fetcher.getCryptoCurrencies(userWallet = userWallet).ifEmpty { error("UserWallet doesn't contain crypto-currencies: $userWalletId") } + fetchExpressAssets(userWallet = userWallet, currencies = currencies) + fetcher.fetch( userWalletId = userWalletId, currencies = currencies, @@ -190,6 +211,16 @@ class WalletBalanceFetcher internal constructor( } } + private suspend fun fetchExpressAssets(userWallet: UserWallet, currencies: Set) { + val assetIds = currencies.mapTo(hashSetOf()) { currency -> + ExpressAsset.ID( + networkId = currency.network.backendId, + contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress, + ) + } + expressServiceFetcher.fetch(userWallet = userWallet, assetIds = assetIds) + } + private suspend fun fetchPaymentAccount( userWalletId: UserWalletId, paymentAccountRefactorEnabled: Boolean, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcher.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcher.kt index eaa142f3fb..adbd435398 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcher.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcher.kt @@ -1,25 +1,25 @@ package com.tangem.domain.tokens.wallet.implementor import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.MultiWalletAccountListFetcher import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.wallet.BaseWalletBalanceFetcher import com.tangem.domain.tokens.wallet.FetchingSource -import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.firstOrNull import timber.log.Timber /** * Implementation of [BaseWalletBalanceFetcher] for MULTI-CURRENCY wallet * - * @property multiWalletCryptoCurrenciesFetcher multi wallet fetcher of crypto currencies + * @property multiWalletAccountListFetcher multi wallet fetcher of crypto currencies * @property multiWalletCryptoCurrenciesSupplier multi wallet supplier of crypto currencies * [REDACTED_AUTHOR] */ internal class MultiWalletBalanceFetcher( - private val multiWalletCryptoCurrenciesFetcher: MultiWalletCryptoCurrenciesFetcher, + private val multiWalletAccountListFetcher: MultiWalletAccountListFetcher, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, ) : BaseWalletBalanceFetcher { @@ -30,9 +30,11 @@ internal class MultiWalletBalanceFetcher( FetchingSource.TANGEM_PAY, ) - override suspend fun getCryptoCurrencies(userWalletId: UserWalletId): Set { - multiWalletCryptoCurrenciesFetcher( - params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId), + override suspend fun getCryptoCurrencies(userWallet: UserWallet): Set { + val userWalletId = userWallet.walletId + + multiWalletAccountListFetcher( + params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId), ) .onLeft(Timber::e) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletBalanceFetcher.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletBalanceFetcher.kt index 1440a42560..9e31c0b078 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletBalanceFetcher.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletBalanceFetcher.kt @@ -1,20 +1,21 @@ package com.tangem.domain.tokens.wallet.implementor +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.tokens.wallet.BaseWalletBalanceFetcher import com.tangem.domain.tokens.wallet.FetchingSource -import com.tangem.domain.models.wallet.UserWalletId /** * Implementation of [BaseWalletBalanceFetcher] for SINGLE-CURRENCY wallet * - * @property currenciesRepository currencies repository + * @property cardCryptoCurrencyFactory card crypto currency factory * [REDACTED_AUTHOR] */ internal class SingleWalletBalanceFetcher( - private val currenciesRepository: CurrenciesRepository, + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, ) : BaseWalletBalanceFetcher { override val fetchingSources: Set = setOf( @@ -22,12 +23,13 @@ internal class SingleWalletBalanceFetcher( FetchingSource.QUOTE, ) - override suspend fun getCryptoCurrencies(userWalletId: UserWalletId): Set { - val primaryCurrency = currenciesRepository.getSingleCurrencyWalletPrimaryCurrency( - userWalletId = userWalletId, - refresh = true, + override suspend fun getCryptoCurrencies(userWallet: UserWallet): Set { + val coldWallet = userWallet.requireColdWallet() + + val currency = cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard( + userWallet = coldWallet, ) - return setOf(primaryCurrency) + return setOf(currency) } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletWithTokenBalanceFetcher.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletWithTokenBalanceFetcher.kt index 1e9f83d60e..9f40bad1c4 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletWithTokenBalanceFetcher.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletWithTokenBalanceFetcher.kt @@ -1,20 +1,21 @@ package com.tangem.domain.tokens.wallet.implementor +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.tokens.wallet.BaseWalletBalanceFetcher import com.tangem.domain.tokens.wallet.FetchingSource -import com.tangem.domain.models.wallet.UserWalletId /** * Implementation of [BaseWalletBalanceFetcher] for SINGLE-CURRENCY wallet WITH TOKEN (like, NODL) * - * @property currenciesRepository currencies repository + * @property cardCryptoCurrencyFactory card crypto currency factory * [REDACTED_AUTHOR] */ internal class SingleWalletWithTokenBalanceFetcher( - private val currenciesRepository: CurrenciesRepository, + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, ) : BaseWalletBalanceFetcher { override val fetchingSources: Set = setOf( @@ -22,10 +23,11 @@ internal class SingleWalletWithTokenBalanceFetcher( FetchingSource.QUOTE, ) - override suspend fun getCryptoCurrencies(userWalletId: UserWalletId): Set { - return currenciesRepository.getSingleCurrencyWalletWithCardCurrencies( - userWalletId = userWalletId, - refresh = true, - ).toSet() + override suspend fun getCryptoCurrencies(userWallet: UserWallet): Set { + val coldWallet = userWallet.requireColdWallet() + + val currencies = cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(coldWallet) + + return currencies.toSet() } } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt index 937aa0d32d..eed3947fb1 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt @@ -5,8 +5,13 @@ import arrow.core.left import arrow.core.right import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.domain.card.CardTypesResolver +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.getSyncStrict +import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.StakingID +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher @@ -14,7 +19,6 @@ import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.wallet.FetchingSource.* import com.tangem.domain.tokens.wallet.implementor.MultiWalletBalanceFetcher import com.tangem.domain.tokens.wallet.implementor.SingleWalletBalanceFetcher @@ -24,6 +28,7 @@ import com.tangem.test.core.assertEitherRight import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @@ -36,7 +41,8 @@ internal class WalletBalanceFetcherTest { private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() - private val currenciesRepository: CurrenciesRepository = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val expressServiceFetcher: ExpressServiceFetcher = mockk() private val multiWalletBalanceFetcher: MultiWalletBalanceFetcher = mockk() private val singleWalletWithTokenBalanceFetcher: SingleWalletWithTokenBalanceFetcher = mockk() private val singleWalletBalanceFetcher: SingleWalletBalanceFetcher = mockk() @@ -47,7 +53,8 @@ internal class WalletBalanceFetcherTest { private val stakingIdFactory: StakingIdFactory = mockk() private val fetcher = WalletBalanceFetcher( - currenciesRepository = currenciesRepository, + userWalletsListRepository = userWalletsListRepository, + expressServiceFetcher = expressServiceFetcher, multiWalletBalanceFetcher = multiWalletBalanceFetcher, singleWalletWithTokenBalanceFetcher = singleWalletWithTokenBalanceFetcher, singleWalletBalanceFetcher = singleWalletBalanceFetcher, @@ -62,7 +69,8 @@ internal class WalletBalanceFetcherTest { @BeforeEach fun resetMocks() { clearMocks( - currenciesRepository, + userWalletsListRepository, + expressServiceFetcher, multiWalletBalanceFetcher, singleWalletWithTokenBalanceFetcher, singleWalletBalanceFetcher, @@ -70,32 +78,39 @@ internal class WalletBalanceFetcherTest { multiQuoteStatusFetcher, multiStakingBalanceFetcher, ) + mockkStatic(UserWalletsListRepository::getSyncStrict) + } + + @AfterEach + fun tearDownStaticMocks() { + unmockkStatic(UserWalletsListRepository::getSyncStrict) } @Test - fun `fetch failure if getCardTypesResolver THROWS EXCEPTION`() = runTest { + fun `fetch failure if getSyncStrict THROWS EXCEPTION`() = runTest { // Arrange val exception = IllegalStateException("Error") - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } throws exception + every { userWalletsListRepository.getSyncStrict(userWalletId) } throws exception // Act val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert val expected = exception.left() assertEither(actual, expected) - verifyOrder { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } + verifyOrder { userWalletsListRepository.getSyncStrict(userWalletId) } coVerify(inverse = true) { - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) multiNetworkStatusFetcher(params = any()) multiQuoteStatusFetcher(params = any()) stakingIdFactory.create(userWalletId = any(), cryptoCurrency = any()) @@ -104,7 +119,7 @@ internal class WalletBalanceFetcherTest { } @Test - fun `fetch failure if getCardTypesResolver cannot resolve wallet type`() = runTest { + fun `fetch failure if cardTypesResolver cannot resolve wallet type`() = runTest { // Arrange val cardTypesResolver = mockk { every { isMultiwalletAllowed() } returns false @@ -112,26 +127,27 @@ internal class WalletBalanceFetcherTest { every { isSingleWallet() } returns false } - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver + mockColdWallet(cardTypesResolver) // Act val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert val expected = IllegalStateException("Unknown type of wallet: $userWalletId").left() assertEither(actual, expected) - verifyOrder { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } + verifyOrder { userWalletsListRepository.getSyncStrict(userWalletId) } coVerify(inverse = true) { - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) multiNetworkStatusFetcher(params = any()) multiQuoteStatusFetcher(params = any()) stakingIdFactory.create(userWalletId = any(), cryptoCurrency = any()) @@ -148,15 +164,15 @@ internal class WalletBalanceFetcherTest { val exception = IllegalStateException("Error") - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver - coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) } throws exception + mockColdWallet(cardTypesResolver) + coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } throws exception // Act val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert @@ -164,13 +180,14 @@ internal class WalletBalanceFetcherTest { assertEither(actual, expected) coVerifyOrder { - currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) + userWalletsListRepository.getSyncStrict(userWalletId) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } coVerify(inverse = true) { - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) multiNetworkStatusFetcher(params = any()) multiQuoteStatusFetcher(params = any()) stakingIdFactory.create(userWalletId = any(), cryptoCurrency = any()) @@ -185,15 +202,15 @@ internal class WalletBalanceFetcherTest { every { isMultiwalletAllowed() } returns true } - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver - coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) } returns emptySet() + mockColdWallet(cardTypesResolver) + coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } returns emptySet() // Act val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert @@ -201,13 +218,14 @@ internal class WalletBalanceFetcherTest { assertEither(actual, expected) coVerifyOrder { - currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) + userWalletsListRepository.getSyncStrict(userWalletId) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } coVerify(inverse = true) { - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) multiNetworkStatusFetcher(params = any()) multiQuoteStatusFetcher(params = any()) stakingIdFactory.create(userWalletId = any(), cryptoCurrency = any()) @@ -230,8 +248,9 @@ internal class WalletBalanceFetcherTest { val exception = IllegalStateException("Error") - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver - coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) } returns currencies + mockColdWallet(cardTypesResolver) + coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } returns currencies + coEvery { expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) } returns mockk() every { multiWalletBalanceFetcher.fetchingSources } returns setOf(NETWORK) coEvery { multiNetworkStatusFetcher(params = networkStatusFetcherParams) } returns exception.left() @@ -239,8 +258,8 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert @@ -251,15 +270,16 @@ internal class WalletBalanceFetcherTest { assertEither(actual, expected) coVerifyOrder { - currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) + userWalletsListRepository.getSyncStrict(userWalletId) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) multiWalletBalanceFetcher.fetchingSources multiNetworkStatusFetcher(params = networkStatusFetcherParams) } coVerify(inverse = true) { - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) multiQuoteStatusFetcher(params = any()) stakingIdFactory.create(userWalletId = any(), cryptoCurrency = any()) multiStakingBalanceFetcher(params = any()) @@ -281,8 +301,9 @@ internal class WalletBalanceFetcherTest { val exception = IllegalStateException("Error") - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver - coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) } returns currencies + mockColdWallet(cardTypesResolver) + coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } returns currencies + coEvery { expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) } returns mockk() every { multiWalletBalanceFetcher.fetchingSources } returns setOf(QUOTE) coEvery { multiQuoteStatusFetcher(params = quoteStatusFetcherParams) } returns exception.left() @@ -290,8 +311,8 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert @@ -302,15 +323,16 @@ internal class WalletBalanceFetcherTest { assertEither(actual, expected) coVerifyOrder { - currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) + userWalletsListRepository.getSyncStrict(userWalletId) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) multiWalletBalanceFetcher.fetchingSources multiQuoteStatusFetcher(params = quoteStatusFetcherParams) } coVerify(inverse = true) { - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) multiNetworkStatusFetcher(params = any()) stakingIdFactory.create(userWalletId = any(), cryptoCurrency = any()) multiStakingBalanceFetcher(params = any()) @@ -332,8 +354,9 @@ internal class WalletBalanceFetcherTest { val exception = IllegalStateException("Error") - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver - coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) } returns currencies + mockColdWallet(cardTypesResolver) + coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } returns currencies + coEvery { expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) } returns mockk() every { multiWalletBalanceFetcher.fetchingSources } returns setOf(STAKING) coEvery { stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = cryptoCurrencyFactory.ethereum) @@ -347,8 +370,8 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert @@ -359,8 +382,9 @@ internal class WalletBalanceFetcherTest { assertEither(actual, expected) coVerifyOrder { - currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) + userWalletsListRepository.getSyncStrict(userWalletId) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) multiWalletBalanceFetcher.fetchingSources stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = cryptoCurrencyFactory.ethereum) stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = cryptoCurrencyFactory.stellar) @@ -368,8 +392,8 @@ internal class WalletBalanceFetcherTest { } coVerify(inverse = true) { - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) multiNetworkStatusFetcher(params = any()) multiQuoteStatusFetcher(params = any()) } @@ -384,8 +408,9 @@ internal class WalletBalanceFetcherTest { val currencies = cryptoCurrencyFactory.ethereumAndStellar.toSet() - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver - coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) } returns currencies + mockColdWallet(cardTypesResolver) + coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } returns currencies + coEvery { expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) } returns mockk() every { multiWalletBalanceFetcher.fetchingSources } returns setOf(STAKING) coEvery { stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = any()) @@ -395,24 +420,25 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert assertEitherRight(actual) coVerifyOrder { - currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) + userWalletsListRepository.getSyncStrict(userWalletId) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) multiWalletBalanceFetcher.fetchingSources stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = cryptoCurrencyFactory.ethereum) stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = cryptoCurrencyFactory.stellar) } coVerify(inverse = true) { - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) multiNetworkStatusFetcher(params = any()) multiQuoteStatusFetcher(params = any()) multiStakingBalanceFetcher(params = any()) @@ -433,8 +459,9 @@ internal class WalletBalanceFetcherTest { ), ) - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver - coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) } returns currencies + mockColdWallet(cardTypesResolver) + coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } returns currencies + coEvery { expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) } returns mockk() every { multiWalletBalanceFetcher.fetchingSources } returns setOf(STAKING) coEvery { stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = any()) } returns stakingId @@ -442,24 +469,25 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert assertEitherRight(actual) coVerifyOrder { - currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) + userWalletsListRepository.getSyncStrict(userWalletId) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) multiWalletBalanceFetcher.fetchingSources stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = cryptoCurrencyFactory.ethereum) stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = cryptoCurrencyFactory.stellar) } coVerify(inverse = true) { - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) multiNetworkStatusFetcher(params = any()) multiQuoteStatusFetcher(params = any()) multiStakingBalanceFetcher(params = any()) @@ -481,8 +509,9 @@ internal class WalletBalanceFetcherTest { ) val stellarStakingId = Either.Left(StakingIdFactory.Error.UnsupportedCurrency) - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver - coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) } returns currencies + mockColdWallet(cardTypesResolver) + coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } returns currencies + coEvery { expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) } returns mockk() every { multiWalletBalanceFetcher.fetchingSources } returns setOf(STAKING) coEvery { stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = cryptoCurrencyFactory.ethereum) @@ -495,24 +524,25 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert assertEitherRight(actual) coVerifyOrder { - currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) + userWalletsListRepository.getSyncStrict(userWalletId) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) multiWalletBalanceFetcher.fetchingSources stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = cryptoCurrencyFactory.ethereum) stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = cryptoCurrencyFactory.stellar) } coVerify(inverse = true) { - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) multiNetworkStatusFetcher(params = any()) multiQuoteStatusFetcher(params = any()) multiStakingBalanceFetcher(params = any()) @@ -545,8 +575,9 @@ internal class WalletBalanceFetcherTest { val exception = IllegalStateException("Error") - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver - coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) } returns currencies + mockColdWallet(cardTypesResolver) + coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } returns currencies + coEvery { expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) } returns mockk() every { multiWalletBalanceFetcher.fetchingSources } returns setOf(NETWORK, QUOTE, STAKING) coEvery { multiNetworkStatusFetcher(params = networkStatusFetcherParams) } returns exception.left() coEvery { multiQuoteStatusFetcher(params = quoteStatusFetcherParams) } returns exception.left() @@ -562,8 +593,8 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert @@ -576,8 +607,9 @@ internal class WalletBalanceFetcherTest { assertEither(actual, expected) coVerifyOrder { - currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) + userWalletsListRepository.getSyncStrict(userWalletId) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) multiWalletBalanceFetcher.fetchingSources multiNetworkStatusFetcher(params = networkStatusFetcherParams) multiQuoteStatusFetcher(params = quoteStatusFetcherParams) @@ -587,8 +619,8 @@ internal class WalletBalanceFetcherTest { } coVerify(inverse = true) { - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } } @@ -616,8 +648,9 @@ internal class WalletBalanceFetcherTest { stakingIds = setOf(ethereumStakingId, stellarStakingId), ) - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver - coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) } returns currencies + mockColdWallet(cardTypesResolver) + coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } returns currencies + coEvery { expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) } returns mockk() every { multiWalletBalanceFetcher.fetchingSources } returns setOf(NETWORK, QUOTE, STAKING) coEvery { multiNetworkStatusFetcher(params = networkStatusFetcherParams) } returns Unit.right() coEvery { multiQuoteStatusFetcher(params = quoteStatusFetcherParams) } returns Unit.right() @@ -633,8 +666,8 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert @@ -642,8 +675,9 @@ internal class WalletBalanceFetcherTest { assertEither(actual, expected) coVerifyOrder { - currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) + userWalletsListRepository.getSyncStrict(userWalletId) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) multiWalletBalanceFetcher.fetchingSources multiNetworkStatusFetcher(params = networkStatusFetcherParams) multiQuoteStatusFetcher(params = quoteStatusFetcherParams) @@ -653,8 +687,8 @@ internal class WalletBalanceFetcherTest { } coVerify(inverse = true) { - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } } @@ -678,10 +712,11 @@ internal class WalletBalanceFetcherTest { appCurrencyId = null, ) - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver + mockColdWallet(cardTypesResolver) coEvery { - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) } returns currencies + coEvery { expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) } returns mockk() every { singleWalletWithTokenBalanceFetcher.fetchingSources } returns setOf(NETWORK, QUOTE) coEvery { multiNetworkStatusFetcher(params = networkStatusFetcherParams) } returns Unit.right() coEvery { multiQuoteStatusFetcher(params = quoteStatusFetcherParams) } returns Unit.right() @@ -690,8 +725,8 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert @@ -699,16 +734,17 @@ internal class WalletBalanceFetcherTest { assertEither(actual, expected) coVerifyOrder { - currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) + userWalletsListRepository.getSyncStrict(userWalletId) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) singleWalletWithTokenBalanceFetcher.fetchingSources multiNetworkStatusFetcher(params = networkStatusFetcherParams) multiQuoteStatusFetcher(params = quoteStatusFetcherParams) } coVerify(inverse = true) { - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) stakingIdFactory.create(userWalletId = any(), cryptoCurrency = any()) multiStakingBalanceFetcher(params = any()) } @@ -735,8 +771,9 @@ internal class WalletBalanceFetcherTest { appCurrencyId = null, ) - every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver - coEvery { singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) } returns currencies + mockColdWallet(cardTypesResolver) + coEvery { singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } returns currencies + coEvery { expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) } returns mockk() every { singleWalletBalanceFetcher.fetchingSources } returns setOf(NETWORK, QUOTE) coEvery { multiNetworkStatusFetcher(params = networkStatusFetcherParams) } returns Unit.right() coEvery { multiQuoteStatusFetcher(params = quoteStatusFetcherParams) } returns Unit.right() @@ -745,8 +782,8 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false - ) + isPaymentAccountRefactorEnabled = false, + ), ) // Assert @@ -754,21 +791,89 @@ internal class WalletBalanceFetcherTest { assertEither(actual, expected) coVerifyOrder { - currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) - singleWalletBalanceFetcher.getCryptoCurrencies(userWalletId = userWalletId) + userWalletsListRepository.getSyncStrict(userWalletId) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) singleWalletBalanceFetcher.fetchingSources multiNetworkStatusFetcher(params = networkStatusFetcherParams) multiQuoteStatusFetcher(params = quoteStatusFetcherParams) } coVerify(inverse = true) { - multiWalletBalanceFetcher.getCryptoCurrencies(userWalletId = any()) - singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWalletId = any()) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) stakingIdFactory.create(userWalletId = any(), cryptoCurrency = any()) multiStakingBalanceFetcher(params = any()) } } + @Test + fun `fetch successfully for hot wallet`() = runTest { + // Arrange + val hotWallet = mockk() + every { userWalletsListRepository.getSyncStrict(userWalletId) } returns hotWallet + + val currencies = cryptoCurrencyFactory.ethereumAndStellar.toSet() + + val networkStatusFetcherParams = MultiNetworkStatusFetcher.Params( + userWalletId = userWalletId, + networks = currencies.mapTo(destination = hashSetOf(), transform = CryptoCurrency::network), + ) + + val quoteStatusFetcherParams = MultiQuoteStatusFetcher.Params( + currenciesIds = currencies.mapNotNullTo(destination = hashSetOf(), transform = { it.id.rawCurrencyId }), + appCurrencyId = null, + ) + + val stakingBalanceFetcherParams = MultiStakingBalanceFetcher.Params( + userWalletId = userWalletId, + stakingIds = setOf(ethereumStakingId, stellarStakingId), + ) + + coEvery { multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) } returns currencies + coEvery { expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) } returns mockk() + every { multiWalletBalanceFetcher.fetchingSources } returns setOf(NETWORK, QUOTE, STAKING) + coEvery { multiNetworkStatusFetcher(params = networkStatusFetcherParams) } returns Unit.right() + coEvery { multiQuoteStatusFetcher(params = quoteStatusFetcherParams) } returns Unit.right() + coEvery { + stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = cryptoCurrencyFactory.ethereum) + } returns Either.Right(ethereumStakingId) + coEvery { + stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = cryptoCurrencyFactory.stellar) + } returns Either.Right(stellarStakingId) + coEvery { multiStakingBalanceFetcher(params = stakingBalanceFetcherParams) } returns Unit.right() + + // Act + val actual = fetcher( + params = WalletBalanceFetcher.Params( + userWalletId = userWalletId, + isPaymentAccountRefactorEnabled = false, + ), + ) + + // Assert + val expected = Unit.right() + assertEither(actual, expected) + + coVerifyOrder { + userWalletsListRepository.getSyncStrict(userWalletId) + multiWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + expressServiceFetcher.fetch(userWallet = any(), assetIds = any()) + } + + coVerify(inverse = true) { + singleWalletWithTokenBalanceFetcher.getCryptoCurrencies(userWallet = any()) + singleWalletBalanceFetcher.getCryptoCurrencies(userWallet = any()) + } + } + + private fun mockColdWallet(cardTypesResolver: CardTypesResolver) { + val coldWallet = mockk() + every { userWalletsListRepository.getSyncStrict(userWalletId) } returns coldWallet + mockkStatic(UserWallet.Cold::cardTypesResolver) + every { coldWallet.cardTypesResolver } returns cardTypesResolver + } + private companion object { val userWalletId = UserWalletId("011") diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcherTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcherTest.kt index 100c5964c1..aa4e51b9e9 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcherTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcherTest.kt @@ -5,11 +5,12 @@ import arrow.core.right import com.google.common.truth.Truth import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.MultiWalletAccountListFetcher import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.wallet.FetchingSource -import com.tangem.domain.models.wallet.UserWalletId import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.every @@ -29,10 +30,10 @@ class MultiWalletBalanceFetcherTest { private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() - private val multiWalletFetcher: MultiWalletCryptoCurrenciesFetcher = mockk() + private val multiWalletFetcher: MultiWalletAccountListFetcher = mockk() private val multiWalletSupplier: MultiWalletCryptoCurrenciesSupplier = mockk() private val fetcher = MultiWalletBalanceFetcher( - multiWalletCryptoCurrenciesFetcher = multiWalletFetcher, + multiWalletAccountListFetcher = multiWalletFetcher, multiWalletCryptoCurrenciesSupplier = multiWalletSupplier, ) @@ -63,7 +64,7 @@ class MultiWalletBalanceFetcherTest { val supplierFlow = flowOf(currencies) coEvery { - multiWalletFetcher(params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)) + multiWalletFetcher(params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId)) } returns Unit.right() every { @@ -71,7 +72,7 @@ class MultiWalletBalanceFetcherTest { } returns supplierFlow // Act - val actual = fetcher.getCryptoCurrencies(userWalletId = userWalletId) + val actual = fetcher.getCryptoCurrencies(userWallet = userWallet) // Assert val expected = currencies.toSet() @@ -85,7 +86,7 @@ class MultiWalletBalanceFetcherTest { val supplierFlow = flowOf(currencies) coEvery { - multiWalletFetcher(params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)) + multiWalletFetcher(params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId)) } returns IllegalStateException().left() every { @@ -93,7 +94,7 @@ class MultiWalletBalanceFetcherTest { } returns supplierFlow // Act - val actual = fetcher.getCryptoCurrencies(userWalletId = userWalletId) + val actual = fetcher.getCryptoCurrencies(userWallet = userWallet) // Assert val expected = currencies.toSet() @@ -106,7 +107,7 @@ class MultiWalletBalanceFetcherTest { val supplierFlow = emptyFlow>() coEvery { - multiWalletFetcher(params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)) + multiWalletFetcher(params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId)) } returns Unit.right() every { @@ -114,7 +115,7 @@ class MultiWalletBalanceFetcherTest { } returns supplierFlow // Act - val actual = fetcher.getCryptoCurrencies(userWalletId = userWalletId) + val actual = fetcher.getCryptoCurrencies(userWallet = userWallet) // Assert val expected = emptySet() @@ -123,5 +124,8 @@ class MultiWalletBalanceFetcherTest { private companion object { val userWalletId = UserWalletId("011") + val userWallet: UserWallet = mockk { + every { walletId } returns userWalletId + } } } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletBalanceFetcherTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletBalanceFetcherTest.kt index 4dae4b7a01..bae0299511 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletBalanceFetcherTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletBalanceFetcherTest.kt @@ -2,11 +2,11 @@ package com.tangem.domain.tokens.wallet.implementor import com.google.common.truth.Truth import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.wallet.FetchingSource -import com.tangem.domain.models.wallet.UserWalletId import io.mockk.clearMocks -import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -21,12 +21,15 @@ class SingleWalletBalanceFetcherTest { private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() - private val currenciesRepository: CurrenciesRepository = mockk() - private val fetcher = SingleWalletBalanceFetcher(currenciesRepository = currenciesRepository) + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() + + private val fetcher = SingleWalletBalanceFetcher( + cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + ) @BeforeEach fun resetMocks() { - clearMocks(currenciesRepository) + clearMocks(cardCryptoCurrencyFactory) } @Test @@ -42,15 +45,13 @@ class SingleWalletBalanceFetcherTest { @Test fun getCryptoCurrencies() = runTest { // Arrange - val userWalletId = UserWalletId("011") val currency = cryptoCurrencyFactory.ethereum + val coldWallet = mockk() - coEvery { - currenciesRepository.getSingleCurrencyWalletPrimaryCurrency(userWalletId = userWalletId, refresh = true) - } returns currency + every { cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(coldWallet) } returns currency // Act - val actual = fetcher.getCryptoCurrencies(userWalletId = userWalletId) + val actual = fetcher.getCryptoCurrencies(userWallet = coldWallet) // Assert val expected = setOf(currency) diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletWithTokenBalanceFetcherTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletWithTokenBalanceFetcherTest.kt index 31221257e6..56d54f2d0a 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletWithTokenBalanceFetcherTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/SingleWalletWithTokenBalanceFetcherTest.kt @@ -2,11 +2,11 @@ package com.tangem.domain.tokens.wallet.implementor import com.google.common.truth.Truth import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.wallet.FetchingSource -import com.tangem.domain.models.wallet.UserWalletId import io.mockk.clearMocks -import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -21,12 +21,15 @@ class SingleWalletWithTokenBalanceFetcherTest { private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() - private val currenciesRepository: CurrenciesRepository = mockk() - private val fetcher = SingleWalletWithTokenBalanceFetcher(currenciesRepository = currenciesRepository) + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() + + private val fetcher = SingleWalletWithTokenBalanceFetcher( + cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + ) @BeforeEach fun resetMocks() { - clearMocks(currenciesRepository) + clearMocks(cardCryptoCurrencyFactory) } @Test @@ -42,15 +45,15 @@ class SingleWalletWithTokenBalanceFetcherTest { @Test fun getCryptoCurrencies() = runTest { // Arrange - val userWalletId = UserWalletId("011") val currencies = cryptoCurrencyFactory.ethereumAndStellar + val coldWallet = mockk() - coEvery { - currenciesRepository.getSingleCurrencyWalletWithCardCurrencies(userWalletId = userWalletId, refresh = true) + every { + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(coldWallet) } returns currencies // Act - val actual = fetcher.getCryptoCurrencies(userWalletId = userWalletId) + val actual = fetcher.getCryptoCurrencies(userWallet = coldWallet) // Assert val expected = currencies.toSet() From fe1506ea0e1094e8b9194362b6e2a80883ac7600 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 13 Mar 2026 19:16:08 +0500 Subject: [PATCH 43/60] Updated on 2026-08-14 --- .../core/ui/ds/button/GhostTangemButton.kt | 41 +++--- .../core/ui/ds/button/OutlineTangemButton.kt | 61 ++++----- .../ds/button/PrimaryInverseTangemButton.kt | 52 ++++---- .../core/ui/ds/button/PrimaryTangemButton.kt | 54 ++++---- .../ui/ds/button/SecondaryTangemButton.kt | 52 ++++---- .../core/ui/ds/button/StatusTangemButton.kt | 121 ++++++++++-------- .../tangem/core/ui/ds/button/TangemButton.kt | 28 ++-- .../core/ui/ds/button/TangemButtonInternal.kt | 24 +--- .../core/ui/ds/button/TangemButtonUM.kt | 6 +- .../core/ui/ds/message/TangemMessage.kt | 12 +- .../feed/ui/earn/components/FilterButtons.kt | 4 +- .../storybook/page/buttons/ButtonsStory.kt | 48 +++---- ...OrganizeSortingProgressStateTransformer.kt | 13 +- .../wallet/state/model/WalletActionButtons.kt | 6 - 14 files changed, 251 insertions(+), 271 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt index 1bd3696db3..93369d7b46 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt @@ -34,9 +34,9 @@ fun GhostTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { text = buttonUM.text, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, - enabled = buttonUM.isEnabled, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, size = buttonUM.size, - state = buttonUM.state, shape = buttonUM.shape, ) } @@ -49,9 +49,9 @@ fun GhostTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { * @param text TextReference for the button label. * @param iconRes Drawable resource ID for the icon to be displayed in the button. * @param iconPosition Position of the icon (Start or End). - * @param enabled Boolean indicating whether the button is enabled. + * @param isEnabled Boolean indicating whether the button is enabled. + * @param isLoading Boolean indicating whether the button is in a loading state. * @param size TangemButtonSize defining the size of the button. - * @param state TangemButtonState defining the current state of the button. * [REDACTED_AUTHOR] */ @@ -61,15 +61,16 @@ fun GhostTangemButton( modifier: Modifier = Modifier, text: TextReference? = null, @DrawableRes iconRes: Int? = null, - enabled: Boolean = true, + isEnabled: Boolean = true, + isLoading: Boolean = false, size: TangemButtonSize = TangemButtonSize.X15, - state: TangemButtonState = TangemButtonState.Default, iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, shape: TangemButtonShape = TangemButtonShape.Default, ) { - val contentColor = when (state) { - TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled - else -> TangemTheme.colors2.text.neutral.primary + val contentColor = if (isEnabled) { + TangemTheme.colors2.text.neutral.primary + } else { + TangemTheme.colors2.text.status.disabled } TangemButtonInternal( onClick = onClick, @@ -77,9 +78,9 @@ fun GhostTangemButton( .clip(shape = shape.toShape(size)), text = text, contentColor = contentColor, - enabled = enabled, + isEnabled = isEnabled, + isLoading = isLoading, size = size, - state = state, iconPosition = iconPosition, iconRes = iconRes, ) @@ -90,9 +91,10 @@ fun GhostTangemButton( @Preview(showBackground = true, widthDp = 480) @Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun GhostTangemButton_Preview( - @PreviewParameter(GhostTangemButtonPreviewProvider::class) params: TangemButtonState, + @PreviewParameter(GhostTangemButtonPreviewProvider::class) params: Pair, ) { TangemThemePreviewRedesign { + val (isEnabled, isLoading) = params Row( horizontalArrangement = Arrangement.spacedBy(21.dp), modifier = Modifier @@ -114,7 +116,8 @@ private fun GhostTangemButton_Preview( size = TangemButtonSize.X15, iconPosition = iconPosition, iconRes = R.drawable.ic_tangem_24, - state = params, + isEnabled = isEnabled, + isLoading = isLoading, ) } } @@ -123,13 +126,13 @@ private fun GhostTangemButton_Preview( } } -private class GhostTangemButtonPreviewProvider : PreviewParameterProvider { - override val values: Sequence +private class GhostTangemButtonPreviewProvider : PreviewParameterProvider> { + override val values: Sequence> get() = sequenceOf( - TangemButtonState.Default, - TangemButtonState.Pressed, - TangemButtonState.Loading, - TangemButtonState.Disabled, + true to false, + false to false, + true to true, + false to true, ) } // endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/OutlineTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/OutlineTangemButton.kt index 03486f2f74..0ffd8e912a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/OutlineTangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/OutlineTangemButton.kt @@ -35,9 +35,9 @@ fun OutlineTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) text = buttonUM.text, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, - enabled = buttonUM.isEnabled, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, size = buttonUM.size, - state = buttonUM.state, shape = buttonUM.shape, ) } @@ -50,9 +50,9 @@ fun OutlineTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) * @param text TextReference for the button label. * @param iconRes Drawable resource ID for the icon to be displayed in the button. * @param iconPosition Position of the icon (Start or End). - * @param enabled Boolean indicating whether the button is enabled. + * @param isEnabled Boolean indicating whether the button is enabled. + * @param isLoading Boolean indicating whether the button is in a loading state. * @param size TangemButtonSize defining the size of the button. - * @param state TangemButtonState defining the current state of the button. * @param shape TangemButtonShape defining the shape of the button. * [REDACTED_AUTHOR] @@ -64,39 +64,32 @@ fun OutlineTangemButton( text: TextReference? = null, @DrawableRes iconRes: Int? = null, iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, - enabled: Boolean = true, + isEnabled: Boolean = true, + isLoading: Boolean = false, size: TangemButtonSize = TangemButtonSize.X15, - state: TangemButtonState = TangemButtonState.Default, shape: TangemButtonShape = TangemButtonShape.Default, ) { - val backgroundModifier = when (state) { - TangemButtonState.Loading, - TangemButtonState.Pressed, - TangemButtonState.Disabled, - TangemButtonState.Default, - -> Modifier - .background(TangemTheme.colors2.surface.level1) - .border( - width = 1.dp, - color = TangemTheme.colors2.border.neutral.primary, - shape = shape.toShape(size), - ) - } - val contentColor = when (state) { - TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled - else -> TangemTheme.colors2.text.neutral.primary + val contentColor = if (isEnabled) { + TangemTheme.colors2.text.neutral.primary + } else { + TangemTheme.colors2.text.status.disabled } TangemButtonInternal( onClick = onClick, modifier = modifier .clip(shape.toShape(size)) - .then(backgroundModifier), + .background(TangemTheme.colors2.surface.level1) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.primary, + shape = shape.toShape(size), + ), text = text, contentColor = contentColor, iconRes = iconRes, - enabled = enabled, + isEnabled = isEnabled, + isLoading = isLoading, size = size, - state = state, iconPosition = iconPosition, ) } @@ -106,9 +99,10 @@ fun OutlineTangemButton( @Preview(showBackground = true, widthDp = 480) @Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun OutlineTangemButton_Preview( - @PreviewParameter(OutlineTangemButtonPreviewProvider::class) params: TangemButtonState, + @PreviewParameter(OutlineTangemButtonPreviewProvider::class) params: Pair, ) { TangemThemePreviewRedesign { + val (isEnabled, isLoading) = params Row( horizontalArrangement = Arrangement.spacedBy(21.dp), modifier = Modifier @@ -132,7 +126,8 @@ private fun OutlineTangemButton_Preview( shape = shape, iconPosition = iconPosition, iconRes = R.drawable.ic_tangem_24, - state = params, + isEnabled = isEnabled, + isLoading = isLoading, ) } } @@ -141,13 +136,13 @@ private fun OutlineTangemButton_Preview( } } -private class OutlineTangemButtonPreviewProvider : PreviewParameterProvider { - override val values: Sequence +private class OutlineTangemButtonPreviewProvider : PreviewParameterProvider> { + override val values: Sequence> get() = sequenceOf( - TangemButtonState.Default, - TangemButtonState.Pressed, - TangemButtonState.Loading, - TangemButtonState.Disabled, + true to false, + false to false, + true to true, + false to true, ) } // endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryInverseTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryInverseTangemButton.kt index 68d5b682b4..b8bf5c1228 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryInverseTangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryInverseTangemButton.kt @@ -34,9 +34,9 @@ fun PrimaryInverseTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Mo text = buttonUM.text, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, - enabled = buttonUM.isEnabled, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, size = buttonUM.size, - state = buttonUM.state, shape = buttonUM.shape, ) } @@ -49,9 +49,9 @@ fun PrimaryInverseTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Mo * @param text TextReference for the button label. * @param iconRes Drawable resource ID for the icon to be displayed in the button. * @param iconPosition Position of the icon (Start or End). - * @param enabled Boolean indicating whether the button is enabled. + * @param isEnabled Boolean indicating whether the button is enabled. + * @param isLoading Boolean indicating whether the button is in a loading state. * @param size TangemButtonSize defining the size of the button. - * @param state TangemButtonState defining the current state of the button. * @param shape TangemButtonShape defining the shape of the button. * [REDACTED_AUTHOR] @@ -63,23 +63,19 @@ fun PrimaryInverseTangemButton( text: TextReference? = null, @DrawableRes iconRes: Int? = null, iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, - enabled: Boolean = true, + isEnabled: Boolean = true, + isLoading: Boolean = false, size: TangemButtonSize = TangemButtonSize.X15, - state: TangemButtonState = TangemButtonState.Default, shape: TangemButtonShape = TangemButtonShape.Default, ) { - val backgroundModifier = when (state) { - TangemButtonState.Default -> Modifier.background(TangemTheme.colors2.button.backgroundPrimaryInverse) - TangemButtonState.Disabled -> Modifier.background(TangemTheme.colors2.button.backgroundDisabled) - TangemButtonState.Loading, - TangemButtonState.Pressed, - -> Modifier - .background(TangemTheme.colors2.button.backgroundPrimaryInverse) - .background(TangemTheme.colors2.overlay.overlayPrimary) + val backgroundModifier = when { + isEnabled -> Modifier.background(TangemTheme.colors2.button.backgroundPrimaryInverse) + else -> Modifier.background(TangemTheme.colors2.button.backgroundDisabled) } - val contentColor = when (state) { - TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled - else -> TangemTheme.colors2.text.neutral.primary + val contentColor = if (isEnabled) { + TangemTheme.colors2.text.neutral.primary + } else { + TangemTheme.colors2.text.status.disabled } TangemButtonInternal( onClick = onClick, @@ -88,9 +84,9 @@ fun PrimaryInverseTangemButton( .then(backgroundModifier), text = text, contentColor = contentColor, - enabled = enabled, + isEnabled = isEnabled, + isLoading = isLoading, size = size, - state = state, iconPosition = iconPosition, iconRes = iconRes, ) @@ -101,9 +97,10 @@ fun PrimaryInverseTangemButton( @Preview(showBackground = true, widthDp = 480) @Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun PrimaryInverseTangemButton_Preview( - @PreviewParameter(PrimaryInverseTangemButtonPreviewProvider::class) params: TangemButtonState, + @PreviewParameter(PrimaryInverseTangemButtonPreviewProvider::class) params: Pair, ) { TangemThemePreviewRedesign { + val (isEnabled, isLoading) = params Row( horizontalArrangement = Arrangement.spacedBy(21.dp), modifier = Modifier @@ -127,7 +124,8 @@ private fun PrimaryInverseTangemButton_Preview( shape = shape, iconPosition = iconPosition, iconRes = R.drawable.ic_tangem_24, - state = params, + isEnabled = isEnabled, + isLoading = isLoading, ) } } @@ -136,13 +134,13 @@ private fun PrimaryInverseTangemButton_Preview( } } -private class PrimaryInverseTangemButtonPreviewProvider : PreviewParameterProvider { - override val values: Sequence +private class PrimaryInverseTangemButtonPreviewProvider : PreviewParameterProvider> { + override val values: Sequence> get() = sequenceOf( - TangemButtonState.Default, - TangemButtonState.Pressed, - TangemButtonState.Loading, - TangemButtonState.Disabled, + true to false, + false to false, + true to true, + false to true, ) } // endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryTangemButton.kt index f845d30d37..ce45ef3943 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryTangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryTangemButton.kt @@ -35,9 +35,9 @@ fun PrimaryTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) descriptionText = buttonUM.descriptionText, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, - enabled = buttonUM.isEnabled, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, size = buttonUM.size, - state = buttonUM.state, shape = buttonUM.shape, ) } @@ -50,9 +50,9 @@ fun PrimaryTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) * @param text TextReference for the button label. * @param iconRes Drawable resource ID for the icon to be displayed in the button. * @param iconPosition Position of the icon (Start or End). - * @param enabled Boolean indicating whether the button is enabled. + * @param isEnabled Boolean indicating whether the button is enabled. + * @param isLoading Boolean indicating whether the button is in a loading state. * @param size TangemButtonSize defining the size of the button. - * @param state TangemButtonState defining the current state of the button. * @param shape TangemButtonShape defining the shape of the button. * [REDACTED_AUTHOR] @@ -65,24 +65,22 @@ fun PrimaryTangemButton( descriptionText: TextReference? = null, @DrawableRes iconRes: Int? = null, iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, - enabled: Boolean = true, + isEnabled: Boolean = true, + isLoading: Boolean = false, size: TangemButtonSize = TangemButtonSize.X15, - state: TangemButtonState = TangemButtonState.Default, shape: TangemButtonShape = TangemButtonShape.Default, ) { - val backgroundModifier = when (state) { - TangemButtonState.Loading, - TangemButtonState.Default, - -> Modifier.background(TangemTheme.colors2.button.backgroundPrimary) - TangemButtonState.Disabled -> Modifier.background(TangemTheme.colors2.button.backgroundDisabled) - TangemButtonState.Pressed -> Modifier - .background(TangemTheme.colors2.button.backgroundPrimary) - .background(TangemTheme.colors2.overlay.overlaySecondary) + val backgroundModifier = if (isEnabled) { + Modifier.background(TangemTheme.colors2.button.backgroundPrimary) + } else { + Modifier.background(TangemTheme.colors2.button.backgroundDisabled) } - val contentColor = when (state) { - TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled - else -> TangemTheme.colors2.text.neutral.primaryInverted + val contentColor = if (isEnabled) { + TangemTheme.colors2.text.neutral.primaryInverted + } else { + TangemTheme.colors2.text.status.disabled } + TangemButtonInternal( onClick = onClick, modifier = modifier @@ -91,9 +89,9 @@ fun PrimaryTangemButton( text = text, descriptionText = descriptionText, contentColor = contentColor, - enabled = enabled, + isEnabled = isEnabled, + isLoading = isLoading, size = size, - state = state, iconPosition = iconPosition, iconRes = iconRes, ) @@ -104,9 +102,10 @@ fun PrimaryTangemButton( @Preview(showBackground = true, widthDp = 480) @Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun PrimaryTangemButton_Preview( - @PreviewParameter(PrimaryTangemButtonPreviewProvider::class) params: TangemButtonState, + @PreviewParameter(PrimaryTangemButtonPreviewProvider::class) params: Pair, ) { TangemThemePreviewRedesign { + val (isEnabled, isLoading) = params Row( horizontalArrangement = Arrangement.spacedBy(21.dp), modifier = Modifier @@ -125,7 +124,8 @@ private fun PrimaryTangemButton_Preview( shape = shape, iconPosition = TangemButtonIconPosition.entries[xIndex], iconRes = R.drawable.ic_tangem_24, - state = params, + isEnabled = isEnabled, + isLoading = isLoading, ) } } @@ -134,13 +134,13 @@ private fun PrimaryTangemButton_Preview( } } -private class PrimaryTangemButtonPreviewProvider : PreviewParameterProvider { - override val values: Sequence +private class PrimaryTangemButtonPreviewProvider : PreviewParameterProvider> { + override val values: Sequence> get() = sequenceOf( - TangemButtonState.Default, - TangemButtonState.Pressed, - TangemButtonState.Loading, - TangemButtonState.Disabled, + true to false, + false to false, + true to true, + false to true, ) } // endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt index 856d3ebce9..fc8cd14a2d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt @@ -35,9 +35,9 @@ fun SecondaryTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifie text = buttonUM.text, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, - enabled = buttonUM.isEnabled, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, size = buttonUM.size, - state = buttonUM.state, shape = buttonUM.shape, ) } @@ -50,9 +50,9 @@ fun SecondaryTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifie * @param text TextReference for the button label. * @param iconRes Drawable resource ID for the icon to be displayed in the button. * @param iconPosition Position of the icon (Start or End). - * @param enabled Boolean indicating whether the button is enabled. + * @param isEnabled Boolean indicating whether the button is enabled. + * @param isLoading Boolean indicating whether the button is in a loading state. * @param size TangemButtonSize defining the size of the button. - * @param state TangemButtonState defining the current state of the button. * @param shape TangemButtonShape defining the shape of the button. * [REDACTED_AUTHOR] @@ -64,22 +64,22 @@ fun SecondaryTangemButton( text: TextReference? = null, @DrawableRes iconRes: Int? = null, iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, - enabled: Boolean = true, + isEnabled: Boolean = true, + isLoading: Boolean = false, size: TangemButtonSize = TangemButtonSize.X15, - state: TangemButtonState = TangemButtonState.Default, shape: TangemButtonShape = TangemButtonShape.Default, ) { - val backgroundModifier = when (state) { - TangemButtonState.Loading, - TangemButtonState.Default, - -> Modifier.background(TangemTheme.colors2.button.backgroundSecondary) - TangemButtonState.Disabled -> Modifier.background(TangemTheme.colors2.button.backgroundDisabled) - TangemButtonState.Pressed -> Modifier.background(TangemTheme.colors2.overlay.overlayPrimary) + val backgroundModifier = if (isEnabled) { + Modifier.background(TangemTheme.colors2.button.backgroundSecondary) + } else { + Modifier.background(TangemTheme.colors2.button.backgroundDisabled) } - val contentColor = when (state) { - TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled - else -> TangemTheme.colors2.text.neutral.primary + val contentColor = if (isEnabled) { + TangemTheme.colors2.text.neutral.primary + } else { + TangemTheme.colors2.text.status.disabled } + TangemButtonInternal( onClick = onClick, modifier = modifier @@ -89,9 +89,9 @@ fun SecondaryTangemButton( text = text, contentColor = contentColor, iconRes = iconRes, - enabled = enabled, + isEnabled = isEnabled, + isLoading = isLoading, size = size, - state = state, iconPosition = iconPosition, ) } @@ -101,9 +101,10 @@ fun SecondaryTangemButton( @Preview(showBackground = true, widthDp = 480) @Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun SecondaryTangemButton_Preview( - @PreviewParameter(SecondaryTangemButtonPreviewProvider::class) params: TangemButtonState, + @PreviewParameter(SecondaryTangemButtonPreviewProvider::class) params: Pair, ) { TangemThemePreviewRedesign { + val (isEnabled, isLoading) = params Row( horizontalArrangement = Arrangement.spacedBy(21.dp), modifier = Modifier @@ -127,7 +128,8 @@ private fun SecondaryTangemButton_Preview( shape = shape, iconPosition = iconPosition, iconRes = R.drawable.ic_tangem_24, - state = params, + isEnabled = isEnabled, + isLoading = isLoading, ) } } @@ -136,13 +138,13 @@ private fun SecondaryTangemButton_Preview( } } -private class SecondaryTangemButtonPreviewProvider : PreviewParameterProvider { - override val values: Sequence +private class SecondaryTangemButtonPreviewProvider : PreviewParameterProvider> { + override val values: Sequence> get() = sequenceOf( - TangemButtonState.Default, - TangemButtonState.Pressed, - TangemButtonState.Loading, - TangemButtonState.Disabled, + true to false, + false to false, + true to true, + false to true, ) } // endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/StatusTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/StatusTangemButton.kt index aa18decee3..7f1fc5b787 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/StatusTangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/StatusTangemButton.kt @@ -3,10 +3,7 @@ package com.tangem.core.ui.ds.button import android.content.res.Configuration import androidx.annotation.DrawableRes import androidx.compose.foundation.background -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.* import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Modifier @@ -15,6 +12,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach import com.tangem.core.ui.R import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference @@ -35,10 +33,10 @@ fun StatusTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) text = buttonUM.text, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, - enabled = buttonUM.isEnabled, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, type = buttonUM.type, size = buttonUM.size, - state = buttonUM.state, shape = buttonUM.shape, ) } @@ -51,9 +49,9 @@ fun StatusTangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) * @param text TextReference for the button label. * @param iconRes Drawable resource ID for the icon to be displayed in the button. * @param iconPosition Position of the icon (Start or End). - * @param enabled Boolean indicating whether the button is enabled. + * @param isEnabled Boolean indicating whether the button is enabled. + * @param isLoading Boolean indicating whether the button is in a loading state. * @param size TangemButtonSize defining the size of the button. - * @param state TangemButtonState defining the current state of the button. * @param shape TangemButtonShape defining the shape of the button. * [REDACTED_AUTHOR] @@ -65,25 +63,20 @@ fun StatusTangemButton( text: TextReference? = null, @DrawableRes iconRes: Int? = null, iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, - enabled: Boolean = true, + isEnabled: Boolean = true, + isLoading: Boolean = false, type: TangemButtonType = TangemButtonType.Accent, size: TangemButtonSize = TangemButtonSize.X15, - state: TangemButtonState = TangemButtonState.Default, shape: TangemButtonShape = TangemButtonShape.Default, ) { val statusColor = type.getStatusColor() - val contentColor = when (state) { - TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled - else -> TangemTheme.colors2.text.neutral.primaryInvertedConstant + val contentColor = when { + isEnabled -> TangemTheme.colors2.text.neutral.primaryInvertedConstant + else -> TangemTheme.colors2.text.status.disabled } - val backgroundModifier = when (state) { - TangemButtonState.Disabled -> Modifier.background(TangemTheme.colors2.button.backgroundDisabled) - TangemButtonState.Default -> Modifier.background(statusColor) - TangemButtonState.Loading, - TangemButtonState.Pressed, - -> Modifier - .background(statusColor) - .background(TangemTheme.colors2.overlay.overlaySecondary) + val backgroundModifier = when { + isEnabled -> Modifier.background(statusColor) + else -> Modifier.background(TangemTheme.colors2.button.backgroundDisabled) } TangemButtonInternal( onClick = onClick, @@ -93,9 +86,9 @@ fun StatusTangemButton( text = text, contentColor = contentColor, iconRes = iconRes, - enabled = enabled, + isEnabled = isEnabled, + isLoading = isLoading, size = size, - state = state, iconPosition = iconPosition, ) } @@ -113,35 +106,42 @@ private fun TangemButtonType.getStatusColor() = when (this) { @Preview(showBackground = true, widthDp = 480) @Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun StatusTangemButton_Preview( - @PreviewParameter(AccentTangemButtonPreviewProvider::class) params: TangemButtonState, + @PreviewParameter(StatusTangemButtonPreviewProvider::class) params: StatusTangemButtonPreviewData, ) { TangemThemePreviewRedesign { - Row( - horizontalArrangement = Arrangement.spacedBy(21.dp), + Column( modifier = Modifier - .background(TangemTheme.colors2.surface.level1) - .padding(8.dp), + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level1), ) { - repeat(4) { yIndex -> - val shape = if (yIndex < 2) TangemButtonShape.Default else TangemButtonShape.Rounded - val text = if (yIndex % 2 == 1) null else stringReference("Button") - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - repeat(2) { xIndex -> - val iconPosition = if (xIndex == 1) { - TangemButtonIconPosition.Start - } else { - TangemButtonIconPosition.End + params.statuses.fastForEach { (isEnabled, isLoading) -> + Row( + horizontalArrangement = Arrangement.spacedBy(21.dp), + modifier = Modifier.padding(8.dp), + ) { + repeat(4) { yIndex -> + val shape = if (yIndex < 2) TangemButtonShape.Default else TangemButtonShape.Rounded + val text = if (yIndex % 2 == 1) null else stringReference("Button") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(2) { xIndex -> + val iconPosition = if (xIndex == 1) { + TangemButtonIconPosition.Start + } else { + TangemButtonIconPosition.End + } + StatusTangemButton( + onClick = {}, + text = text, + size = TangemButtonSize.X15, + shape = shape, + iconPosition = iconPosition, + iconRes = R.drawable.ic_tangem_24, + type = params.type, + isEnabled = isEnabled, + isLoading = isLoading, + ) + } } - StatusTangemButton( - onClick = {}, - text = text, - size = TangemButtonSize.X15, - shape = shape, - iconPosition = iconPosition, - iconRes = R.drawable.ic_tangem_24, - type = TangemButtonType.Accent, - state = params, - ) } } } @@ -149,13 +149,28 @@ private fun StatusTangemButton_Preview( } } -private class AccentTangemButtonPreviewProvider : PreviewParameterProvider { - override val values: Sequence +private data class StatusTangemButtonPreviewData( + val type: TangemButtonType, + val statuses: List>, +) + +private class StatusTangemButtonPreviewProvider : PreviewParameterProvider { + val statuses = listOf( + true to false, + false to false, + true to true, + false to true, + ) + override val values: Sequence get() = sequenceOf( - TangemButtonState.Default, - TangemButtonState.Pressed, - TangemButtonState.Loading, - TangemButtonState.Disabled, + StatusTangemButtonPreviewData( + type = TangemButtonType.Positive, + statuses = statuses, + ), + StatusTangemButtonPreviewData( + type = TangemButtonType.Accent, + statuses = statuses, + ), ) } // endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt index 7e78082811..cd5fe7858f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt @@ -23,9 +23,9 @@ fun TangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { descriptionText = buttonUM.descriptionText, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, - enabled = buttonUM.isEnabled, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, size = buttonUM.size, - state = buttonUM.state, shape = buttonUM.shape, ) TangemButtonType.Secondary -> SecondaryTangemButton( @@ -34,9 +34,9 @@ fun TangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { text = buttonUM.text, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, - enabled = buttonUM.isEnabled, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, size = buttonUM.size, - state = buttonUM.state, shape = buttonUM.shape, ) TangemButtonType.Accent -> StatusTangemButton( @@ -45,10 +45,10 @@ fun TangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { text = buttonUM.text, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, - enabled = buttonUM.isEnabled, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, type = TangemButtonType.Positive, size = buttonUM.size, - state = buttonUM.state, shape = buttonUM.shape, ) TangemButtonType.Positive -> StatusTangemButton( @@ -57,10 +57,10 @@ fun TangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { text = buttonUM.text, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, - enabled = buttonUM.isEnabled, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, type = TangemButtonType.Positive, size = buttonUM.size, - state = buttonUM.state, shape = buttonUM.shape, ) TangemButtonType.Outline -> OutlineTangemButton( @@ -69,9 +69,9 @@ fun TangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { text = buttonUM.text, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, - enabled = buttonUM.isEnabled, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, size = buttonUM.size, - state = buttonUM.state, shape = buttonUM.shape, ) TangemButtonType.PrimaryInverse -> PrimaryInverseTangemButton( @@ -80,9 +80,9 @@ fun TangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { text = buttonUM.text, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, - enabled = buttonUM.isEnabled, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, size = buttonUM.size, - state = buttonUM.state, shape = buttonUM.shape, ) TangemButtonType.Ghost -> GhostTangemButton( @@ -91,9 +91,9 @@ fun TangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { text = buttonUM.text, iconRes = buttonUM.iconRes, iconPosition = buttonUM.iconPosition, - enabled = buttonUM.isEnabled, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, size = buttonUM.size, - state = buttonUM.state, ) } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt index 66665e2f5d..49f4001a56 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt @@ -43,10 +43,10 @@ private const val LOADING_ANIMATION_DURATION = 150 * @param text TextReference for the button label. * @param iconRes Drawable resource ID for the icon to be displayed in the button. * @param iconPosition Position of the icon (Start or End). - * @param enabled Boolean indicating whether the button is enabled. + * @param isEnabled Boolean indicating whether the button is enabled. + * @param isLoading Boolean indicating whether the button is in a loading state. * @param contentColor Color of the button content (text and icon). * @param size TangemButtonSize defining the size of the button. - * @param state TangemButtonState defining the current state of the button. * [REDACTED_AUTHOR] */ @@ -58,16 +58,16 @@ internal fun TangemButtonInternal( descriptionText: TextReference? = null, @DrawableRes iconRes: Int? = null, iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, - enabled: Boolean = true, + isEnabled: Boolean = true, + isLoading: Boolean = false, contentColor: Color = TangemTheme.colors2.text.neutral.primary, size: TangemButtonSize = TangemButtonSize.X15, - state: TangemButtonState = TangemButtonState.Default, ) { ProvideButtonRippleConfiguration { Box( modifier = modifier .testTag(BaseButtonTestTags.BUTTON) - .clickableSingle(enabled = enabled, onClick = onClick, role = Role.Button) + .clickableSingle(enabled = isEnabled, onClick = onClick, role = Role.Button) .height(size.toHeightDp()) .conditionalCompose(text == null) { width(size.toHeightDp()) @@ -81,7 +81,7 @@ internal fun TangemButtonInternal( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .align(Alignment.Center) - .conditional(state == TangemButtonState.Loading) { + .conditional(isLoading) { alpha(0f) }, ) { @@ -118,7 +118,7 @@ internal fun TangemButtonInternal( modifier = Modifier .align(Alignment.Center) .size(size.toContentSize()), - visible = state == TangemButtonState.Loading, + visible = isLoading, exit = fadeOut(animationSpec = tween(LOADING_ANIMATION_DURATION)), enter = fadeIn(animationSpec = tween(LOADING_ANIMATION_DURATION)), ) { @@ -323,16 +323,6 @@ enum class TangemButtonSize { } } -/** - * Defines the state of the Tangem button. - */ -enum class TangemButtonState { - Default, - Disabled, - Pressed, - Loading, -} - /** * Defines the position of the icon in the Tangem button. */ diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonUM.kt index 6d96b1f577..029a4d3111 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonUM.kt @@ -11,9 +11,9 @@ import com.tangem.core.ui.extensions.TextReference * @param descriptionText TextReference for the button description (optional). * @param iconRes Drawable resource ID for the icon to be displayed in the button (optional). * @param iconPosition Position of the icon (Start or End). - * @param isEnabled Boolean indicating whether the button is enabled. + * @param isEnabled Boolean indicating whether the button is enabled. + * @param isLoading Boolean indicating whether the button is in a loading state. * @param size TangemButtonSize defining the size of the button. - * @param state TangemButtonState defining the current state of the button. * @param shape TangemButtonShape defining the shape of the button. * @param type TangemButtonType defining the style type of the button. * @param onClick Lambda to be invoked when the button is clicked. @@ -27,8 +27,8 @@ data class TangemButtonUM( @DrawableRes val iconRes: Int? = null, val iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, val isEnabled: Boolean = true, + val isLoading: Boolean = false, val size: TangemButtonSize = TangemButtonSize.X15, - val state: TangemButtonState = TangemButtonState.Default, val shape: TangemButtonShape = TangemButtonShape.Default, val type: TangemButtonType, val onClick: () -> Unit, diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt index 86d955f2d9..ffa2cdd4f2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt @@ -270,11 +270,7 @@ private fun RowScope.TangemMessageLegacyButtons(buttonState: ButtonsState) { iconPosition = TangemButtonIconPosition.End, shape = TangemButtonShape.Rounded, size = TangemButtonSize.X9, - state = if (buttonState.shouldShowProgress) { - TangemButtonState.Loading - } else { - TangemButtonState.Default - }, + isLoading = buttonState.shouldShowProgress, modifier = Modifier.weight(1f), ) is ButtonsState.SecondaryButtonConfig -> PrimaryInverseTangemButton( @@ -284,11 +280,7 @@ private fun RowScope.TangemMessageLegacyButtons(buttonState: ButtonsState) { iconPosition = TangemButtonIconPosition.End, shape = TangemButtonShape.Rounded, size = TangemButtonSize.X9, - state = if (buttonState.shouldShowProgress) { - TangemButtonState.Loading - } else { - TangemButtonState.Default - }, + isLoading = buttonState.shouldShowProgress, modifier = Modifier.weight(1f), ) is ButtonsState.PairButtonsConfig -> { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/FilterButtons.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/FilterButtons.kt index 72d3494f1f..f957da7fda 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/FilterButtons.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/FilterButtons.kt @@ -97,7 +97,7 @@ private fun FilterButtonsV2( iconPosition = com.tangem.core.ui.ds.button.TangemButtonIconPosition.End, size = TangemButtonSize.X9, shape = TangemButtonShape.Rounded, - enabled = earnFilterUM.isNetworkFilterEnabled, + isEnabled = earnFilterUM.isNetworkFilterEnabled, ) SpacerWMax() @@ -109,7 +109,7 @@ private fun FilterButtonsV2( iconPosition = com.tangem.core.ui.ds.button.TangemButtonIconPosition.End, size = TangemButtonSize.X9, shape = TangemButtonShape.Rounded, - enabled = earnFilterUM.isTypeFilterEnabled, + isEnabled = earnFilterUM.isTypeFilterEnabled, ) } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt index 7a7815c402..5c46b2587c 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt @@ -1,4 +1,5 @@ @file:Suppress("MagicNumber", "LongMethod") + package com.tangem.feature.tester.presentation.storybook.page.buttons import androidx.compose.foundation.background @@ -30,25 +31,25 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { .background(TangemTheme.colors2.surface.level1), ) { item("primary") { - ButtonSection(title = "Primary") { state, text, shape -> + ButtonSection(title = "Primary") { isEnabled, text, shape -> PrimaryTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, iconRes = R.drawable.ic_tangem_24, size = TangemButtonSize.X10, - state = state, + isEnabled = isEnabled, shape = shape, ) } } item("secondary") { - ButtonSection(title = "Secondary") { state, text, shape -> + ButtonSection(title = "Secondary") { isEnabled, text, shape -> SecondaryTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, iconRes = R.drawable.ic_tangem_24, size = TangemButtonSize.X10, - state = state, + isEnabled = isEnabled, shape = shape, ) } @@ -57,62 +58,62 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { ButtonSection( title = "PrimaryInverse", background = TangemTheme.colors2.surface.level2, - ) { state, text, shape -> + ) { isEnabled, text, shape -> PrimaryInverseTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, iconRes = R.drawable.ic_tangem_24, size = TangemButtonSize.X10, - state = state, + isEnabled = isEnabled, shape = shape, ) } } item("outline") { - ButtonSection(title = "Outline") { state, text, shape -> + ButtonSection(title = "Outline") { isEnabled, text, shape -> OutlineTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, iconRes = R.drawable.ic_tangem_24, size = TangemButtonSize.X10, - state = state, + isEnabled = isEnabled, shape = shape, ) } } item("accent") { - ButtonSection(title = "Accent") { state, text, shape -> + ButtonSection(title = "Accent") { isEnabled, text, shape -> StatusTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, iconRes = R.drawable.ic_tangem_24, size = TangemButtonSize.X10, - state = state, + isEnabled = isEnabled, shape = shape, ) } } item("positive") { - ButtonSection(title = "Positive") { state, text, shape -> + ButtonSection(title = "Positive") { isEnabled, text, shape -> StatusTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, iconRes = R.drawable.ic_tangem_24, size = TangemButtonSize.X10, type = TangemButtonType.Positive, - state = state, + isEnabled = isEnabled, shape = shape, ) } } item("ghost") { - ButtonSection(title = "Ghost") { state, text, shape -> + ButtonSection(title = "Ghost") { isEnabled, text, shape -> GhostTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, iconRes = R.drawable.ic_tangem_24, size = TangemButtonSize.X10, - state = state, + isEnabled = isEnabled, shape = shape, ) } @@ -125,7 +126,7 @@ private fun ButtonSection( title: String, background: Color = TangemTheme.colors2.surface.level1, shapes: List = TangemButtonShape.entries, - button: @Composable (state: TangemButtonState, text: Boolean, shape: TangemButtonShape) -> Unit, + button: @Composable (isEnabled: Boolean, text: Boolean, shape: TangemButtonShape) -> Unit, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -152,7 +153,7 @@ private fun ButtonSection( @Composable private fun ShapeGroup( shape: TangemButtonShape, - button: @Composable (state: TangemButtonState, text: Boolean, shape: TangemButtonShape) -> Unit, + button: @Composable (isEnabled: Boolean, text: Boolean, shape: TangemButtonShape) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( @@ -161,9 +162,8 @@ private fun ShapeGroup( color = TangemTheme.colors.text.secondary, ) ColumnHeaderRow() - TangemButtonState.entries.forEach { state -> - StateRow(state = state, shape = shape, button = button) - } + StateRow(isEnabled = true, shape = shape, button = button) + StateRow(isEnabled = false, shape = shape, button = button) } } @@ -191,25 +191,25 @@ private fun ColumnHeaderRow() { @Composable private fun StateRow( - state: TangemButtonState, + isEnabled: Boolean, shape: TangemButtonShape, - button: @Composable (state: TangemButtonState, text: Boolean, shape: TangemButtonShape) -> Unit, + button: @Composable (isEnabled: Boolean, text: Boolean, shape: TangemButtonShape) -> Unit, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( - text = state.name, + text = if (isEnabled) "Enabled" else "Disabled", style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, modifier = Modifier.width(STATE_LABEL_WIDTH.dp), ) Box(modifier = Modifier.weight(1f)) { - button(state, true, shape) + button(isEnabled, true, shape) } Box(modifier = Modifier.weight(1f)) { - button(state, false, shape) + button(isEnabled, false, shape) } } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeSortingProgressStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeSortingProgressStateTransformer.kt index e16022ef42..2e6736c0bd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeSortingProgressStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/transformer/OrganizeSortingProgressStateTransformer.kt @@ -1,6 +1,5 @@ package com.tangem.feature.wallet.child.organizetokens.model.transformer -import com.tangem.core.ui.ds.button.TangemButtonState import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM import com.tangem.utils.transformer.Transformer @@ -13,18 +12,10 @@ internal class OrganizeSortingProgressStateTransformer( isEnabled = !isSortingInProgress, ), cancelButton = prevState.cancelButton.copy( - state = if (isSortingInProgress) { - TangemButtonState.Disabled - } else { - TangemButtonState.Default - }, + isEnabled = !isSortingInProgress, ), applyButton = prevState.applyButton.copy( - state = if (isSortingInProgress) { - TangemButtonState.Loading - } else { - TangemButtonState.Default - }, + isLoading = isSortingInProgress, ), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletActionButtons.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletActionButtons.kt index 512f16d709..5f463a795d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletActionButtons.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletActionButtons.kt @@ -3,7 +3,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.model import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import com.tangem.core.ui.ds.button.TangemButtonShape -import com.tangem.core.ui.ds.button.TangemButtonState import com.tangem.core.ui.ds.button.TangemButtonType import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.extensions.TextReference @@ -31,11 +30,6 @@ internal sealed class WalletActionButtons( shape = TangemButtonShape.Rounded, onClick = onClick, isEnabled = isEnabled, - state = if (isEnabled) { - TangemButtonState.Default - } else { - TangemButtonState.Disabled - }, ) data class Buy( From 6b874dda676231453b45c0839f67a50f972871f8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Mar 2026 12:25:46 +0300 Subject: [PATCH 44/60] Updated on 2026-08-14 --- .../com/tangem/core/ui/components/Fade.kt | 13 ++++++-- .../tangem/core/ui/components/haze/HazeExt.kt | 4 +-- .../ui/components/common/WalletTopBar.kt | 32 ++++++++++++++++--- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt index 9a4cb95448..d9fd87ea4b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt @@ -80,8 +80,11 @@ fun BottomFadeWithBlur(backgroundColor: Color, modifier: Modifier = Modifier) { backgroundColor = Color.Transparent, ), ) { - progressive = - HazeProgressive.verticalGradient(startIntensity = 0f, endIntensity = 1f) + progressive = HazeProgressive.verticalGradient( + startIntensity = 0f, + endIntensity = 1f, + preferPerformance = true, + ) }, ) } @@ -110,7 +113,11 @@ fun HorizontalFadeWithBlur(backgroundColor: Color, modifier: Modifier = Modifier ), ) { progressive = - HazeProgressive.horizontalGradient(startIntensity = 0f, endIntensity = 1f) + HazeProgressive.horizontalGradient( + startIntensity = 0f, + endIntensity = 1f, + preferPerformance = true, + ) }, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt b/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt index b80af29a35..dd40c612c7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt @@ -41,11 +41,11 @@ fun Modifier.hazeEffectTangem( val rootBackground by LocalRootBackgroundColor.current return hazeEffect(state, style) { - fallbackTint = HazeTint(rootBackground) + fallbackTint = HazeTint(rootBackground.copy(alpha = 0.5f)) if (isGlobalBlurEnabled) { configure() } - blurEnabled = isGlobalBlurEnabled + blurEnabled = blurEnabled && isGlobalBlurEnabled } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt index 7d820d015c..8285366fb7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt @@ -1,6 +1,7 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.common import android.content.res.Configuration +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -8,6 +9,8 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -24,6 +27,7 @@ import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalRootBackgroundColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreviewRedesign @@ -32,6 +36,7 @@ import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.common.WalletPreviewDataLegacy import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTopBarConfig import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeTint import kotlinx.collections.immutable.persistentListOf private const val VISIBILITY_THRESHOLD = 0.5f @@ -52,12 +57,14 @@ internal fun WalletTopBar( Surface( color = Color.Unspecified, contentColor = Color.Unspecified, - modifier = Modifier.hazeEffectTangem { - progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f) - }, + modifier = Modifier.hazeEffectTangemTopBar(behavior), ) { - val wrappedBalance = remember(behavior.state.collapsedFraction) { - if (behavior.state.collapsedFraction > VISIBILITY_THRESHOLD) walletBalance else null + val isWrappedBalanceShown by remember { + derivedStateOf { behavior.state.collapsedFraction > VISIBILITY_THRESHOLD } + } + + val wrappedBalance = remember(walletBalance, isWrappedBalanceShown) { + walletBalance.takeIf { isWrappedBalanceShown } } TangemTopBar( @@ -131,6 +138,21 @@ internal fun WalletTopBar(config: WalletTopBarConfig) { ) } +@Composable +private fun Modifier.hazeEffectTangemTopBar(behavior: TangemCollapsingAppBarBehavior): Modifier { + val rootBackground by LocalRootBackgroundColor.current + val intensity by animateFloatAsState(targetValue = behavior.state.collapsedFraction * 2f) + + return hazeEffectTangem { + fallbackTint = HazeTint(rootBackground.copy(alpha = intensity / 2)) + progressive = HazeProgressive.verticalGradient( + startIntensity = intensity, + endIntensity = 0f, + preferPerformance = true, + ) + } +} + @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable From 6d77f2552def74967e7c34c745ce3938188c8b14 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Mar 2026 13:41:52 +0400 Subject: [PATCH 45/60] Updated on 2026-08-14 --- .../utils/SdkTransactionHistoryItemConverter.kt | 5 +++++ .../walletmanager/utils/SdkTransactionTypeConverter.kt | 9 +++++++++ gradle/tangem_dependencies.toml | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt index 99f7e8fa99..9832632e12 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt @@ -66,6 +66,11 @@ internal class SdkTransactionHistoryItemConverter( is SdkTransactionHistoryItem.TransactionType.ContractMethodName, -> mapToInteractionAddressType(destinationType = destinationType) + is SdkTransactionHistoryItem.TransactionType.SolanaStakingTransactionType.Stake -> { + transactionType.validatorAddress?.let { + TxInfo.InteractionAddressType.Validator(address = it) + } + } is SdkTransactionHistoryItem.TransactionType.TronStakingTransactionType.VoteWitnessContract -> { TxInfo.InteractionAddressType.Validator(address = transactionType.validatorAddress) } diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt index 44850b9ec5..a3f9c33ba0 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt @@ -42,6 +42,15 @@ internal class SdkTransactionTypeConverter( is TransactionType.Transfer -> { TxInfo.TransactionType.Transfer } + is TransactionType.SolanaStakingTransactionType.Stake -> { + TxInfo.TransactionType.Staking.Stake + } + is TransactionType.SolanaStakingTransactionType.Unstake -> { + TxInfo.TransactionType.Staking.Unstake + } + is TransactionType.SolanaStakingTransactionType.Withdraw -> { + TxInfo.TransactionType.Staking.Withdraw + } is TransactionType.TronStakingTransactionType.FreezeBalanceV2Contract -> { TxInfo.TransactionType.Staking.Stake } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 5f2038f91c..3e5fe1fd8a 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.34-1430" +tangemBlockchainSdk = "develop-1447" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-582" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 07092e4eb32a87b8812b4456c28d98539ba6b4b8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 13 Mar 2026 15:28:32 +0500 Subject: [PATCH 46/60] Updated on 2026-08-14 --- .../domain/tokens/actions/BaseActionsFactory.kt | 14 +++++++++----- .../domain/tokens/actions/CommonActionsFactory.kt | 4 +++- .../tokens/actions/OutdatedDataActionsFactory.kt | 4 +++- .../tokens/actions/UnreachableActionsFactory.kt | 4 +++- .../ui/components/common/WalletPagerIndicator.kt | 2 +- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt index c3850162db..c78daffbe3 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenActionsState.ActionState @@ -115,6 +116,14 @@ internal open class BaseActionsFactory( ) } + protected fun getTokenHideUnavailabilityReason(userWallet: UserWallet): ScenarioUnavailabilityReason { + return if (userWallet.isMultiCurrency) { + ScenarioUnavailabilityReason.None + } else { + ScenarioUnavailabilityReason.SingleWallet + } + } + /** Adds a "Copy Address" action to the builder if the address is available [isAddressAvailable] */ protected fun ActionAvailabilityBuilder.addCopyAction(isAddressAvailable: Boolean) { if (isAddressAvailable) { @@ -155,11 +164,6 @@ internal open class BaseActionsFactory( } } - /** Adds a "Hide Token" action to the builder */ - protected fun ActionAvailabilityBuilder.addHideTokenAction() { - ActionState.HideToken(unavailabilityReason = ScenarioUnavailabilityReason.None).active() - } - /** * Creates a staking action based on the staking availability * diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt index 3fb574d9e2..e90b41cbd4 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt @@ -79,6 +79,8 @@ internal class CommonActionsFactory( null } + val hideTokenUnavailabilityReason = getTokenHideUnavailabilityReason(userWallet) + actionAvailabilityBuilder { // region Analytics if (cryptoCurrencyStatus.currency.id.rawCurrencyId != null) { @@ -129,7 +131,7 @@ internal class CommonActionsFactory( // endregion // region HideToken - addHideTokenAction() + ActionState.HideToken(unavailabilityReason = hideTokenUnavailabilityReason).addByReason() // endregion // region YieldMode diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt index 308a2812bd..91ae07242d 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt @@ -71,6 +71,8 @@ internal class OutdatedDataActionsFactory( null } + val hideTokenUnavailabilityReason = getTokenHideUnavailabilityReason(userWallet) + actionAvailabilityBuilder { // region Copy addCopyAction(isAddressAvailable = isAddressAvailable) @@ -130,7 +132,7 @@ internal class OutdatedDataActionsFactory( // endregion // region HideToken - addHideTokenAction() + ActionState.HideToken(hideTokenUnavailabilityReason).addByReason() // endregion // region Yield Mode diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt index 4850068923..411ee415a2 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt @@ -42,6 +42,8 @@ internal class UnreachableActionsFactory( requirementsDeferred = requirementsDeferred, ) } + + val hideTokenUnavailabilityReason = getTokenHideUnavailabilityReason(userWallet) // endregion actionAvailabilityBuilder { @@ -70,7 +72,7 @@ internal class UnreachableActionsFactory( // endregion // region HideToken - addHideTokenAction() + ActionState.HideToken(hideTokenUnavailabilityReason).addByReason() // endregion } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletPagerIndicator.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletPagerIndicator.kt index de6770b7e9..8c359c0506 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletPagerIndicator.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletPagerIndicator.kt @@ -22,7 +22,7 @@ import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior private const val MIN_SCALE = 0.75f private const val MAX_SCALE = 1f -private const val WALLET_INDICATOR_OFFSET = 0.63f +private const val WALLET_INDICATOR_OFFSET = 0.6f @Composable internal fun WalletPagerIndicator( From a31b747b71ede06b0aa82f94ad1ffd8239dd41cf Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 13 Mar 2026 19:38:17 +0500 Subject: [PATCH 47/60] Updated on 2026-08-14 --- .../tangem/core/ui/ds/badge/TangemBadge.kt | 2 + .../tangem/core/ui/ds/image/TangemIconUM.kt | 10 +++ .../ui/ds/opportunities/OpportunitiesBG.kt | 1 + .../core/ui/ds/placeholder/Placeholder.kt | 86 +++++++++++++++++++ .../core/ui/ds/row/TangemRowContainer.kt | 4 +- .../core/ui/ds/row/token/TangemTokenRow.kt | 12 ++- .../core/ui/ds/row/token/TangemTokenRowUM.kt | 25 +++++- .../internal/TangemTokenRowPreviewData.kt | 5 +- .../row/token/internal/TokenRowEndContent.kt | 15 +++- .../ds/row/token/internal/TokenRowSubtitle.kt | 9 +- .../ui/ds/row/token/internal/TokenRowTitle.kt | 22 ++++- .../tangem/core/ui/res/TangemThemeRedesign.kt | 4 +- .../page/tokenrow/TangemTokenRowStory.kt | 20 ++++- .../SetRefreshStateTransformer.kt | 1 - .../multicurrency/MultiCurrencyContent.kt | 23 +++-- 15 files changed, 213 insertions(+), 26 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/placeholder/Placeholder.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt index c196b908aa..bc59df4610 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt @@ -137,6 +137,7 @@ private fun StartIcon( is TangemIconUM.Ident, is TangemIconUM.Image, is TangemIconUM.Url, + TangemIconUM.Empty, -> wrappedIconRes is TangemIconUM.Icon -> wrappedIconRes.copy(tintReference = ColorReference2 { iconColor }) }, @@ -164,6 +165,7 @@ private fun EndIcon( is TangemIconUM.Ident, is TangemIconUM.Image, is TangemIconUM.Url, + TangemIconUM.Empty, -> wrappedIconRes is TangemIconUM.Icon -> wrappedIconRes.copy(tintReference = ColorReference2 { iconColor }) }, diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt index f6ec4b82e1..86bf70b0c4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt @@ -2,6 +2,9 @@ package com.tangem.core.ui.ds.image import androidx.annotation.DrawableRes import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -25,6 +28,9 @@ import com.tangem.core.ui.res.TangemTheme @Immutable sealed interface TangemIconUM { + /** Empty icon */ + data object Empty : TangemIconUM + /** Icon representing a currency. */ data class Currency( val currencyIconState: CurrencyIconState, @@ -100,5 +106,9 @@ fun TangemIcon(tangemIconUM: TangemIconUM, modifier: Modifier = Modifier) { }, contentDescription = null, ) + TangemIconUM.Empty -> Box( + modifier = modifier + .background(TangemTheme.colors2.skeleton.backgroundPrimary, shape = CircleShape), + ) } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt index 5a392b66cd..79f093e351 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt @@ -128,6 +128,7 @@ private fun BoxScope.BackgroundLayer(icon: TangemIconUM, blurRadius: Dp = 26.dp) ResBackground(icon.fallbackRes, blurRadius) } } + TangemIconUM.Empty -> SolidColorBackground(TangemTheme.colors2.skeleton.backgroundPrimary, blurRadius) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/placeholder/Placeholder.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/placeholder/Placeholder.kt new file mode 100644 index 0000000000..074105a926 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/placeholder/Placeholder.kt @@ -0,0 +1,86 @@ +package com.tangem.core.ui.ds.placeholder + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * Placeholder composable to display a skeleton loading state. + * + * @param modifier Modifier to be applied to the placeholder. + * @param size Size of the placeholder. Default is 100x20 dp. + * @param radius Corner radius of the placeholder. Default is 25 dp. + */ +@Composable +fun Placeholder( + modifier: Modifier = Modifier, + size: DpSize = DpSize(TangemTheme.dimens2.x10, TangemTheme.dimens2.x2), + radius: Dp = TangemTheme.dimens2.x25, +) { + Box( + modifier = modifier + .size(size) + .background( + color = TangemTheme.colors2.skeleton.backgroundPrimary, + shape = RoundedCornerShape(radius), + ), + ) +} + +/** + * TextPlaceholder composable to display a skeleton loading state for text elements. + * + * @param textStyle TextStyle to determine the line height of the placeholder. + * @param modifier Modifier to be applied to the placeholder. + * @param width Width of the placeholder. Default is 200 dp. + * @param radius Corner radius of the placeholder. Default is 25 dp. + */ +@Composable +fun TextPlaceholder( + textStyle: TextStyle, + modifier: Modifier = Modifier, + width: Dp = 200.dp, + radius: Dp = TangemTheme.dimens2.x25, +) { + val lineHeight = with(LocalDensity.current) { textStyle.lineHeight.toDp() } + Box( + modifier = modifier + .size(width = width, height = lineHeight) + .background( + color = TangemTheme.colors2.skeleton.backgroundPrimary, + shape = RoundedCornerShape(radius), + ), + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Placeholder_Preview() { + TangemThemePreviewRedesign { + Column( + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + Placeholder() + TextPlaceholder( + textStyle = TangemTheme.typography2.titleRegular44, + ) + } + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt index 71d79083c4..e6e837040d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt @@ -87,7 +87,7 @@ fun TangemRowContainer( val startTopPlaceable = measurables.measure( layoutId = TangemRowLayoutId.START_TOP, constraints = constraints.copy( - minWidth = startTopMinWidth, + minWidth = 0, maxWidth = max( a = startTopMinWidth, b = availableWidthForBody - endTopPlaceable.widthOrZero(), @@ -99,7 +99,7 @@ fun TangemRowContainer( val startBottomPlaceable = measurables.measure( layoutId = TangemRowLayoutId.START_BOTTOM, constraints = constraints.copy( - minWidth = startBottomMinWidth, + minWidth = 0, maxWidth = max( a = startBottomMinWidth, b = availableWidthForBody - endBottomPlaceable.widthOrZero(), diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRow.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRow.kt index cd2b93a853..307dafb258 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRow.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.composed @@ -50,6 +51,7 @@ fun TangemTokenRow( modifier = Modifier .layoutId(layoutId = TangemRowLayoutId.HEAD) .padding(end = TangemTheme.dimens2.x2) + .size(TangemTheme.dimens2.x9) .testTag(tag = TokenElementsTestTags.TOKEN_ICON), ) @@ -74,6 +76,7 @@ fun TangemTokenRow( isBalanceHidden = isBalanceHidden, textStyle = TangemTheme.typography2.bodySemibold16, textColor = TangemTheme.colors2.text.neutral.primary, + placeholderWidth = TangemTheme.dimens2.x20, modifier = Modifier .layoutId(layoutId = TangemRowLayoutId.END_TOP) .testTag(tag = TokenElementsTestTags.TOKEN_FIAT_AMOUNT), @@ -84,6 +87,7 @@ fun TangemTokenRow( isBalanceHidden = isBalanceHidden, textStyle = TangemTheme.typography2.captionSemibold12, textColor = TangemTheme.colors2.text.neutral.secondary, + placeholderWidth = TangemTheme.dimens2.x11, modifier = Modifier .layoutId(layoutId = TangemRowLayoutId.END_BOTTOM) .testTag(tag = TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT), @@ -169,6 +173,7 @@ fun TangemTokenRow( isBalanceHidden = isBalanceHidden, textStyle = TangemTheme.typography2.bodySemibold16, textColor = TangemTheme.colors2.text.neutral.primary, + placeholderWidth = TangemTheme.dimens2.x20, modifier = Modifier .layoutId(layoutId = TangemRowLayoutId.END_TOP) .testTag(tag = TokenElementsTestTags.TOKEN_FIAT_AMOUNT), @@ -179,6 +184,7 @@ fun TangemTokenRow( isBalanceHidden = isBalanceHidden, textStyle = TangemTheme.typography2.captionSemibold12, textColor = TangemTheme.colors2.text.neutral.secondary, + placeholderWidth = TangemTheme.dimens2.x11, modifier = Modifier .layoutId(layoutId = TangemRowLayoutId.END_BOTTOM) .testTag(tag = TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT), @@ -227,7 +233,7 @@ private fun Modifier.tokenClickable(tokenRowUM: TangemTokenRowUM): Modifier = co @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun TangemTokenRow_Preview( - @PreviewParameter(TangemTokenRow_PreviewProvider::class) tokenRowUM: TangemTokenRowUM, + @PreviewParameter(TangemTokenRowPreviewProvider::class) tokenRowUM: TangemTokenRowUM, ) { TangemThemePreviewRedesign { TangemTokenRow( @@ -239,8 +245,7 @@ private fun TangemTokenRow_Preview( } } -@Suppress("ClassNaming") -class TangemTokenRow_PreviewProvider : CollectionPreviewParameterProvider( +private class TangemTokenRowPreviewProvider : CollectionPreviewParameterProvider( collection = listOf( TangemTokenRowPreviewData.defaultState, TangemTokenRowPreviewData.defaultEllipsisState, @@ -249,6 +254,7 @@ class TangemTokenRow_PreviewProvider : CollectionPreviewParameterProvider Unit)? = null } + /** + * Loading state of [TangemTokenRowUM] + */ + data class Empty( + override val id: String, + ) : TangemTokenRowUM() { + override val headIconUM: TangemIconUM = TangemIconUM.Empty + override val subtitleUM: SubtitleUM = SubtitleUM.Placeholder + override val titleUM: TitleUM = TitleUM.Placeholder + override val topEndContentUM: EndContentUM = EndContentUM.Placeholder + override val bottomEndContentUM: EndContentUM = EndContentUM.Placeholder + override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty + override val tailUM: TangemRowTailUM = TangemRowTailUM.Empty + override val onItemClick: (() -> Unit)? = null + override val onItemLongClick: (() -> Unit)? = null + } + /** * Actionable state of [TangemTokenRowUM] */ @@ -108,6 +125,8 @@ sealed class TangemTokenRowUM : TangemRowUM { data object Loading : TitleUM() + data object Placeholder : TitleUM() + data object Empty : TitleUM() } @@ -125,6 +144,8 @@ sealed class TangemTokenRowUM : TangemRowUM { data object Loading : SubtitleUM() + data object Placeholder : SubtitleUM() + data object Empty : SubtitleUM() } @@ -142,6 +163,8 @@ sealed class TangemTokenRowUM : TangemRowUM { data object Loading : EndContentUM() + data object Placeholder : EndContentUM() + data object Empty : EndContentUM() } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt index e4a05a37db..bd003a5ee9 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt @@ -19,7 +19,7 @@ import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.persistentListOf import java.util.UUID -internal object TangemTokenRowPreviewData { +object TangemTokenRowPreviewData { private val priceChangeState: PriceChangeState.Content get() = PriceChangeState.Content( @@ -218,6 +218,9 @@ internal object TangemTokenRowPreviewData { subtitleUM = TangemTokenRowUM.SubtitleUM.Loading, ) + val emptyState: TangemTokenRowUM.Empty + get() = TangemTokenRowUM.Empty(id = UUID.randomUUID().toString()) + val unreachableState: TangemTokenRowUM get() = defaultState.copy( topEndContentUM = TangemTokenRowUM.EndContentUM.Content( diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndContent.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndContent.kt index 934b33aa8c..59c4822b88 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndContent.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndContent.kt @@ -17,10 +17,12 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp import androidx.compose.ui.util.fastForEach import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.ds.placeholder.TextPlaceholder import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveAnnotatedReference @@ -33,6 +35,7 @@ internal fun TokenRowEndContent( isBalanceHidden: Boolean, textStyle: TextStyle, textColor: Color, + placeholderWidth: Dp, modifier: Modifier = Modifier, ) { when (endContentUM) { @@ -43,12 +46,17 @@ internal fun TokenRowEndContent( textStyle = textStyle, textColor = textColor, ) - TangemTokenRowUM.EndContentUM.Empty -> Unit TangemTokenRowUM.EndContentUM.Loading -> TextShimmer( style = textStyle, - modifier = modifier.width(TangemTheme.dimens2.x10), + modifier = modifier.width(placeholderWidth), radius = TangemTheme.dimens2.x25, ) + TangemTokenRowUM.EndContentUM.Placeholder -> TextPlaceholder( + modifier = modifier, + textStyle = textStyle, + width = placeholderWidth, + ) + TangemTokenRowUM.EndContentUM.Empty -> Unit } } @@ -141,6 +149,7 @@ private fun TokenRowEndContent_Preview( isBalanceHidden = false, textColor = TangemTheme.colors2.text.neutral.primary, textStyle = TangemTheme.typography2.captionSemibold12, + placeholderWidth = TangemTheme.dimens2.x11, ) } } @@ -149,6 +158,8 @@ private class TokenRowEndContentPreviewProvider : PreviewParameterProvider get() = sequenceOf( TangemTokenRowPreviewData.bottomEndContentUM, + TangemTokenRowUM.EndContentUM.Loading, + TangemTokenRowUM.EndContentUM.Empty, ) } // endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowSubtitle.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowSubtitle.kt index 4bcdac0586..353cefed4c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowSubtitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowSubtitle.kt @@ -17,6 +17,7 @@ import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.text.applyBladeBrush import com.tangem.core.ui.ds.badge.TangemBadge +import com.tangem.core.ui.ds.placeholder.TextPlaceholder import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.res.TangemTheme @@ -31,9 +32,14 @@ internal fun TokenRowSubtitle(subtitleUM: TangemTokenRowUM.SubtitleUM, modifier: ) TangemTokenRowUM.SubtitleUM.Loading -> TextShimmer( style = TangemTheme.typography2.captionSemibold12, - modifier = modifier.width(TangemTheme.dimens2.x8), + modifier = modifier.width(TangemTheme.dimens2.x11), radius = TangemTheme.dimens2.x25, ) + TangemTokenRowUM.SubtitleUM.Placeholder -> TextPlaceholder( + modifier = modifier, + textStyle = TangemTheme.typography2.captionSemibold12, + width = TangemTheme.dimens2.x11, + ) TangemTokenRowUM.SubtitleUM.Empty -> Unit } } @@ -95,6 +101,7 @@ private class TokenRowSubtitlePreviewProvider : PreviewParameterProvider TextPlaceholder( + modifier = modifier, + textStyle = TangemTheme.typography2.bodySemibold16, + width = TangemTheme.dimens2.x22, + ) TangemTokenRowUM.TitleUM.Empty -> Unit } } @@ -89,13 +97,23 @@ private fun ContentTitle(titleUM: TangemTokenRowUM.TitleUM.Content, modifier: Mo @Composable @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun TokenRowTitle_Preview() { +private fun TokenRowTitle_Preview( + @PreviewParameter(TokenRowTitlePreviewProvider::class) params: TangemTokenRowUM.TitleUM, +) { TangemThemePreviewRedesign { TokenRowTitle( - titleUM = TangemTokenRowPreviewData.titleUM, + titleUM = params, modifier = Modifier.fillMaxWidth(), ) } } +private class TokenRowTitlePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemTokenRowPreviewData.titleUM, + TangemTokenRowUM.TitleUM.Loading, + TangemTokenRowUM.TitleUM.Empty, + ) +} // endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt index 724f09365d..a410eeb471 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt @@ -147,7 +147,7 @@ private fun lightThemeColors2(): TangemColors2 { borderInvalid = border.status.warning, ) val skeleton = TangemColors2.Skeleton( - backgroundPrimary = TangemColorPalette.Light1V2, + backgroundPrimary = TangemColorPalette.Dark_10, ) val markers = TangemColors2.Markers( backgroundSolidGray = TangemColorPalette.Light3, @@ -324,7 +324,7 @@ private fun darkThemeColors2(): TangemColors2 { borderInvalid = border.status.warning, ) val skeleton = TangemColors2.Skeleton( - backgroundPrimary = TangemColorPalette.Dark5, + backgroundPrimary = TangemColorPalette.Light_10, ) val markers = TangemColors2.Markers( backgroundSolidGray = TangemColorPalette.Dark5, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tokenrow/TangemTokenRowStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tokenrow/TangemTokenRowStory.kt index d87c97f642..5fa78660ba 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tokenrow/TangemTokenRowStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tokenrow/TangemTokenRowStory.kt @@ -15,13 +15,29 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.tangem.core.ui.ds.row.token.TangemTokenRow -import com.tangem.core.ui.ds.row.token.TangemTokenRow_PreviewProvider +import com.tangem.core.ui.ds.row.token.internal.TangemTokenRowPreviewData import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowStory @Composable internal fun TangemTokenRowStory(state: TangemTokenRowStory, modifier: Modifier = Modifier) { - val rows = remember { TangemTokenRow_PreviewProvider().values.toList() } + val rows = remember { + listOf( + TangemTokenRowPreviewData.defaultState, + TangemTokenRowPreviewData.defaultEllipsisState, + TangemTokenRowPreviewData.tokenState, + TangemTokenRowPreviewData.customTokenState, + TangemTokenRowPreviewData.draggableState, + TangemTokenRowPreviewData.draggableStateV2, + TangemTokenRowPreviewData.loadingState, + TangemTokenRowPreviewData.emptyState, + TangemTokenRowPreviewData.unreachableState, + TangemTokenRowPreviewData.accountState, + TangemTokenRowPreviewData.accountLetterState, + TangemTokenRowPreviewData.accountEllipsisState, + TangemTokenRowPreviewData.promoBannerState, + ) + } LazyColumn( contentPadding = PaddingValues(bottom = 16.dp), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt index 504e4192e7..15ef1cb2e8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt @@ -4,7 +4,6 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.wallet.presentation.wallet.state.model.* import com.tangem.feature.wallet.presentation.wallet.state.utils.enableButtons -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.mutate 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 2f1006c1cc..e66c280dac 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 @@ -31,6 +31,7 @@ import com.tangem.core.ui.components.tokenlist.TokenListItem import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.row.header.TangemHeaderRow import com.tangem.core.ui.ds.row.header.TangemHeaderRowUM import com.tangem.core.ui.ds.row.internal.TangemRowTailUM @@ -321,17 +322,21 @@ internal fun PortfolioRowItem( SharedTokenRowComposables( icon = { modifier -> val size = if (isExpandedWrapped) AccountIconSize.ExtraSmall else AccountIconSize.Default - val currencyIconState = - when (val currencyIconState = item.tokenRowUM.headIconUM.currencyIconState) { - is CurrencyIconState.CryptoPortfolio.Icon -> - currencyIconState.copy(size = size) - is CurrencyIconState.CryptoPortfolio.Letter -> - currencyIconState.copy(size = size) - else -> currencyIconState - } + val headIcon = item.tokenRowUM.headIconUM + val sizedHeadIcon = if (headIcon is TangemIconUM.Currency) { + headIcon.copy( + currencyIconState = when (val currencyIconState = headIcon.currencyIconState) { + is CurrencyIconState.CryptoPortfolio.Icon -> currencyIconState.copy(size = size) + is CurrencyIconState.CryptoPortfolio.Letter -> currencyIconState.copy(size = size) + else -> currencyIconState + }, + ) + } else { + headIcon + } TangemIcon( - tangemIconUM = item.tokenRowUM.headIconUM.copy(currencyIconState = currencyIconState), + tangemIconUM = sizedHeadIcon, modifier = modifier.sharedBounds( sharedContentState = iconSharedContentState, animatedVisibilityScope = animatedContentScope, From eabdda867d6607eeaab2234b5f4d0a0a45df873a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Mar 2026 13:59:27 +0300 Subject: [PATCH 48/60] Updated on 2026-08-14 --- .../MovingColorfulBlubsBackground.kt | 26 +++-- .../NorthernLightsBackground.kt | 104 ++++++------------ .../presentation/wallet/ui/WalletScreen2.kt | 5 +- 3 files changed, 50 insertions(+), 85 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/background/northernlights/MovingColorfulBlubsBackground.kt b/core/ui/src/main/java/com/tangem/core/ui/components/background/northernlights/MovingColorfulBlubsBackground.kt index f4e9988a2b..3d197fbc4a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/background/northernlights/MovingColorfulBlubsBackground.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/background/northernlights/MovingColorfulBlubsBackground.kt @@ -16,16 +16,18 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import com.tangem.core.ui.res.LocalIsInDarkTheme @Suppress("LongMethod") @Composable internal fun MovingColorfulBlubsBackground(modifier: Modifier = Modifier) { val transition = rememberInfiniteTransition(label = "FluidMeshGradient") + val isDark = LocalIsInDarkTheme.current - // ── Circle 1 (left) ────────────────────────────────────────────────────── + // ── Circle 1 (left) — matches dc1 / lc1 from shader version ───────────── val color1 by transition.animateColor( - initialValue = Color(0xFF3355EE), - targetValue = Color(0xFF5577FF), + initialValue = if (isDark) Color(0xFF0D0D3A) else Color(0xFFCCB8EE), + targetValue = if (isDark) Color(0xFF1C1C6E) else Color(0xFFBBA0E8), animationSpec = infiniteRepeatable( animation = tween(4_000, easing = FastOutSlowInEasing), repeatMode = RepeatMode.Reverse, @@ -51,10 +53,10 @@ internal fun MovingColorfulBlubsBackground(modifier: Modifier = Modifier) { label = "y1", ) - // ── Circle 2 (right) ───────────────────────────────────────────────────── + // ── Circle 2 (right) — matches dc2 / lc2 from shader version ──────────── val color2 by transition.animateColor( - initialValue = Color(0xFF7733CC), - targetValue = Color(0xFF4455EE), + initialValue = if (isDark) Color(0xFF0A1238) else Color(0xFFB8C8F0), + targetValue = if (isDark) Color(0xFF112266) else Color(0xFF9AAEE8), animationSpec = infiniteRepeatable( animation = tween(5_000, easing = FastOutSlowInEasing), repeatMode = RepeatMode.Reverse, @@ -82,10 +84,10 @@ internal fun MovingColorfulBlubsBackground(modifier: Modifier = Modifier) { label = "y2", ) - // ── Oval (center) ──────────────────────────────────────────────────────── + // ── Oval (center) — matches dc4 / lc4 from shader version ─────────────── val ovalColor by transition.animateColor( - initialValue = Color(0xFF5533CC), - targetValue = Color(0xFF8844EE), + initialValue = if (isDark) Color(0xFF081A30) else Color(0xFFB8C4EE), + targetValue = if (isDark) Color(0xFF113355) else Color(0xFFA8B4E8), animationSpec = infiniteRepeatable( animation = tween(7_000, easing = FastOutSlowInEasing), repeatMode = RepeatMode.Reverse, @@ -93,10 +95,10 @@ internal fun MovingColorfulBlubsBackground(modifier: Modifier = Modifier) { ), label = "ovalColor", ) - // ── Circle 3 (center) ──────────────────────────────────────────────────── + // ── Circle 3 (center) — matches dc3 / lc3 from shader version ─────────── val color3 by transition.animateColor( - initialValue = Color(0xFF9933BB), - targetValue = Color(0xFFBB44DD), + initialValue = if (isDark) Color(0xFF110A38) else Color(0xFFDDC8F5), + targetValue = if (isDark) Color(0xFF2A1666) else Color(0xFFCCB0EE), animationSpec = infiniteRepeatable( animation = tween(6_000, easing = FastOutSlowInEasing), repeatMode = RepeatMode.Reverse, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/background/northernlights/NorthernLightsBackground.kt b/core/ui/src/main/java/com/tangem/core/ui/components/background/northernlights/NorthernLightsBackground.kt index 27b3583edc..7d8a8aa875 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/background/northernlights/NorthernLightsBackground.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/background/northernlights/NorthernLightsBackground.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.tangem.core.ui.components.background.shaderBackground +import com.tangem.core.ui.res.LocalIsInDarkTheme import com.tangem.core.ui.res.LocalPowerSavingState import com.tangem.core.ui.shader.NorthernLightsMeshGradientShader @@ -36,99 +37,58 @@ fun NorthernLightsBackground( } } -@Suppress("LongMethod") +@Suppress("LongMethod", "NamedArguments") @Composable private fun NorthernLightsBackgroundWithShader(containerColor: Color, modifier: Modifier = Modifier) { val transition = rememberInfiniteTransition(label = "FluidMeshGradientV2") + val isDark = LocalIsInDarkTheme.current - // Each track cycles through 4 states (matching the screenshot frames): - // deep/dark → saturated+bright → light/pastel → vibrant/vivid → back - // 16 s total per track, staggered so no two tracks peak simultaneously. - - // ── Color 1 – indigo → bright blue → lavender → hot violet ────────────── - val color1 by transition.animateColor( - initialValue = Color(0xFF2A1480), - targetValue = Color(0xFF2A1480), + @Composable + fun anim(a: Color, b: Color, c: Color, d: Color, offset: Int = 0) = transition.animateColor( + initialValue = a, + targetValue = a, animationSpec = infiniteRepeatable( animation = keyframes { - durationMillis = 16_000 - Color(0xFF2A1480) at 0 using FastOutSlowInEasing - Color(0xFF4477EE) at 4_000 using FastOutSlowInEasing - Color(0xFFBBAAEE) at 8_000 using FastOutSlowInEasing - Color(0xFF8833EE) at 12_000 using FastOutSlowInEasing + durationMillis = 40_000 + a at 0 using FastOutSlowInEasing + b at 10_000 using FastOutSlowInEasing + c at 20_000 using FastOutSlowInEasing + d at 30_000 using FastOutSlowInEasing }, repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(offset), ), - label = "color1", + label = "c$offset", ) - // ── Color 2 – dark blue → cyan-blue → sky → teal ───────────────────────── - val color2 by transition.animateColor( - initialValue = Color(0xFF1444AA), - targetValue = Color(0xFF1444AA), - animationSpec = infiniteRepeatable( - animation = keyframes { - durationMillis = 16_000 - Color(0xFF1444AA) at 0 using FastOutSlowInEasing - Color(0xFF22AADD) at 4_000 using FastOutSlowInEasing - Color(0xFF99BBDD) at 8_000 using FastOutSlowInEasing - Color(0xFF44DDCC) at 12_000 using FastOutSlowInEasing - }, - repeatMode = RepeatMode.Restart, - initialStartOffset = StartOffset(4_000), - ), - label = "color2", - ) + val dc1 by anim(Color(0xFF0D0D3A), Color(0xFF141455), Color(0xFF1C1C6E), Color(0xFF111148), 0) + val dc2 by anim(Color(0xFF0A1238), Color(0xFF0D1D55), Color(0xFF112266), Color(0xFF0E1A4A), 10_000) + val dc3 by anim(Color(0xFF110A38), Color(0xFF1C1050), Color(0xFF2A1666), Color(0xFF180E48), 20_000) + val dc4 by anim(Color(0xFF081A30), Color(0xFF0D2844), Color(0xFF113355), Color(0xFF0D2240), 5_000) - // ── Color 3 – dark purple → medium purple → rose pink → magenta ────────── - val color3 by transition.animateColor( - initialValue = Color(0xFF4422BB), - targetValue = Color(0xFF4422BB), - animationSpec = infiniteRepeatable( - animation = keyframes { - durationMillis = 16_000 - Color(0xFF4422BB) at 0 using FastOutSlowInEasing - Color(0xFF7733CC) at 4_000 using FastOutSlowInEasing - Color(0xFFDD88BB) at 8_000 using FastOutSlowInEasing - Color(0xFFEE44AA) at 12_000 using FastOutSlowInEasing - }, - repeatMode = RepeatMode.Restart, - initialStartOffset = StartOffset(8_000), - ), - label = "color3", - ) + val lc1 by anim(Color(0xFFCCB8EE), Color(0xFFBBA0E8), Color(0xFFCCB0F0), Color(0xFFC4AAEC), 0) + val lc2 by anim(Color(0xFFB8C8F0), Color(0xFF9AAEE8), Color(0xFFAABEF0), Color(0xFFA0B8EE), 10_000) + val lc3 by anim(Color(0xFFDDC8F5), Color(0xFFCCB0EE), Color(0xFFD8BEF5), Color(0xFFD0B8F2), 20_000) + val lc4 by anim(Color(0xFFB8C4EE), Color(0xFFA8B4E8), Color(0xFFB4C0EE), Color(0xFFAABCEC), 5_000) - // ── Color 4 – dark violet → medium violet → light pink → hot pink ──────── - val color4 by transition.animateColor( - initialValue = Color(0xFF331199), - targetValue = Color(0xFF331199), - animationSpec = infiniteRepeatable( - animation = keyframes { - durationMillis = 16_000 - Color(0xFF331199) at 0 using FastOutSlowInEasing - Color(0xFF6644CC) at 4_000 using FastOutSlowInEasing - Color(0xFFCC77DD) at 8_000 using FastOutSlowInEasing - Color(0xFFFF66CC) at 12_000 using FastOutSlowInEasing - }, - repeatMode = RepeatMode.Restart, - initialStartOffset = StartOffset(2_000), - ), - label = "color4", - ) + val color1 = if (isDark) dc1 else lc1 + val color2 = if (isDark) dc2 else lc2 + val color3 = if (isDark) dc3 else lc3 + val color4 = if (isDark) dc4 else lc4 // Keep a stable shader instance so the RuntimeShader is never recreated. // Colors are pushed each recomposition via updateColors(). val shader = remember { NorthernLightsMeshGradientShader( colors = arrayOf( - Color(0xFF2A1480), - Color(0xFF1444AA), - Color(0xFF4422BB), - Color(0xFF331199), + color1, + color2, + color3, + color4, containerColor, ), - speed = 0.5f, - scale = 4f, + speed = 0.07f, + scale = 1.8f, ) } val colorsArray = remember { Array(5) { Color.Unspecified } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index 7ca5c1dbf1..5053fbfbda 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.focus.FocusState import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity @@ -188,7 +189,9 @@ private fun WalletContent2( } else { TangemTheme.colors2.surface.level2 }, - modifier = Modifier.matchParentSize(), + modifier = Modifier + .graphicsLayer { alpha = 1 - behavior.state.collapsedFraction * 2 } + .matchParentSize(), ) WalletPagerIndicator( From 3cc83d3666606bfd1a3b14735b3b555e8386c2ca Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 13 Mar 2026 20:22:15 +0500 Subject: [PATCH 49/60] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 7 +- core/res/src/main/res/values-es/strings.xml | 1 + core/res/src/main/res/values-fr/strings.xml | 1 + core/res/src/main/res/values-ja/strings.xml | 1 + core/res/src/main/res/values-ru/strings.xml | 2 + .../src/main/res/values-uk-rUA/strings.xml | 1 + core/res/src/main/res/values/strings.xml | 7 ++ .../core/ui/ds/button/TangemButtonInternal.kt | 2 +- .../common/preview/WalletScreenPreviewData.kt | 41 ++++++- .../preview/WalletBalancePreview.kt | 9 ++ .../presentation/preview/WalletPreviewData.kt | 6 + .../wallet/state/model/WalletBalanceUM.kt | 13 +++ .../state/model/WalletNotificationUM.kt | 8 +- .../wallet/state/model/WalletTokensListUM.kt | 18 +++ .../wallet/state/model/WalletUM.kt | 2 +- .../InitializeWalletsTransformer.kt | 4 +- .../converter/WalletTokensListUMConverter.kt | 4 +- .../presentation/wallet/ui/WalletScreen2.kt | 3 +- .../ui/components/common/WalletBalance.kt | 107 ++++++++++-------- .../multicurrency/MultiCurrencyContent.kt | 15 ++- 20 files changed, 184 insertions(+), 68 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 402ebaa74f..1ed8a0bee0 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -217,6 +217,7 @@ Hinzufügen Zum Portfolio hinzufügen Token hinzufügen + Token hinzufügen Hinzugefügt Vertragsadresse Alle @@ -1107,9 +1108,9 @@ für %d Wallet für %d Wallets - Du bekommst ^^%1$s^^ für jede von einem Freund gekaufte Wallet auf deine %2$s Netzwerkadresse %3$s ^^30 Tage nach^^ dem - Du - Bekommt eine + Du erhältst ^^%1$s^^ für jede Wallet, die ein Freund kauft.\nDie Auszahlung erfolgt ^^30 Tage^^ nach dem Kauf an deine%2$s Adresse.%3$s + Du erhältst + Er erhält beim Kauf einer Wallet auf tangem.com %s Rabatt Dein Freund diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 0ec38e3506..4fde82b922 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -217,6 +217,7 @@ Agregar Añadir al portafolio Agregar token + Añada tokens Agregado Dirección Todos diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 41913d6515..89c9435c9b 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -217,6 +217,7 @@ Ajouter Ajouter au portfolio Ajouter un jeton + Ajouter des jetons Ajouté Adresse Tous diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index db979e464e..9aa6f483ab 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -214,6 +214,7 @@ 追加 ポートフォリオに追加 トークンを追加 + トークンの追加 追加済み アドレス すべて diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index c727172575..44dcbe51f1 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -223,6 +223,7 @@ Добавить Добавить в портфель Добавить токен + Добавьте токены Добавлен Адрес Все @@ -408,6 +409,7 @@ Я понял Я понимаю, продолжить Произошла ошибка. Пожалуйста, попробуйте снова. + Разблокировать Недоступно Завершить стейкинг Из-за ограничений %1$s в одну транзакцию может поместиться только %2$d UTXO. Это означает, что вы можете отправить только %3$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 297d8310dc..14557017ad 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -223,6 +223,7 @@ Додати Додати у портфель Додати токен + Додайте токени Додано Адреса Усе diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 3d4d8d7a48..693a34367e 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -217,6 +217,7 @@ Add Add to portfolio Add token + Add tokens Added Address All @@ -383,6 +384,7 @@ To To %s Today + Token to send %d token %d tokens @@ -395,6 +397,7 @@ I understand I understand, continue There was an error. Please try again. + Unlock Unreachable Unstake Due to %1$s limitations only %2$d UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount. @@ -1207,6 +1210,10 @@ Memo: %s Invalid Memo Network fee coverage + + %d token isn\'t compatible with this address + %d tokens aren\'t compatible with this address + Nonce Unique number for each transaction. Use it to resend or cancel a pending transaction. Enter nonce… diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt index 49f4001a56..a4574014be 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt @@ -68,7 +68,7 @@ internal fun TangemButtonInternal( modifier = modifier .testTag(BaseButtonTestTags.BUTTON) .clickableSingle(enabled = isEnabled, onClick = onClick, role = Role.Button) - .height(size.toHeightDp()) + .heightIn(min = size.toHeightDp()) .conditionalCompose(text == null) { width(size.toHeightDp()) } 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 2b44fa4727..5458deb06b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt @@ -2,6 +2,8 @@ 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.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize import com.tangem.core.ui.ds.button.TangemButtonType import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.image.TangemIconUM @@ -50,14 +52,19 @@ internal object WalletScreenPreviewData { text = resourceReference(R.string.organize_tokens_title), type = TangemButtonType.Secondary, onClick = {}, + iconRes = R.drawable.ic_filter_default_24, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, ), ) private val walletLocked = WalletUM.Locked( - walletsBalanceUM = WalletBalancePreview.content, - buttons = WalletPreviewData.actionButtons, + walletsBalanceUM = WalletBalancePreview.empty, + buttons = WalletPreviewData.disabledActionButtons, type = WalletType.Cold, - notifications = persistentListOf(), + notifications = persistentListOf( + WalletNotificationUM.UnlockWallets({}), + ), ) private val walletDefault = WalletUM.Content( @@ -82,13 +89,29 @@ internal object WalletScreenPreviewData { tangemPayState = TangemPayState.Loading, ) - internal val defaultState = WalletScreenState( + private val walletEmpty = WalletUM.Content( + walletsBalanceUM = WalletBalancePreview.empty, + buttons = WalletPreviewData.disabledActionButtons, + type = WalletType.Cold, + pullToRefreshConfig = PullToRefreshConfig( + isRefreshing = false, + onRefresh = {}, + ), + notifications = persistentListOf(), + notificationsCarousel = persistentListOf(), + tokensListUM = WalletTokensListUM.Empty, + nftState = WalletNFTItemUM.Hidden, + tangemPayState = TangemPayState.Empty, + ) + + val defaultState = WalletScreenState( topBarConfig = topBarConfig, selectedWalletIndex = 0, wallets = persistentListOf(), wallets2 = persistentListOf( - walletLocked, walletDefault, + walletEmpty, + walletLocked, ), onWalletChange = { _, _ -> }, event = consumedEvent(), @@ -96,4 +119,12 @@ internal object WalletScreenPreviewData { showMarketsOnboarding = false, onDismissMarketsTooltip = {}, ) + + val emptyState = defaultState.copy( + selectedWalletIndex = 1, + ) + + val lockedState = defaultState.copy( + selectedWalletIndex = 2, + ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletBalancePreview.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletBalancePreview.kt index dc1d34c847..57e11b02aa 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletBalancePreview.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletBalancePreview.kt @@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.preview import androidx.compose.ui.text.SpanStyle import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.styledStringReference import com.tangem.core.ui.res.TangemTheme @@ -37,6 +38,8 @@ internal object WalletBalancePreview { isZeroBalance = false, ) + val hiddenBalanceContent = content.copy(balance = content.balance.orMaskWithStars(true)) + val loading: WalletBalanceUM.Loading = WalletBalanceUM.Loading( id = UserWalletId("1"), name = "My Wallet", @@ -48,4 +51,10 @@ internal object WalletBalancePreview { name = "My Wallet", deviceIcon = DeviceIconUM.Stub(cardsCount = 3), ) + + val empty: WalletBalanceUM.Empty = WalletBalanceUM.Empty( + id = UserWalletId("2"), + name = "My Wallet", + deviceIcon = DeviceIconUM.Stub(cardsCount = 3), + ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletPreviewData.kt index 7a61716733..10acb5ef26 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletPreviewData.kt @@ -15,6 +15,12 @@ internal object WalletPreviewData { } val actionButtons = persistentListOf( + WalletActionButtons.Buy({}, true).buttonUM, + WalletActionButtons.Swap({}, true).buttonUM, + WalletActionButtons.Sell({}, true).buttonUM, + ) + + val disabledActionButtons = persistentListOf( WalletActionButtons.Buy({}, false).buttonUM, WalletActionButtons.Swap({}, false).buttonUM, WalletActionButtons.Sell({}, false).buttonUM, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletBalanceUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletBalanceUM.kt index b3a4710b3a..cc322c0a38 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletBalanceUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletBalanceUM.kt @@ -69,11 +69,24 @@ internal sealed interface WalletBalanceUM { override val deviceIcon: DeviceIconUM, ) : WalletBalanceUM + /** + * Wallet card loading state + * + * @property id wallet id + * @property name wallet name + */ + data class Empty( + override val id: UserWalletId, + override val name: String, + override val deviceIcon: DeviceIconUM, + ) : WalletBalanceUM + fun copySealed(name: String): WalletBalanceUM { return when (this) { is Content -> copy(name = name) is Error -> copy(name = name) is Loading -> copy(name = name) + is Empty -> copy(name = name) } } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt index 3a61317174..f0c7a36808 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt @@ -343,7 +343,13 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t resourceReference(R.string.common_biometrics), ), ), - onClick = onClick, + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(R.string.common_unlock), + onClick = onClick, + type = TangemButtonType.Primary, + ), + ), messageEffect = TangemMessageEffect.Card, isCentered = true, ), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListUM.kt index 91ad3be6ab..ed35c7fd98 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListUM.kt @@ -25,6 +25,24 @@ internal sealed class WalletTokensListUM { override val organizeButtonUM: TangemButtonUM? = null } + data object Locked : WalletTokensListUM() { + override val tokenList: ImmutableList = persistentListOf( + TokensListItemUM2.Portfolio( + tokenRowUM = TangemTokenRowUM.Empty(id = "0"), + tokenList = persistentListOf(), + isExpanded = false, + isCollapsable = true, + ), + TokensListItemUM2.Portfolio( + tokenRowUM = TangemTokenRowUM.Empty(id = "1"), + tokenList = persistentListOf(), + isExpanded = false, + isCollapsable = true, + ), + ) + override val organizeButtonUM: TangemButtonUM? = null + } + data object Loading : WalletTokensListUM() { override val tokenList: ImmutableList = persistentListOf( TokensListItemUM2.Portfolio( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletUM.kt index d7b3a5af2d..a225e271a3 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletUM.kt @@ -45,7 +45,7 @@ internal sealed interface WalletUM { ) : WalletUM { override val notificationsCarousel: ImmutableList = persistentListOf() override val pullToRefreshConfig = PullToRefreshConfig(false, {}) - override val tokensListUM: WalletTokensListUM = WalletTokensListUM.Empty // todo redesign main locked state + override val tokensListUM: WalletTokensListUM = WalletTokensListUM.Locked override val nftState: WalletNFTItemUM = WalletNFTItemUM.Hidden override val tangemPayState: TangemPayState = TangemPayState.Empty } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt index a79d895999..a92b59ba44 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt @@ -1,6 +1,5 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers -import com.tangem.core.ui.R as CoreUiR import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM @@ -20,6 +19,7 @@ import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList +import com.tangem.core.ui.R as CoreUiR internal class InitializeWalletsTransformer( private val selectedWalletIndex: Int, @@ -128,7 +128,7 @@ internal class InitializeWalletsTransformer( private fun UserWallet.toLockedWalletUM(): WalletUM.Locked { return WalletUM.Locked( - walletsBalanceUM = WalletBalanceUM.Loading( + walletsBalanceUM = WalletBalanceUM.Empty( id = walletId, name = name, deviceIcon = getWalletIconUseCase.invoke(userWallet = this) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt index 005d2bb298..4ebcd03f94 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt @@ -71,7 +71,7 @@ internal class WalletTokensListUMConverter( override fun convert(value: AccountStatusList): WalletTokensListUM { val promoCryptoCurrency = yieldSupplyPromoBannerConverter.convert2(value = value) - return if (value.accountStatuses.isEmpty()) { + return if (value.flattenCurrencies().isEmpty()) { WalletTokensListUM.Empty } else { val isCollapsable = value.accountStatuses.count { @@ -163,7 +163,7 @@ internal class WalletTokensListUMConverter( return if (accountList.flattenCurrencies().size > 1 && !selectedWallet.isSingleWalletWithToken()) { TangemButtonUM( text = resourceReference(R.string.organize_tokens_title), - isEnabled = accountList.totalFiatBalance !is TotalFiatBalance.Loading, + isEnabled = accountList.totalFiatBalance is TotalFiatBalance.Loading, size = TangemButtonSize.X9, shape = TangemButtonShape.Rounded, type = TangemButtonType.PrimaryInverse, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index 7ca5c1dbf1..d1eb985db4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -575,7 +575,8 @@ private class WalletScreen2PreviewProvider : PreviewParameterProvider get() = sequenceOf( WalletScreenPreviewData.defaultState, - WalletScreenPreviewData.defaultState.copy(selectedWalletIndex = 1), + WalletScreenPreviewData.emptyState, + WalletScreenPreviewData.lockedState, ) } // endregion \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt index dd23a5debb..55367e4bff 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt @@ -29,6 +29,7 @@ import com.tangem.core.ui.ds.button.SecondaryTangemButton import com.tangem.core.ui.ds.button.TangemButtonShape import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.image.TangemDeviceIcon +import com.tangem.core.ui.ds.placeholder.TextPlaceholder import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior import com.tangem.core.ui.ds.topbar.collapsing.snapToExitUntilCollapsed @@ -113,43 +114,40 @@ private fun Balance(walletBalanceUM: WalletBalanceUM, isBalanceHidden: Boolean, }, ) { balanceUM -> when (balanceUM) { - is WalletBalanceUM.Content -> { - Text( - text = balanceUM.balance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), - style = TangemTheme.typography2.titleRegular44.applyBladeBrush( - isEnabled = balanceUM.isBalanceFlickering, - textColor = TangemTheme.colors2.text.neutral.primary, - ), - color = TangemTheme.colors2.text.neutral.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - autoSize = TextAutoSize.StepBased( - minFontSize = TangemTheme.typography2.bodySemibold15.fontSize, - maxFontSize = TangemTheme.typography2.titleRegular44.fontSize, - ), - ) - } - is WalletBalanceUM.Error -> { - Text( - text = StringsSigns.DASH_SIGN, - style = TangemTheme.typography2.titleRegular44, - color = TangemTheme.colors2.text.neutral.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - autoSize = TextAutoSize.StepBased( - minFontSize = TangemTheme.typography2.bodySemibold15.fontSize, - maxFontSize = TangemTheme.typography2.titleRegular44.fontSize, - ), - ) - } - is WalletBalanceUM.Loading, - -> { - TextShimmer( - text = "123456", - style = TangemTheme.typography2.titleRegular44, - radius = TangemTheme.dimens2.x25, - ) - } + is WalletBalanceUM.Content -> Text( + text = balanceUM.balance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + style = TangemTheme.typography2.titleRegular44.applyBladeBrush( + isEnabled = balanceUM.isBalanceFlickering, + textColor = TangemTheme.colors2.text.neutral.primary, + ), + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = TangemTheme.typography2.bodySemibold15.fontSize, + maxFontSize = TangemTheme.typography2.titleRegular44.fontSize, + ), + ) + is WalletBalanceUM.Error -> Text( + text = StringsSigns.DASH_SIGN, + style = TangemTheme.typography2.titleRegular44, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = TangemTheme.typography2.bodySemibold15.fontSize, + maxFontSize = TangemTheme.typography2.titleRegular44.fontSize, + ), + ) + is WalletBalanceUM.Loading -> TextShimmer( + text = "123456", + style = TangemTheme.typography2.titleRegular44, + radius = TangemTheme.dimens2.x25, + ) + is WalletBalanceUM.Empty -> TextPlaceholder( + textStyle = TangemTheme.typography2.titleRegular44, + width = 200.dp, + ) } } } @@ -162,6 +160,11 @@ private fun ActionButtons(buttons: ImmutableList) { ) { buttons.fastForEach { button -> key(button.text) { + val textColor = if (button.isEnabled) { + TangemTheme.colors2.text.neutral.primary + } else { + TangemTheme.colors2.text.status.disabled + } Column( modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2_5), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), @@ -170,12 +173,13 @@ private fun ActionButtons(buttons: ImmutableList) { SecondaryTangemButton( iconRes = button.iconRes, onClick = button.onClick, + isEnabled = button.isEnabled, shape = TangemButtonShape.Rounded, ) Text( text = button.text.orEmpty().resolveReference(), style = TangemTheme.typography2.bodySemibold15, - color = TangemTheme.colors2.text.neutral.primary, + color = textColor, ) } } @@ -187,25 +191,34 @@ private fun ActionButtons(buttons: ImmutableList) { @Composable @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun WalletBalance_Preview(@PreviewParameter(WalletBalancePreviewProvider::class) params: WalletBalanceUM) { +private fun WalletBalance_Preview( + @PreviewParameter(WalletBalancePreviewProvider::class) + params: WalletBalancePreviewData, +) { TangemThemePreviewRedesign { WalletBalance( - walletBalanceUM = params, + walletBalanceUM = params.walletBalanceUM, behavior = rememberTangemExitUntilCollapsedScrollBehavior(), - buttons = WalletPreviewData.actionButtons, + buttons = params.actionsList, isBalanceHidden = false, modifier = Modifier.background(TangemTheme.colors2.surface.level1), ) } } -private class WalletBalancePreviewProvider : PreviewParameterProvider { - override val values: Sequence +private data class WalletBalancePreviewData( + val walletBalanceUM: WalletBalanceUM, + val actionsList: ImmutableList, +) + +private class WalletBalancePreviewProvider : PreviewParameterProvider { + override val values: Sequence get() = sequenceOf( - WalletBalancePreview.content, - WalletBalancePreview.content.copy(isBalanceFlickering = true), - WalletBalancePreview.loading, - WalletBalancePreview.error, + WalletBalancePreviewData(WalletBalancePreview.content, WalletPreviewData.actionButtons), + WalletBalancePreviewData(WalletBalancePreview.hiddenBalanceContent, WalletPreviewData.actionButtons), + WalletBalancePreviewData(WalletBalancePreview.loading, WalletPreviewData.disabledActionButtons), + WalletBalancePreviewData(WalletBalancePreview.error, WalletPreviewData.disabledActionButtons), + WalletBalancePreviewData(WalletBalancePreview.empty, WalletPreviewData.disabledActionButtons), ) } // endregion \ 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 e66c280dac..6ba90b9b6f 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 @@ -98,6 +98,7 @@ internal fun LazyListScope.tokensListItems2( when (walletTokensListUM) { is WalletTokensListUM.Loading, is WalletTokensListUM.Content, + WalletTokensListUM.Locked, -> { walletTokensListUM.tokenList.fastForEachIndexed { index, listItem -> when (listItem) { @@ -337,11 +338,15 @@ internal fun PortfolioRowItem( TangemIcon( tangemIconUM = sizedHeadIcon, - modifier = modifier.sharedBounds( - sharedContentState = iconSharedContentState, - animatedVisibilityScope = animatedContentScope, - boundsTransform = boundsTransform, - ), + modifier = modifier + .conditionalCompose(headIcon is TangemIconUM.Empty) { + size(TangemTheme.dimens2.x9) + } + .sharedBounds( + sharedContentState = iconSharedContentState, + animatedVisibilityScope = animatedContentScope, + boundsTransform = boundsTransform, + ), ) }, title = { modifier -> From bcee90926c659429e94f89a922ac6cfbb916484d Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Mar 2026 15:23:26 +0300 Subject: [PATCH 50/60] Updated on 2026-08-14 --- .../com/tangem/common/extensions/KNode.kt | 9 + .../screens/AddTokenBottomSheetPageObject.kt | 29 ++ .../screens/SwapChooseTokenPageObject.kt | 8 + .../screens/SwapSelectTokenPageObject.kt | 26 +- .../com/tangem/screens/SwapTokenPageObject.kt | 4 +- .../tangem/tests/swap/SearchAndSwapTest.kt | 262 ++++++++++++++++++ .../tangem/tests/swap/SwapTokenScreenTest.kt | 10 +- .../core/ui/test/SwapTokenScreenTestTags.kt | 2 +- .../feature/swap/ui/SwapScreenContent.kt | 2 +- 9 files changed, 339 insertions(+), 13 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/AddTokenBottomSheetPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/swap/SearchAndSwapTest.kt 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 3ce482fb53..f57b43288a 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt @@ -58,3 +58,12 @@ fun KNode.clickAndWaitFor( throw AssertionError("Condition not met after $maxRetries click attempts") } + +fun KNode.performTextInputInChunks( + text: String, + chunkSize: Int = 2 +) { + text.chunked(chunkSize).forEach { chunk -> + performTextInput(chunk) + } +} diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AddTokenBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AddTokenBottomSheetPageObject.kt new file mode 100644 index 0000000000..fc864f40e2 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/AddTokenBottomSheetPageObject.kt @@ -0,0 +1,29 @@ +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 com.tangem.core.ui.test.BaseButtonTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasTestTag(BaseBottomSheetTestTags.TITLE) + hasText(getResourceString(R.string.common_add_token)) + } + + val addButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_add)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onAddTokenBottomSheet(function: AddTokenBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapChooseTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapChooseTokenPageObject.kt index 6da3d7a7f5..77b3e18ad1 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapChooseTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapChooseTokenPageObject.kt @@ -5,6 +5,7 @@ import com.tangem.common.BaseTestCase import com.tangem.core.ui.R import com.tangem.core.ui.test.AppBarWithSearchTestTags import com.tangem.core.ui.test.BuyTokenScreenTestTags +import com.tangem.core.ui.test.MarketsTestTags 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 @@ -46,6 +47,13 @@ class SwapChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProv } useUnmergedTree = true } + + fun marketsTokenWithTitle(title: String): KNode { + return child { + hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM) + hasText(title) + } + } } internal fun BaseTestCase.onSwapChooseTokenScreen(function: SwapChooseTokenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectTokenPageObject.kt index 83e09a9a09..b07573e5da 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectTokenPageObject.kt @@ -3,10 +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.SearchBarTestTags -import com.tangem.core.ui.test.SwapSelectTokenScreenTestTags -import com.tangem.core.ui.test.TokenElementsTestTags -import com.tangem.core.ui.test.TopAppBarTestTags +import com.tangem.core.ui.test.* 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 @@ -48,6 +45,11 @@ class SwapSelectTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProv useUnmergedTree = true } + val searchBarBlock: KNode = child { + hasTestTag(BaseSearchBarTestTags.SEARCH_BAR) + useUnmergedTree = true + } + val searchBarIcon: KNode = child { hasTestTag(SearchBarTestTags.ICON) useUnmergedTree = true @@ -58,12 +60,28 @@ class SwapSelectTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProv useUnmergedTree = true } + val tryAgainButton: KNode = child { + hasText(getResourceString(R.string.alert_button_try_again)) + useUnmergedTree = true + } + + val unableToLoadData: KNode = child { + hasText(getResourceString(R.string.markets_loading_error_title)) + useUnmergedTree = true + } + fun tokenWithName(tokenName: String): KNode = child { hasTestTag(TokenElementsTestTags.TOKEN_TITLE) hasAnyChild(withText(tokenName)) useUnmergedTree = true } + fun marketsTokenWithName(title: String): KNode { + return child { + hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM) + hasText(title) + } + } } internal fun BaseTestCase.onSwapSelectTokenScreen(function: SwapSelectTokenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index 30fe6823bd..21f6b80423 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -52,8 +52,8 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasTestTag(SwapTokenScreenTestTags.RECEIVE_AMOUNT_SHIMMER) } - val swapTokensOnscreenButton: KNode = child { - hasTestTag(SwapTokenScreenTestTags.SWAP_BUTTON) + val replaceTokensButton: KNode = child { + hasTestTag(SwapTokenScreenTestTags.REPLACE_TOKENS_BUTTON) } val receiveAmount: KNode = child { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SearchAndSwapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SearchAndSwapTest.kt new file mode 100644 index 0000000000..ce2450ee04 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SearchAndSwapTest.kt @@ -0,0 +1,262 @@ +package com.tangem.tests.swap + +import com.tangem.common.BaseTestCase +import com.tangem.common.R +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.performTextInputInChunks +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.SwapEntryPoint +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.openSwapScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SearchAndSwapTest : BaseTestCase() { + + @AllureId("8520") + @DisplayName("Search and Swap: add token without derivation") + @Test + fun addTokenWithoutDerivationTest() { + val swapTokenName = "Ethereum" + val receiveTokenName = "Tether" + val swapTokenSymbol = "ETH" + val receiveTokenSymbol = "USDT" + + setupHooks().run { + + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.MainScreen) + } + step("Click on token with name '$swapTokenName'") { + onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() } + waitForIdle() + } + step("Click on 'Search' text field") { + onSwapSelectTokenScreen { searchBarPlaceholderText.performClick() } + } + step("Type '$receiveTokenName' in input text field") { + onSwapSelectTokenScreen { searchBarBlock.performTextInputInChunks(receiveTokenName) } + } + step("Click on token with name '$receiveTokenName'") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + onSwapSelectTokenScreen { marketsTokenWithName(receiveTokenName).clickWithAssertion() } + } + } + step("Click on 'Add' button") { + onAddTokenBottomSheet { addButton.performClick() } + } + step("Assert swap token symbol: '$swapTokenSymbol' is displayed") { + onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() } + } + step("Assert receive token symbol: '$receiveTokenSymbol' is displayed") { + onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() } + } + } + } + + @AllureId("8519") + @DisplayName("Search and Swap: add token with derivation") + @Test + fun addTokenWithDerivationTest() { + val swapTokenName = "Ethereum" + val receiveTokenName = "TRON" + val swapTokenSymbol = "ETH" + val receiveTokenSymbol = "TRX" + + setupHooks().run { + + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.MainScreen) + } + step("Click on token with name '$swapTokenName'") { + onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() } + waitForIdle() + } + step("Click on 'Search' text field") { + onSwapSelectTokenScreen { searchBarPlaceholderText.performClick() } + } + step("Type '$receiveTokenSymbol' in input text field") { + onSwapSelectTokenScreen { searchBarBlock.performTextInputInChunks(receiveTokenSymbol) } + } + step("Click on token with name '$receiveTokenName'") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + onSwapSelectTokenScreen { marketsTokenWithName(receiveTokenName).clickWithAssertion() } + } + } + step("Click on 'Add' button") { + onAddTokenBottomSheet { addButton.performClick() } + } + step("Assert swap token symbol: '$swapTokenSymbol' is displayed") { + onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() } + } + step("Assert receive token symbol: '$receiveTokenSymbol' is displayed") { + onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() } + } + } + } + + @AllureId("8523") + @DisplayName("Search and Swap: Markets error") + @Test + fun marketsErrorTest() { + val swapTokenName = "Ethereum" + val scenarioName = "coins_list_api" + val scenarioState = "Error" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName, scenarioState) + } + + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.MainScreen) + } + step("Click on token with name '$swapTokenName'") { + onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() } + waitForIdle() + } + step("Assert 'Unable to load data...' error is displayed") { + onSwapSelectTokenScreen { unableToLoadData.assertIsDisplayed() } + } + step("Assert 'Try again' button is displayed") { + onSwapSelectTokenScreen { tryAgainButton.assertIsDisplayed() } + } + } + } + + @AllureId("8522") + @DisplayName("Search and Swap: check 'Unsupported token pair' warning") + @Test + fun unsupportedTokenPairTest() { + val swapTokenName = "Ethereum" + val receiveTokenName = "Pepe" + val warningTitle = getResourceString(R.string.warning_express_unsupported_pair_title) + val warningMessage = getResourceString(R.string.warning_express_unsupported_pair_description) + + setupHooks().run { + + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.MainScreen) + } + step("Click on token with name '$swapTokenName'") { + onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() } + waitForIdle() + } + step("Click on 'Search' text field") { + onSwapSelectTokenScreen { searchBarPlaceholderText.performClick() } + } + step("Type '$receiveTokenName' in input text field") { + onSwapSelectTokenScreen { searchBarBlock.performTextInputInChunks(receiveTokenName) } + } + step("Click on token with name '$receiveTokenName'") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + onSwapSelectTokenScreen { marketsTokenWithName(receiveTokenName).clickWithAssertion() } + } + } + step("Click on 'Add' button") { + onAddTokenBottomSheet { addButton.performClick() } + } + step("Assert warning title '$warningTitle' is displayed") { + onSwapTokenScreen { warningTitle(warningTitle).assertIsDisplayed() } + } + step("Assert warning message '$warningMessage' is displayed") { + onSwapTokenScreen { warningMessage(warningMessage).assertIsDisplayed() } + } + step("Assert warning icon is displayed'") { + onSwapTokenScreen { warningIcon(warningMessage).assertIsDisplayed() } + } + } + } + + @AllureId("8521") + @DisplayName("Swap: search token on Swap token screen") + @Test + fun networkFeeTest() { + val tokenTitle = "Ethereum" + val swapTokenSymbol = "TRX" + val receiveTokenSymbol = "ETH" + val swapTokenName = "TRON" + + setupHooks().run { + + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenTitle'") { + onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } + } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.TokenDetails) + } + step("Click on 'Replace tokens' button") { + onSwapTokenScreen { replaceTokensButton.performClick() } + } + step("Click on 'Select token' icon") { + onSwapTokenScreen { selectTokenIcon.performClick() } + } + step("Click on 'Search' icon") { + onSwapChooseTokenScreen { searchIcon.performClick() } + } + step("Click on 'Search' text field") { + onSwapChooseTokenScreen { searchTextField.performClick() } + } + step("Type '$swapTokenSymbol' in 'Search' text field") { + onSwapChooseTokenScreen { searchTextField.performTextInputInChunks(swapTokenSymbol) } + } + step("Click on token with name: '$swapTokenName'") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + onSwapChooseTokenScreen { marketsTokenWithTitle(swapTokenName).performClick() } + } + } + step("Click on 'Add' button") { + onAddTokenBottomSheet { addButton.performClick() } + } + step("Assert swap token symbol: '$swapTokenSymbol' is displayed") { + onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() } + } + step("Assert receive token symbol: '$receiveTokenSymbol' is displayed") { + onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt index 515ca716f9..22046cdc8a 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt @@ -63,7 +63,7 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert 'Swap tokens on screen' button is displayed") { onSwapTokenScreen { flakySafely(WAIT_UNTIL_TIMEOUT) { - swapTokensOnscreenButton.assertIsDisplayed() + replaceTokensButton.assertIsDisplayed() } } } @@ -203,7 +203,7 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert 'Swap tokens on screen' button is displayed") { onSwapTokenScreen { flakySafely(WAIT_UNTIL_TIMEOUT) { - swapTokensOnscreenButton.assertIsDisplayed() + replaceTokensButton.assertIsDisplayed() } } } @@ -307,7 +307,7 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert 'Swap tokens on screen' button is displayed") { onSwapTokenScreen { flakySafely(WAIT_UNTIL_TIMEOUT) { - swapTokensOnscreenButton.assertIsDisplayed() + replaceTokensButton.assertIsDisplayed() } } } @@ -397,7 +397,7 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert 'Swap tokens on screen' button is displayed") { onSwapTokenScreen { flakySafely(WAIT_UNTIL_TIMEOUT) { - swapTokensOnscreenButton.assertIsDisplayed() + replaceTokensButton.assertIsDisplayed() } } } @@ -442,7 +442,7 @@ class SwapTokenScreenTest : BaseTestCase() { onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() } } step("Click on 'Swap tokens on screen' button") { - onSwapTokenScreen { swapTokensOnscreenButton.performClick() } + onSwapTokenScreen { replaceTokensButton.performClick() } waitForIdle() } step("Assert new swap token symbol: '$receiveTokenSymbol' is displayed") { diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt index 9cdf82f3f3..726604b2b1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt @@ -10,7 +10,7 @@ object SwapTokenScreenTestTags { const val RECEIVE_CARD = "SWAP_TOKEN_SCREEN_RECEIVE_CARD" const val RECEIVE_AMOUNT_SHIMMER = "SWAP_TOKEN_SCREEN_RECEIVE_AMOUNT_SHIMMER" const val PROVIDERS_BLOCK = "SWAP_TOKEN_SCREEN_PROVIDERS_BLOCK" - const val SWAP_BUTTON = "SWAP_TOKEN_SCREEN_SWAP_BUTTON" + const val REPLACE_TOKENS_BUTTON = "SWAP_TOKEN_SCREEN_REPLACE_TOKENS_BUTTON" const val TOKEN = "SWAP_TOKEN_SCREEN_TOKEN" const val TOKEN_SYMBOL = "SWAP_TOKEN_SCREEN_TOKEN_SYMBOL" const val TOKEN_ICON = "SWAP_TOKEN_SCREEN_TOKEN_ICON" 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 813e71cc11..2fdde2b04a 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 @@ -284,7 +284,7 @@ private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) { indication = ripple(), interactionSource = remember { MutableInteractionSource() }, ) - .testTag(SwapTokenScreenTestTags.SWAP_BUTTON), + .testTag(SwapTokenScreenTestTags.REPLACE_TOKENS_BUTTON), ) { when (state.changeCardsButtonState) { ChangeCardsButtonState.UPDATE_IN_PROGRESS -> { From ba1b0e1f84250cc3890f90aaf20099b5ad14f08f Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Mar 2026 14:26:00 +0100 Subject: [PATCH 51/60] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 4 + .../information/InformationBlockContent.kt | 2 +- .../ui/components/items/DecriptionItem.kt | 149 ++++++++++- .../core/ui/ds/tabs/TangemSegmentedPicker.kt | 4 + .../market/detailed/components/InfoPoint.kt | 157 +++++++++++- .../detailed/components/InsightsBlock.kt | 234 ++++++++++++++++-- .../detailed/components/ListedOnBlock.kt | 6 +- .../detailed/components/SecurityScoreBlock.kt | 10 +- .../components/TokenMarketDetailsBody.kt | 138 +++++++++-- 9 files changed, 652 insertions(+), 52 deletions(-) diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 3d4d8d7a48..f60477cf1c 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -801,6 +801,10 @@ Holders The change in the number of token holders within a specific timeframe Holders + D + M + W + Y Insights Links Liquidity diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlockContent.kt b/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlockContent.kt index a39c6b7783..ea9b54bea5 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlockContent.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlockContent.kt @@ -40,7 +40,7 @@ inline fun InformationBlockContentScope.ListItems( } @Composable -inline fun InformationBlockContentScope.GridItems( +inline fun GridItems( items: ImmutableList, modifier: Modifier = Modifier, verticalAlignment: Alignment.Vertical = Alignment.Top, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/items/DecriptionItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/items/DecriptionItem.kt index 25e496e785..2841c1c57e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/items/DecriptionItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/items/DecriptionItem.kt @@ -1,24 +1,30 @@ package com.tangem.core.ui.components.items import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle 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.TextShimmer 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.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.utils.PreviewShimmerContainer import com.tangem.utils.StringsSigns @@ -29,6 +35,32 @@ fun DescriptionItem( onReadMoreClick: () -> Unit, modifier: Modifier = Modifier, textStyle: TextStyle = TangemTheme.typography.body2, +) { + if (LocalRedesignEnabled.current) { + DescriptionItemV2( + description = description, + hasFullDescription = hasFullDescription, + onReadMoreClick = onReadMoreClick, + modifier = modifier, + ) + } else { + DescriptionItemV1( + description = description, + hasFullDescription = hasFullDescription, + onReadMoreClick = onReadMoreClick, + modifier = modifier, + textStyle = textStyle, + ) + } +} + +@Composable +private fun DescriptionItemV1( + description: TextReference, + hasFullDescription: Boolean, + onReadMoreClick: () -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle = TangemTheme.typography.body2, ) { if (hasFullDescription) { val text = buildAnnotatedString { @@ -66,7 +98,58 @@ fun DescriptionItem( } @Composable -public fun DescriptionPlaceholder(modifier: Modifier = Modifier) { +private fun DescriptionItemV2( + description: TextReference, + hasFullDescription: Boolean, + onReadMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (hasFullDescription) { + val text = buildAnnotatedString { + withStyle(SpanStyle(color = TangemTheme.colors2.text.neutral.tertiary)) { + append(description.resolveReference()) + } + withStyle(SpanStyle(color = TangemTheme.colors2.text.neutral.primary)) { + append( + " " + stringResourceSafe(R.string.common_read_more).replace( + ' ', + StringsSigns.NON_BREAKING_SPACE, + ), + ) + } + } + + Text( + modifier = modifier + .clickable( + interactionSource = null, + indication = null, + onClick = onReadMoreClick, + ), + text = text, + style = TangemTheme.typography2.bodyRegular15, + ) + } else { + Text( + modifier = modifier, + text = description.resolveReference(), + style = TangemTheme.typography2.bodyRegular15, + color = TangemTheme.colors2.text.neutral.tertiary, + ) + } +} + +@Composable +fun DescriptionPlaceholder(modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + DescriptionPlaceholderV2(modifier) + } else { + DescriptionPlaceholderV1(modifier) + } +} + +@Composable +private fun DescriptionPlaceholderV1(modifier: Modifier = Modifier) { Column( modifier = modifier, ) { @@ -88,9 +171,33 @@ public fun DescriptionPlaceholder(modifier: Modifier = Modifier) { } } +@Composable +private fun DescriptionPlaceholderV2(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(horizontal = 6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography2.bodyRegular15, + radius = TangemTheme.dimens2.x25, + ) + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography2.bodyRegular15, + radius = TangemTheme.dimens2.x25, + ) + TextShimmer( + modifier = Modifier.fillMaxWidth(fraction = 0.8f), + style = TangemTheme.typography2.bodyRegular15, + radius = TangemTheme.dimens2.x25, + ) + } +} + @Preview @Composable -private fun ContentPreview() { +private fun ContentPreviewV1() { TangemThemePreview { DescriptionItem( description = stringReference( @@ -105,15 +212,49 @@ private fun ContentPreview() { @Preview @Composable -private fun PreviewPlaceholder() { +private fun ContentPreviewV2() { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + TangemThemePreviewRedesign { + DescriptionItem( + description = stringReference( + "XRP (XRP) is a cryptocurrency launched in January 2009, where the first " + + "genesis block was mined on 9th January 2009", + ), + hasFullDescription = true, + onReadMoreClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewPlaceholderV1() { TangemThemePreview { PreviewShimmerContainer( actualContent = { - ContentPreview() + ContentPreviewV1() }, shimmerContent = { DescriptionPlaceholder() }, ) } +} + +@Preview +@Composable +private fun PreviewPlaceholderV2() { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + TangemThemePreviewRedesign { + PreviewShimmerContainer( + actualContent = { + ContentPreviewV2() + }, + shimmerContent = { + DescriptionPlaceholder() + }, + ) + } + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt index fc0a59340b..3f98cea45a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt @@ -79,6 +79,7 @@ fun TangemSegmentedPicker( hasSeparator: Boolean = false, isFixed: Boolean = false, isAltSurface: Boolean = false, + minSegmentWidth: Dp = Dp.Unspecified, onClick: (TangemSegmentUM) -> Unit, ) { if (items.isEmpty() || items.size == 1) return @@ -123,6 +124,7 @@ fun TangemSegmentedPicker( item = item, index = index, isFixed = isFixed, + minSegmentWidth = minSegmentWidth, selectedIndex = selectedIndex, onClick = { onClick(item) }, modifier = Modifier @@ -176,9 +178,11 @@ private fun RowScope.Segment( selectedIndex: MutableState, onClick: () -> Unit, modifier: Modifier = Modifier, + minSegmentWidth: Dp = Dp.Unspecified, ) { Box( modifier = modifier + .defaultMinSize(minWidth = minSegmentWidth) .conditional(isFixed) { weight(1f) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoPoint.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoPoint.kt index a3d85081b4..4baddf7c1a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoPoint.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoPoint.kt @@ -2,17 +2,11 @@ package com.tangem.features.feed.ui.market.detailed.components import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredHeight -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -21,18 +15,30 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.R +import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.SpacerW4 import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.text.TooltipText import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.utils.PreviewShimmerContainer import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM @Composable internal fun InfoPoint(infoPointUM: InfoPointUM, modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + InfoPointV2(infoPointUM, modifier) + } else { + InfoPointV1(infoPointUM, modifier) + } +} + +@Composable +private fun InfoPointV1(infoPointUM: InfoPointUM, modifier: Modifier = Modifier) { Column( modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8), horizontalAlignment = Alignment.Start, @@ -81,8 +87,68 @@ internal fun InfoPoint(infoPointUM: InfoPointUM, modifier: Modifier = Modifier) } } +@Composable +private fun InfoPointV2(infoPointUM: InfoPointUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row { + Text( + text = infoPointUM.value, + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + ) + if (infoPointUM.change != null) { + SpacerW4() + Icon( + modifier = Modifier + .size(TangemTheme.dimens2.x2) + .align(Alignment.CenterVertically), + imageVector = ImageVector.vectorResource( + id = when (infoPointUM.change) { + InfoPointUM.ChangeType.UP -> R.drawable.ic_arrow_up_8 + InfoPointUM.ChangeType.DOWN -> R.drawable.ic_arrow_down_8 + }, + ), + tint = when (infoPointUM.change) { + InfoPointUM.ChangeType.UP -> TangemTheme.colors2.markers.iconGreen + InfoPointUM.ChangeType.DOWN -> TangemTheme.colors2.markers.iconRed + }, + contentDescription = null, + ) + } + } + if (infoPointUM.onInfoClick != null) { + InformationTextBlock( + text = infoPointUM.title, + onInfoClick = infoPointUM.onInfoClick, + informationTextBlockIconPosition = InformationTextBlockIconPosition.START, + ) + } else { + Text( + text = infoPointUM.title.resolveReference(), + style = TangemTheme.typography2.captionSemibold12, + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + @Composable internal fun InfoPointShimmer(modifier: Modifier = Modifier, withTooltip: Boolean = false) { + if (LocalRedesignEnabled.current) { + InfoPointShimmerV2(modifier) + } else { + InfoPointShimmerV1(modifier, withTooltip) + } +} + +@Composable +private fun InfoPointShimmerV1(modifier: Modifier = Modifier, withTooltip: Boolean = false) { Column( modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8), horizontalAlignment = Alignment.Start, @@ -115,10 +181,34 @@ internal fun InfoPointShimmer(modifier: Modifier = Modifier, withTooltip: Boolea } } +@Composable +private fun InfoPointShimmerV2(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(vertical = TangemTheme.dimens2.x6), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .padding(end = 10.dp), + radius = TangemTheme.dimens2.x25, + ) + + RectangleShimmer( + modifier = Modifier + .width(68.dp) + .height(16.dp), + radius = TangemTheme.dimens2.x25, + ) + } +} + @Preview @Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun ContentPreview() { +private fun ContentPreviewV1() { TangemThemePreview { Column( modifier = Modifier @@ -158,6 +248,53 @@ private fun ContentPreview() { } } +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreviewV2() { + CompositionLocalProvider( + LocalRedesignEnabled provides true, + ) { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .width(150.dp) + .background(TangemTheme.colors.background.tertiary), + ) { + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000,000", + ), + ) + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000,000", + onInfoClick = { }, + ), + ) + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000", + change = InfoPointUM.ChangeType.UP, + onInfoClick = { }, + ), + ) + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000", + change = InfoPointUM.ChangeType.DOWN, + onInfoClick = { }, + ), + ) + } + } + } +} + @Preview @Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -186,7 +323,7 @@ private fun PreviewShimmer() { } }, actualContent = { - ContentPreview() + ContentPreviewV1() }, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InsightsBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InsightsBlock.kt index 69a803858b..30fd5eda07 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InsightsBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InsightsBlock.kt @@ -1,37 +1,33 @@ package com.tangem.features.feed.ui.market.detailed.components import android.content.res.Configuration -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.block.information.GridItems import com.tangem.core.ui.components.block.information.InformationBlock import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons import com.tangem.core.ui.components.text.TooltipText +import com.tangem.core.ui.ds.tabs.TangemSegmentUM +import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.utils.PreviewShimmerContainer import com.tangem.domain.markets.PriceChangeInterval import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.components.TokenMarketInformationBlock import com.tangem.features.feed.ui.market.detailed.getText import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM import com.tangem.features.feed.ui.market.detailed.state.InsightsUM @@ -40,6 +36,15 @@ import kotlinx.collections.immutable.toImmutableList @Composable internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + InsightsBlockV2(state, modifier) + } else { + InsightsBlockV1(state, modifier) + } +} + +@Composable +private fun InsightsBlockV1(state: InsightsUM, modifier: Modifier = Modifier) { var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) } InformationBlock( @@ -104,8 +109,84 @@ internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) { ) } +@Composable +private fun InsightsBlockV2(state: InsightsUM, modifier: Modifier = Modifier) { + val segmentItems = persistentListOf( + TangemSegmentUM( + id = PriceChangeInterval.H24.name, + title = resourceReference(R.string.markets_token_details_insight_day_timeline), + ), + TangemSegmentUM( + id = PriceChangeInterval.WEEK.name, + title = resourceReference(R.string.markets_token_details_insight_week_timeline), + ), + TangemSegmentUM( + id = PriceChangeInterval.MONTH.name, + title = resourceReference(R.string.markets_token_details_insight_month_timeline), + ), + ) + var currentInterval by remember { mutableStateOf(segmentItems.first()) } + + TokenMarketInformationBlock( + modifier = modifier, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResourceSafe(R.string.markets_token_details_insights), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + ) + TangemSegmentedPicker( + items = segmentItems, + initialSelectedItem = segmentItems.first(), + hasSeparator = true, + isFixed = false, + isAltSurface = true, + minSegmentWidth = 48.dp, + onClick = { segment -> + currentInterval = segment + state.onIntervalChanged(PriceChangeInterval.valueOf(segment.id)) + }, + ) + } + }, + content = { + val infoPoints = when (currentInterval.id) { + PriceChangeInterval.H24.name -> state.h24Info + PriceChangeInterval.WEEK.name -> state.weekInfo + PriceChangeInterval.MONTH.name -> state.monthInfo + else -> state.h24Info + } + + GridItems( + modifier = Modifier.padding(top = 24.dp), + items = infoPoints, + itemContent = { infoPointUM -> + InfoPoint( + modifier = Modifier.align(Alignment.CenterStart), + infoPointUM = infoPointUM, + ) + }, + ) + }, + ) +} + @Composable internal fun InsightsBlockPlaceholder(modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + InsightsBlockPlaceholderV2(modifier) + } else { + InsightsBlockPlaceholderV1(modifier) + } +} + +@Composable +internal fun InsightsBlockPlaceholderV1(modifier: Modifier = Modifier) { val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() } val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() } val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4 @@ -134,10 +215,47 @@ internal fun InsightsBlockPlaceholder(modifier: Modifier = Modifier) { ) } +@Composable +internal fun InsightsBlockPlaceholderV2(modifier: Modifier = Modifier) { + TokenMarketInformationBlock( + modifier = modifier, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + RectangleShimmer( + modifier = Modifier + .height(24.dp) + .width(120.dp), + radius = TangemTheme.dimens2.x25, + ) + + SpacerW(62.dp) + + RectangleShimmer( + modifier = Modifier + .height(36.dp) + .weight(1f), + radius = TangemTheme.dimens2.x25, + ) + } + }, + content = { + GridItems( + items = List(size = 4) { it }.toImmutableList(), + horizontalArragement = Arrangement.spacedBy(10.dp), + itemContent = { + InfoPointShimmer(modifier = Modifier.fillMaxWidth()) + }, + ) + }, + ) +} + @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun ContentPreview() { +private fun ContentPreviewV1() { TangemThemePreview { InsightsBlock( state = InsightsUM( @@ -205,11 +323,95 @@ private fun ContentPreview() { @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun PreviewPlaceholder() { +private fun ContentPreviewV2() { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + TangemThemePreviewRedesign { + InsightsBlock( + state = InsightsUM( + h24Info = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = "1 000 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = "1 000 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = "1 000 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = "1 000 000 000", + ), + ), + weekInfo = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = "1 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = "1 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = "1 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = "1 000 000", + ), + ), + monthInfo = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = "1 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = "1 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = "1 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = "1 000", + ), + ), + onInfoClick = {}, + onIntervalChanged = {}, + ), + ) + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPlaceholderV1() { TangemThemePreview { PreviewShimmerContainer( - actualContent = { ContentPreview() }, + actualContent = { ContentPreviewV1() }, shimmerContent = { InsightsBlockPlaceholder() }, ) } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPlaceholderV2() { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + PreviewShimmerContainer( + actualContent = { ContentPreviewV2() }, + shimmerContent = { InsightsBlockPlaceholder() }, + ) + } + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt index 3305077d9f..e7e041f634 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt @@ -156,17 +156,17 @@ internal fun ListedOnBlockPlaceholderV1(modifier: Modifier = Modifier) { @Composable internal fun ListedOnBlockPlaceholderV2(modifier: Modifier = Modifier) { TokenMarketInformationBlock( - modifier = modifier, + modifier = modifier.fillMaxWidth(), title = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { TextShimmer( style = TangemTheme.typography2.headingSemibold20, - modifier = Modifier.fillMaxWidth(fraction = 0.5f), + modifier = Modifier.width(120.dp), radius = TangemTheme.dimens2.x25, ) TextShimmer( style = TangemTheme.typography2.captionSemibold13, - modifier = Modifier.fillMaxWidth(fraction = 0.5f), + modifier = Modifier.width(66.dp), radius = TangemTheme.dimens2.x25, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt index ec4c86a487..70929c2ab5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt @@ -129,7 +129,7 @@ private fun SecurityScoreBlockPlaceholderV2(modifier: Modifier = Modifier) { TangemRowContainer(modifier = modifier) { TextShimmer( modifier = Modifier - .width(74.dp) + .width(114.dp) .layoutId(layoutId = TangemRowLayoutId.START_TOP), style = TangemTheme.typography2.headingBold28, radius = TangemTheme.dimens2.x25, @@ -137,7 +137,8 @@ private fun SecurityScoreBlockPlaceholderV2(modifier: Modifier = Modifier) { TextShimmer( modifier = Modifier - .width(96.dp) + .width(74.dp) + .padding(top = 8.dp) .layoutId(layoutId = TangemRowLayoutId.START_BOTTOM), style = TangemTheme.typography2.captionSemibold12, radius = TangemTheme.dimens2.x25, @@ -145,7 +146,7 @@ private fun SecurityScoreBlockPlaceholderV2(modifier: Modifier = Modifier) { TextShimmer( modifier = Modifier - .width(120.dp) + .width(116.dp) .layoutId(layoutId = TangemRowLayoutId.END_TOP), style = TangemTheme.typography2.headingBold28, radius = TangemTheme.dimens2.x25, @@ -153,7 +154,8 @@ private fun SecurityScoreBlockPlaceholderV2(modifier: Modifier = Modifier) { TextShimmer( modifier = Modifier - .width(72.dp) + .width(96.dp) + .padding(top = 8.dp) .layoutId(layoutId = TangemRowLayoutId.END_BOTTOM), style = TangemTheme.typography2.captionSemibold12, radius = TangemTheme.dimens2.x25, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt index 4abff26fa9..47d19a1751 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -26,6 +26,26 @@ internal fun LazyListScope.tokenMarketDetailsBody( state: MarketsTokenDetailsUM.Body, portfolioBlock: @Composable ((Modifier) -> Unit)?, relatedNews: RelatedNews, +) { + if (isRedesignEnabled) { + tokenMarketDetailsBodyV2( + state = state, + relatedNews = relatedNews, + ) + } else { + tokenMarketDetailsBodyV1( + state = state, + portfolioBlock = portfolioBlock, + relatedNews = relatedNews, + ) + } +} + +@Suppress("CanBeNonNullable") +private fun LazyListScope.tokenMarketDetailsBodyV1( + state: MarketsTokenDetailsUM.Body, + portfolioBlock: @Composable ((Modifier) -> Unit)?, + relatedNews: RelatedNews, ) { when (state) { MarketsTokenDetailsUM.Body.Loading -> { @@ -41,7 +61,7 @@ internal fun LazyListScope.tokenMarketDetailsBody( aboutCoinHeader() - loadingInfoBlocks(isRedesignEnabled) + loadingInfoBlocks(false) } is MarketsTokenDetailsUM.Body.Content -> { if (state.description != null) { @@ -60,9 +80,35 @@ internal fun LazyListScope.tokenMarketDetailsBody( aboutCoinHeader() - infoBlocksList( + infoBlocksListV1(state = state.infoBlocks) + } + is MarketsTokenDetailsUM.Body.Error -> { + error(state) + } + MarketsTokenDetailsUM.Body.Nothing -> { + // Do nothing + } + } +} + +@Suppress("CanBeNonNullable") +private fun LazyListScope.tokenMarketDetailsBodyV2(state: MarketsTokenDetailsUM.Body, relatedNews: RelatedNews) { + when (state) { + MarketsTokenDetailsUM.Body.Loading -> { + item("description-loading") { + DescriptionPlaceholder(modifier = Modifier.blockPaddings()) + } + + loadingInfoBlocks(true) + } + is MarketsTokenDetailsUM.Body.Content -> { + if (state.description != null) { + description(state.description) + } + + infoBlocksListV2( state = state.infoBlocks, - isRedesignEnabled = isRedesignEnabled, + relatedNews = relatedNews, ) } is MarketsTokenDetailsUM.Body.Error -> { @@ -116,7 +162,7 @@ private fun LazyListScope.description(description: MarketsTokenDetailsUM.Descrip } } -internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.InformationBlocks, isRedesignEnabled: Boolean) { +internal fun LazyListScope.infoBlocksListV1(state: MarketsTokenDetailsUM.InformationBlocks) { if (state.insights != null) { item("insights") { InsightsBlock( @@ -126,7 +172,7 @@ internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.Informati } } - if (state.securityScore != null && !isRedesignEnabled) { + if (state.securityScore != null) { item("securityScore") { SecurityScoreBlock( modifier = Modifier.blockPaddings(), @@ -160,7 +206,47 @@ internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.Informati ) } - if (state.securityScore != null && isRedesignEnabled) { + if (state.links != null) { + item("links") { + LinksBlock( + modifier = Modifier.blockPaddings(), + state = state.links, + ) + } + } +} + +internal fun LazyListScope.infoBlocksListV2(state: MarketsTokenDetailsUM.InformationBlocks, relatedNews: RelatedNews) { + if (state.metrics != null) { + item("metrics") { + MetricsBlock( + modifier = Modifier.blockPaddings(), + state = state.metrics, + ) + } + } + + if (state.insights != null) { + item("insights") { + InsightsBlock( + modifier = Modifier.blockPaddings(), + state = state.insights, + ) + } + } + + item(key = "listedOn") { + ListedOnBlock( + state = state.listedOn, + modifier = Modifier.blockPaddings(), + ) + } + + if (relatedNews.articles.isNotEmpty()) { + relatedNews(relatedNews) + } + + if (state.securityScore != null) { item("securityScore") { SecurityScoreBlock( modifier = Modifier.blockPaddings(), @@ -180,16 +266,22 @@ internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.Informati } private fun LazyListScope.loadingInfoBlocks(isRedesignEnabled: Boolean) { + if (isRedesignEnabled) { + loadingInfoBlocksV2() + } else { + loadingInfoBlocksV1() + } +} + +private fun LazyListScope.loadingInfoBlocksV1() { item("insights-loading") { InsightsBlockPlaceholder( modifier = Modifier.blockPaddings(), ) } - if (!isRedesignEnabled) { - item("securityScore-loading") { - SecurityScoreBlockPlaceholder(modifier = Modifier.blockPaddings()) - } + item("securityScore-loading") { + SecurityScoreBlockPlaceholder(modifier = Modifier.blockPaddings()) } item("metrics-loading") { @@ -204,10 +296,28 @@ private fun LazyListScope.loadingInfoBlocks(isRedesignEnabled: Boolean) { ListedOnBlockPlaceholder(modifier = Modifier.blockPaddings()) } - if (isRedesignEnabled) { - item("securityScore-loading") { - SecurityScoreBlockPlaceholder(modifier = Modifier.blockPaddings()) - } + item("links-loading") { + LinksBlockPlaceholder(modifier = Modifier.blockPaddings()) + } +} + +private fun LazyListScope.loadingInfoBlocksV2() { + item("metrics-loading") { + MetricsBlockPlaceholder(modifier = Modifier.blockPaddings()) + } + + item("insights-loading") { + InsightsBlockPlaceholder( + modifier = Modifier.blockPaddings(), + ) + } + + item(key = "listedOn-loading") { + ListedOnBlockPlaceholder(modifier = Modifier.blockPaddings()) + } + + item("securityScore-loading") { + SecurityScoreBlockPlaceholder(modifier = Modifier.blockPaddings()) } item("links-loading") { From 1470d4b08d30510e94f2c9900cc133f5178a0814 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 17 Mar 2026 08:46:19 +0100 Subject: [PATCH 52/60] Updated on 2026-08-14 --- .../TangemLinearProgressIndicator.kt | 12 +- .../progress/TangemLinearProgressIndicator.kt | 159 +++++++ .../res/drawable/ic_big_laurel_left_20.xml | 9 + .../res/drawable/ic_big_laurel_right_20.xml | 9 + .../details/MarketsTokenDetailsModel.kt | 3 + .../details/converter/MetricsConverter.kt | 209 ++++++++- .../converter/TokenMarketInfoConverter.kt | 2 + .../feed/ui/components/MetricsCard.kt | 77 ++++ .../earn/components/EarnFilterBottomSheet.kt | 3 + .../EarnFilterByNetworkBottomSheet.kt | 6 + .../components/EarnFilterByTypeBottomSheet.kt | 3 + .../components/InformationTextBlock.kt | 45 +- .../detailed/components/MetricsBlock.kt | 177 +++++++- .../detailed/components/MetricsCards.kt | 417 ++++++++++++++++++ .../detailed/components/SecurityScoreBlock.kt | 66 +-- .../components/TokenMarketDetailsBody.kt | 4 +- .../preview/MarketsTokenDetailsPreview.kt | 1 + .../ui/market/detailed/state/MetricsUM.kt | 63 ++- 18 files changed, 1201 insertions(+), 64 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/progress/TangemLinearProgressIndicator.kt create mode 100644 core/ui/src/main/res/drawable/ic_big_laurel_left_20.xml create mode 100644 core/ui/src/main/res/drawable/ic_big_laurel_right_20.xml create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/MetricsCard.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/progressbar/TangemLinearProgressIndicator.kt b/core/ui/src/main/java/com/tangem/core/ui/components/progressbar/TangemLinearProgressIndicator.kt index 3e27fbe865..3975af67b0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/progressbar/TangemLinearProgressIndicator.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/progressbar/TangemLinearProgressIndicator.kt @@ -7,7 +7,8 @@ import androidx.compose.animation.core.* import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -21,6 +22,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.offset +import com.tangem.core.ui.ds.progress.TangemLinearProgressIndicatorWithDot import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import kotlin.math.abs @@ -278,6 +280,14 @@ private fun LinearProgressIndicator_Preview() { color = TangemTheme.colors.icon.primary1, backgroundColor = TangemTheme.colors.background.tertiary, ) + TangemLinearProgressIndicatorWithDot( + progress = { 1f }, + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + dotColor = TangemTheme.colors.icon.primary1, + backgroundColor = TangemTheme.colors.background.tertiary, + ) TangemLinearProgressIndicator( modifier = Modifier.fillMaxWidth(), color = TangemTheme.colors.icon.primary1, diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/progress/TangemLinearProgressIndicator.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/progress/TangemLinearProgressIndicator.kt new file mode 100644 index 0000000000..5eca9a5f3e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/progress/TangemLinearProgressIndicator.kt @@ -0,0 +1,159 @@ +package com.tangem.core.ui.ds.progress + +import android.content.res.Configuration +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +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.progressbar.increaseSemanticsBounds +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import kotlin.math.abs + +/** + * Progress indicator with a dot that reflects the current progress position. + * The dot is drawn on top of the background line. Dot diameter equals the track height. + * At progress 0 the dot's left edge aligns with the track's left edge; + * at progress 1 the dot's right edge aligns with the track's right edge. + * + * @param progress The progress of this indicator, where 0.0 represents no progress and 1.0 + * represents full progress. Values outside of this range are coerced into the range. + * @param modifier the [Modifier] to be applied to this progress indicator + * @param dotColor The color of the progress dot. + * @param backgroundColor The color of the background track. + * @param strokeCap stroke cap to use for the ends of the background track + */ +@Composable +fun TangemLinearProgressIndicatorWithDot( + progress: () -> Float, + dotColor: Color, + backgroundColor: Color, + modifier: Modifier = Modifier, + strokeCap: StrokeCap = StrokeCap.Round, +) { + val coercedProgress = { progress().coerceIn(0f, 1f) } + Canvas( + modifier + .increaseSemanticsBounds() + .semantics(mergeDescendants = true) { + progressBarRangeInfo = ProgressBarRangeInfo(coercedProgress(), 0f..1f) + }, + ) { + val strokeWidth = size.height + drawLinearIndicatorBackground(backgroundColor, strokeWidth, strokeCap) + drawLinearProgressDot( + progress = coercedProgress(), + color = dotColor, + dotDiameter = strokeWidth, + ) + } +} + +private fun DrawScope.drawLinearIndicatorBackground(color: Color, strokeWidth: Float, strokeCap: StrokeCap) = + drawLinearIndicator( + startFraction = 0f, + endFraction = 1f, + color = color, + strokeWidth = strokeWidth, + strokeCap = strokeCap, + ) + +private fun DrawScope.drawLinearProgressDot(progress: Float, color: Color, dotDiameter: Float) { + val width = size.width + val radius = dotDiameter / 2 + val yOffset = size.height / 2 + + val isLtr = layoutDirection == LayoutDirection.Ltr + val centerX = if (isLtr) { + radius + progress * (width - dotDiameter) + } else { + width - radius - progress * (width - dotDiameter) + } + + drawCircle( + color = color, + radius = radius, + center = Offset(centerX, yOffset), + ) +} + +private fun DrawScope.drawLinearIndicator( + startFraction: Float, + endFraction: Float, + color: Color, + strokeWidth: Float, + strokeCap: StrokeCap, +) { + val width = size.width + val height = size.height + // Start drawing from the vertical center of the stroke + val yOffset = height / 2 + + val isLtr = layoutDirection == LayoutDirection.Ltr + val barStart = (if (isLtr) startFraction else 1f - endFraction) * width + val barEnd = (if (isLtr) endFraction else 1f - startFraction) * width + + // if there isn't enough space to draw the stroke caps, fall back to StrokeCap.Butt + if (strokeCap == StrokeCap.Butt || height > width) { + // Progress line + drawLine( + color = color, + start = Offset(barStart, yOffset), + end = Offset(barEnd, yOffset), + strokeWidth = strokeWidth, + ) + } else { + // need to adjust barStart and barEnd for the stroke caps + val strokeCapOffset = strokeWidth / 2 + val coerceRange = strokeCapOffset..width - strokeCapOffset + val adjustedBarStart = barStart.coerceIn(coerceRange) + val adjustedBarEnd = barEnd.coerceIn(coerceRange) + + if (abs(endFraction - startFraction) > 0) { + // Progress line + drawLine( + color = color, + start = Offset(adjustedBarStart, yOffset), + end = Offset(adjustedBarEnd, yOffset), + strokeWidth = strokeWidth, + cap = strokeCap, + ) + } + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun LinearProgressIndicator_Preview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .background(TangemTheme.colors.background.secondary) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TangemLinearProgressIndicatorWithDot( + progress = { 1f }, + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + dotColor = TangemTheme.colors2.fill.status.accent, + backgroundColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + ) + } + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_big_laurel_left_20.xml b/core/ui/src/main/res/drawable/ic_big_laurel_left_20.xml new file mode 100644 index 0000000000..92f617c554 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_big_laurel_left_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_big_laurel_right_20.xml b/core/ui/src/main/res/drawable/ic_big_laurel_right_20.xml new file mode 100644 index 0000000000..72a3061544 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_big_laurel_right_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index a63fc181a1..e371378488 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -16,6 +16,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.marketprice.PriceChangeType @@ -77,6 +78,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, getUserCountryUseCase: GetUserCountryUseCase, paramsContainer: ParamsContainer, + designFeatureToggles: DesignFeatureToggles, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, private val getTokenFullQuotesUseCase: GetTokenFullQuotesUseCase, @@ -147,6 +149,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( needApplyFCARestrictions = Provider { userCountry.needApplyFCARestrictions() }, + isRedesignEnabled = designFeatureToggles.isRedesignEnabled, // ================== ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/MetricsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/MetricsConverter.kt index e849e636cf..76cd1bed72 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/MetricsConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/MetricsConverter.kt @@ -2,6 +2,8 @@ package com.tangem.features.feed.model.market.details.converter import androidx.compose.runtime.Stable import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.compact import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat @@ -9,20 +11,23 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketInfo import com.tangem.features.feed.impl.R -import com.tangem.features.feed.ui.market.detailed.state.InfoBottomSheetContent -import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM -import com.tangem.features.feed.ui.market.detailed.state.MetricsUM +import com.tangem.features.feed.ui.market.detailed.state.* import com.tangem.utils.Provider import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal +import java.math.RoundingMode +@Suppress("LargeClass") @Stable internal class MetricsConverter( private val appCurrency: Provider, private val tokenSymbol: String, private val onInfoClick: (InfoBottomSheetContent) -> Unit, + private val isRedesignEnabled: Boolean, ) : Converter { @Suppress("LongMethod") @@ -115,10 +120,120 @@ internal class MetricsConverter( }, ), ), + metricsV2 = if (isRedesignEnabled) { + convertToMetricsV2UM(value) + } else { + null + }, ) } } + @Suppress("LongMethod", "NestedScopeFunctions") + private fun convertToMetricsV2UM(value: TokenMarketInfo.Metrics): MetricsV2UM { + val infoPoints = with(value) { + val liquidity = getLiquidity(value.volume24h, value.marketCap) + persistentListOf( + InfoPointUMV2.MarketCap( + capitalizationValue = stringReference(marketCap.formatAmount()), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference( + R.string.markets_token_details_market_capitalization_full, + ), + body = resourceReference( + R.string.markets_token_details_market_capitalization_description, + ), + ), + ) + }, + ), + InfoPointUMV2.TradingVolume( + tradingValue = stringReference(volume24h.formatAmount()), + liquidity = liquidity, + trendingVolumeLiquidityType = getTrendingVolumeLiquidityType(liquidity), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_trading_volume_full), + body = resourceReference( + R.string.markets_token_details_trading_volume_24h_description, + ), + ), + ) + }, + ), + InfoPointUMV2.MarketPosition( + position = marketRating?.toString() ?: StringsSigns.DASH_SIGN, + rangeValue = getMarketRatingRangeValue(marketRating), + marketRatingType = getMarketRatingType(marketRating), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_market_rating_full), + body = resourceReference(R.string.markets_token_details_market_rating_description), + ), + ) + }, + ), + InfoPointUMV2.FullyDilutedValuation( + value = resourceReference( + R.string.markets_token_details_valuation_value_in_total, + wrappedList(fullyDilutedValuation.formatAmount()), + ), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference( + R.string.markets_token_details_fully_diluted_valuation_full, + ), + body = resourceReference( + R.string.markets_token_details_fully_diluted_valuation_description, + ), + ), + ) + }, + ), + InfoPointUMV2.CirculatingSupply( + currentValue = stringReference(circulatingSupply.formatAmount(crypto = true)), + maxValue = maxSupply?.let { supply -> + if (supply > BigDecimal.ZERO) { + stringReference(supply.formatMaxSupply()) + } else { + null + } + }, + fillValue = getCirculatingSupplyFillValue(circulatingSupply, maxSupply), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_max_supply_full), + body = resourceReference(R.string.markets_token_details_total_supply_description), + ), + ) + }, + ), + ) + } + + return buildMetricsV2UM(infoPoints) + } + + private fun buildMetricsV2UM(infoPoints: ImmutableList): MetricsV2UM { + val rows = infoPoints.chunked(size = 2) + .map { chunk -> + MetricsV2UM.Row( + first = chunk.first(), + second = chunk.getOrNull(1), + ) + } + + return MetricsV2UM( + rows = rows.toImmutableList(), + ) + } + private fun BigDecimal?.formatMaxSupply(): String { when (this) { null -> return StringsSigns.DASH_SIGN @@ -149,4 +264,92 @@ internal class MetricsConverter( } } } + + @Suppress("MagicNumber") + private fun getLiquidity(volume24h: BigDecimal?, marketCap: BigDecimal?): Float { + if (volume24h == null || marketCap == null || marketCap == BigDecimal.ZERO) return 0f + + val ratio = volume24h + .divide(marketCap, 6, RoundingMode.HALF_UP) + .toFloat() + + return ratio.coerceIn(0f, 1f) + } + + @Suppress("MagicNumber") + private fun getTrendingVolumeLiquidityType(liquidity: Float): TrendingVolumeLiquidityType { + return when { + liquidity >= 0.5f -> TrendingVolumeLiquidityType.HIGH + liquidity in 0.2f..<0.5f -> TrendingVolumeLiquidityType.MEDIUM + else -> TrendingVolumeLiquidityType.LOW + } + } + + @Suppress("MagicNumber") + private fun getMarketRatingType(marketRating: Int?): MarketRatingType { + return when (marketRating) { + 1 -> MarketRatingType.GOLD + 2 -> MarketRatingType.SILVER + 3 -> MarketRatingType.BRONZE + else -> MarketRatingType.OTHER + } + } + + @Suppress("MagicNumber") + private fun getMarketRatingRangeValue(marketRating: Int?): Float { + if (marketRating == null) return 0f + + return when { + marketRating <= 20 -> { + val segmentStart = 1f + val segmentEnd = 0.75f + val rangeMin = 1 + val rangeMax = 20 + val progress = segmentStart + + (marketRating - rangeMin).toFloat() / (rangeMax - rangeMin) * (segmentEnd - segmentStart) + progress.coerceIn(0f, 1f) + } + marketRating <= 100 -> { + val segmentStart = 0.76f + val segmentEnd = 0.5f + val rangeMin = 21 + val rangeMax = 100 + val progress = segmentStart + + (marketRating - rangeMin).toFloat() / (rangeMax - rangeMin) * (segmentEnd - segmentStart) + progress.coerceIn(0f, 1f) + } + marketRating <= 1000 -> { + val segmentStart = 0.51f + val segmentEnd = 0.25f + val rangeMin = 101 + val rangeMax = 1000 + val progress = segmentStart + + (marketRating - rangeMin).toFloat() / (rangeMax - rangeMin) * (segmentEnd - segmentStart) + progress.coerceIn(0f, 1f) + } + marketRating <= 10000 -> { + val segmentStart = 0.26f + val segmentEnd = 0.01f + val rangeMin = 1001 + val rangeMax = 10000 + val progress = segmentStart + + (marketRating - rangeMin).toFloat() / (rangeMax - rangeMin) * (segmentEnd - segmentStart) + progress.coerceIn(0f, 1f) + } + else -> { + 0f + } + } + } + + @Suppress("MagicNumber") + private fun getCirculatingSupplyFillValue(circulatingSupply: BigDecimal?, maxSupply: BigDecimal?): Float? { + if (circulatingSupply == null || maxSupply == null || maxSupply == BigDecimal.ZERO) return null + + val ratio = circulatingSupply + .divide(maxSupply, 6, RoundingMode.HALF_UP) + .toFloat() + + return ratio.coerceIn(0f, 1f) + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/TokenMarketInfoConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/TokenMarketInfoConverter.kt index b0c2eddb35..b564fce453 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/TokenMarketInfoConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/TokenMarketInfoConverter.kt @@ -15,6 +15,7 @@ import com.tangem.utils.converter.Converter @Stable @Suppress("LongParameterList") internal class TokenMarketInfoConverter( + private val isRedesignEnabled: Boolean, private val appCurrency: Provider, private val needApplyFCARestrictions: Provider, private val onInfoClick: (TangemBottomSheetConfigContent) -> Unit, @@ -47,6 +48,7 @@ internal class TokenMarketInfoConverter( tokenSymbol = value.symbol, appCurrency = appCurrency, onInfoClick = onInfoClick, + isRedesignEnabled = isRedesignEnabled, ) val exchangesAmount = value.exchangesAmount diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/MetricsCard.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/MetricsCard.kt new file mode 100644 index 0000000000..ed671b018a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/MetricsCard.kt @@ -0,0 +1,77 @@ +package com.tangem.features.feed.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +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.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.extensions.conditional +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun MetricsCard( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + cardColor: Color = TangemTheme.colors2.surface.level3, + title: @Composable () -> Unit, + content: @Composable () -> Unit, +) { + Column( + modifier = modifier + .background( + color = cardColor, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ) + .conditional( + condition = onClick != null, + modifier = { + if (onClick != null) { + clickable(onClick = onClick) + } else { + this + } + }, + ) + .padding(TangemTheme.dimens2.x4), + verticalArrangement = Arrangement.SpaceBetween, + ) { + title() + content() + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun MetricsCardPreview() { + TangemThemePreviewRedesign { + MetricsCard( + modifier = Modifier.heightIn(120.dp), + title = { + Text( + text = "$ 22.4 M", + style = TangemTheme.typography2.headingBold22, + color = TangemTheme.colors2.text.neutral.primary, + ) + }, + content = { + Text( + text = "Market cap", + style = TangemTheme.typography2.captionSemibold12, + color = TangemTheme.colors2.text.neutral.tertiary, + ) + }, + onClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt index 947d8e8e1b..961de3106d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt @@ -9,6 +9,7 @@ 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.text.style.TextOverflow import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet @@ -40,6 +41,8 @@ internal inline fun EarnFilterBotto style = TangemTheme.typography2.headingSemibold17, color = TangemTheme.colors2.text.neutral.primary, textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) SecondaryTangemButton( modifier = Modifier.align(Alignment.CenterEnd), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt index 78dd407f9d..8a0233f7b8 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt @@ -161,6 +161,8 @@ private fun NetworksTypesBlock( }.resolveReference(), style = TangemTheme.typography2.bodySemibold16, color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) TangemCheckbox( @@ -189,6 +191,8 @@ private fun SpecificNetworksBlock( text = stringResourceSafe(id = R.string.earn_filter_networks), style = TangemTheme.typography2.bodyRegular14, color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) specificNetworks.fastForEachIndexed { index, item -> TangemRowContainer( @@ -215,6 +219,8 @@ private fun SpecificNetworksBlock( text = item.text, style = TangemTheme.typography2.bodySemibold16, color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) TangemCheckbox( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt index 4a77e0adbd..396599caca 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig @@ -110,6 +111,8 @@ private fun ContentV2(content: EarnFilterByTypeBottomSheetContentUM) { text = type.text.resolveReference(), style = TangemTheme.typography2.bodySemibold16, color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) TangemCheckbox( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt index c0d6ca107e..e6cfb24267 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt @@ -4,10 +4,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -16,43 +14,37 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import com.tangem.core.ui.R import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.conditional import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme @Composable internal fun InformationTextBlock( text: TextReference, - onInfoClick: () -> Unit, modifier: Modifier = Modifier, - textStyle: TextStyle = TangemTheme.typography2.captionSemibold12, - textColor: Color = TangemTheme.colors2.text.neutral.secondary, + onInfoClick: (() -> Unit)? = null, + textColor: Color = TangemTheme.colors2.text.neutral.tertiary, + infoIconColor: Color = TangemTheme.colors2.markers.iconGray, informationTextBlockIconPosition: InformationTextBlockIconPosition = InformationTextBlockIconPosition.START, ) { val interactionSource = remember { MutableInteractionSource() } val infoIcon: @Composable () -> Unit = { - IconButton( - modifier = Modifier.requiredSize(TangemTheme.dimens2.x4), - interactionSource = interactionSource, - onClick = onInfoClick, - ) { - Icon( - modifier = Modifier.size(TangemTheme.dimens2.x4), - imageVector = ImageVector.vectorResource(id = R.drawable.ic_information_24), - tint = TangemTheme.colors2.markers.iconGray, - contentDescription = null, - ) - } + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x4), + imageVector = ImageVector.vectorResource(id = R.drawable.ic_information_24), + tint = infoIconColor, + contentDescription = null, + ) } val contentText: @Composable () -> Unit = { Text( text = text.resolveReference(), - style = textStyle, + style = TangemTheme.typography2.captionSemibold12, color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -61,10 +53,17 @@ internal fun InformationTextBlock( Row( modifier = modifier - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onInfoClick, + .conditional( + condition = onInfoClick != null, + modifier = { + onInfoClick?.let { infoClick -> + clickable( + interactionSource = interactionSource, + indication = null, + onClick = infoClick, + ) + } ?: this + }, ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsBlock.kt index 1059eeb198..396ef07835 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsBlock.kt @@ -1,14 +1,14 @@ package com.tangem.features.feed.ui.market.detailed.components import android.content.res.Configuration -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.* import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.TextButton import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.block.information.GridItems @@ -16,12 +16,16 @@ import com.tangem.core.ui.components.block.information.InformationBlock import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.PreviewShimmerContainer import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.components.MetricsCard import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM +import com.tangem.features.feed.ui.market.detailed.state.InfoPointUMV2 import com.tangem.features.feed.ui.market.detailed.state.MetricsUM +import com.tangem.features.feed.ui.market.detailed.state.MetricsV2UM import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -29,6 +33,17 @@ const val MAX_METRICS_COUNT = 6 @Composable internal fun MetricsBlock(state: MetricsUM, modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + state.metricsV2?.let { + MetricsBlockV2(it, modifier) + } + } else { + MetricsBlockV1(state, modifier) + } +} + +@Composable +private fun MetricsBlockV1(state: MetricsUM, modifier: Modifier = Modifier) { var isExpanded by remember { mutableStateOf(false) } InformationBlock( @@ -64,6 +79,50 @@ internal fun MetricsBlock(state: MetricsUM, modifier: Modifier = Modifier) { ) } +@Composable +private fun MetricsBlockV2(state: MetricsV2UM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + state.rows.forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + Box(Modifier.weight(1f)) { + MetricRowItem(row.first) + } + + row.second?.let { second -> + Box(Modifier.weight(1f)) { + MetricRowItem(second) + } + } + } + } + } +} + +@Composable +private fun MetricRowItem(item: InfoPointUMV2) { + when (item) { + is InfoPointUMV2.CirculatingSupply -> CirculatingSupplyCard(item) + else -> MetricCard(item) + } +} + +@Composable +private fun MetricCard(item: InfoPointUMV2) { + when (item) { + is InfoPointUMV2.MarketCap -> MarketCapCard(item) + is InfoPointUMV2.TradingVolume -> TradingVolumeCard(item) + is InfoPointUMV2.MarketPosition -> MarketPositionCard(item) + is InfoPointUMV2.FullyDilutedValuation -> FDVCard(item) + is InfoPointUMV2.CirculatingSupply -> Unit + } +} + // TODO make TextButton clickable area smaller and remove paddings for an action in InformationBlock @Composable private fun ShowLessMoreButton(expanded: Boolean, onClick: () -> Unit) { @@ -84,6 +143,15 @@ private fun ShowLessMoreButton(expanded: Boolean, onClick: () -> Unit) { @Composable internal fun MetricsBlockPlaceholder(modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + MetricsBlockPlaceholderV2(modifier) + } else { + MetricsBlockPlaceholderV1(modifier) + } +} + +@Composable +private fun MetricsBlockPlaceholderV1(modifier: Modifier = Modifier) { InformationBlock( modifier = modifier, title = { @@ -111,6 +179,108 @@ internal fun MetricsBlockPlaceholder(modifier: Modifier = Modifier) { ) } +@Composable +private fun MetricsBlockPlaceholderV2(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + repeat(2) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + repeat(2) { + Box(Modifier.weight(1f)) { + MetricsCard( + modifier = Modifier + .heightIn(120.dp) + .fillMaxWidth(), + title = { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(28.dp) + .padding(end = 10.dp), + radius = TangemTheme.dimens2.x25, + ) + }, + content = { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(16.dp) + .padding(end = 74.dp), + radius = TangemTheme.dimens2.x25, + ) + }, + ) + } + } + } + } + CirculatingSupplyCardPlaceholder() + } +} + +@Composable +private fun CirculatingSupplyCardPlaceholder() { + MetricsCard( + modifier = Modifier + .heightIn(120.dp) + .fillMaxWidth(), + title = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + RectangleShimmer( + modifier = Modifier + .width(104.dp) + .height(16.dp), + radius = TangemTheme.dimens2.x25, + ) + RectangleShimmer( + modifier = Modifier + .width(64.dp) + .height(16.dp), + radius = TangemTheme.dimens2.x25, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + RectangleShimmer( + modifier = Modifier + .width(160.dp) + .height(28.dp), + radius = TangemTheme.dimens2.x25, + ) + RectangleShimmer( + modifier = Modifier + .width(48.dp) + .height(28.dp), + radius = TangemTheme.dimens2.x25, + ) + } + } + }, + content = { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + radius = TangemTheme.dimens2.x25, + ) + }, + ) +} + @Preview @Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -150,6 +320,7 @@ private fun BlockPreview() { onInfoClick = {}, ), ), + metricsV2 = null, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt new file mode 100644 index 0000000000..a5a936a6c8 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt @@ -0,0 +1,417 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.progressbar.TangemLinearProgressIndicator +import com.tangem.core.ui.ds.progress.TangemLinearProgressIndicatorWithDot +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +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.LocalIsInDarkTheme +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.components.MetricsCard +import com.tangem.features.feed.ui.market.detailed.state.InfoPointUMV2 +import com.tangem.features.feed.ui.market.detailed.state.MarketRatingType +import com.tangem.features.feed.ui.market.detailed.state.TrendingVolumeLiquidityType + +@Composable +internal fun MarketCapCard(item: InfoPointUMV2.MarketCap) { + MetricsCard( + modifier = Modifier + .heightIn(120.dp) + .fillMaxWidth(), + title = { + Text( + text = item.capitalizationValue.resolveReference(), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + content = { + InformationTextBlock( + text = resourceReference(R.string.markets_token_details_market_capitalization), + onInfoClick = item.onInfoClick, + ) + }, + ) +} + +@Composable +internal fun TradingVolumeCard(item: InfoPointUMV2.TradingVolume) { + val tradingColor = when (item.trendingVolumeLiquidityType) { + TrendingVolumeLiquidityType.HIGH -> TangemTheme.colors2.markers.backgroundSolidGreen + TrendingVolumeLiquidityType.MEDIUM -> TangemTheme.colors2.graphic.status.attention + TrendingVolumeLiquidityType.LOW -> TangemTheme.colors2.graphic.status.warning + } + MetricsCard( + modifier = Modifier + .heightIn(120.dp) + .fillMaxWidth(), + title = { + Row { + Text( + text = item.tradingValue.resolveReference(), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + modifier = Modifier.padding(TangemTheme.dimens2.x1), + text = stringResourceSafe(R.string.markets_token_details_trading_interval), + style = TangemTheme.typography2.captionSemibold11, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + content = { + Column(modifier = Modifier.fillMaxWidth()) { + TangemLinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + progress = { item.liquidity }, + color = tradingColor, + backgroundColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant.copy(alpha = .1f), + ) + SpacerH(12.dp) + InformationTextBlock( + text = resourceReference(R.string.markets_token_details_trading_volume), + textColor = tradingColor, + infoIconColor = tradingColor, + onInfoClick = item.onInfoClick, + ) + } + }, + cardColor = tradingColor.copy(alpha = .2f), + ) +} + +@Composable +internal fun MarketPositionCard(item: InfoPointUMV2.MarketPosition) { + val ratingCardColor = mapRatingToCardColor(marketRatingType = item.marketRatingType) + val ratingColor = mapRatingToColor(marketRatingType = item.marketRatingType) + + MetricsCard( + modifier = Modifier + .heightIn(120.dp) + .fillMaxWidth(), + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_big_laurel_left_20), + tint = ratingColor, + contentDescription = null, + ) + + Text( + textAlign = TextAlign.Center, + text = item.position, + color = ratingColor, + style = TangemTheme.typography2.headingSemibold20.copy(letterSpacing = 0.sp), + maxLines = 1, + ) + + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_big_laurel_right_20), + tint = ratingColor, + contentDescription = null, + ) + } + }, + content = { + Column(modifier = Modifier.fillMaxWidth()) { + TangemLinearProgressIndicatorWithDot( + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + progress = { item.rangeValue }, + dotColor = TangemTheme.colors2.fill.neutral.primaryInvertedConstant, + backgroundColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant.copy(alpha = .1f), + ) + SpacerH(12.dp) + InformationTextBlock( + text = resourceReference(R.string.markets_token_details_market_rating), + textColor = ratingColor, + infoIconColor = ratingColor, + onInfoClick = item.onInfoClick, + ) + } + }, + cardColor = ratingCardColor, + ) +} + +@Composable +internal fun FDVCard(item: InfoPointUMV2.FullyDilutedValuation) { + MetricsCard( + modifier = Modifier + .heightIn(120.dp) + .fillMaxWidth(), + title = { + Text( + text = item.value.resolveReference(), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + content = { + InformationTextBlock( + text = resourceReference(R.string.markets_token_details_fully_diluted_valuation), + onInfoClick = item.onInfoClick, + ) + }, + ) +} + +@Composable +internal fun CirculatingSupplyCard(item: InfoPointUMV2.CirculatingSupply) { + MetricsCard( + modifier = Modifier + .heightIn(min = if (item.fillValue == null) 88.dp else 114.dp) + .fillMaxWidth(), + title = { + TangemRowContainer(contentPadding = PaddingValues(0.dp)) { + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = stringResourceSafe(R.string.markets_token_details_circulating_supply), + style = TangemTheme.typography2.captionSemibold13, + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + modifier = Modifier + .padding(top = 12.dp) + .layoutId(TangemRowLayoutId.START_BOTTOM), + text = item.currentValue.resolveReference(), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), + text = stringResourceSafe(R.string.markets_token_details_max_supply), + style = TangemTheme.typography2.captionSemibold13, + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (item.maxValue != null) { + Text( + modifier = Modifier + .padding(top = 12.dp) + .layoutId(TangemRowLayoutId.END_BOTTOM), + text = item.maxValue.resolveReference(), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + content = { + if (item.fillValue != null) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + color = TangemTheme.colors2.graphic.status.accent, + trackColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant.copy(alpha = .1f), + progress = { item.fillValue }, + strokeCap = StrokeCap.Round, + drawStopIndicator = {}, + gapSize = 4.dp, + ) + } + }, + onClick = item.onInfoClick, + ) +} + +@Composable +private fun MarketRatingType.baseColor(): Color { + val isDarkTheme = LocalIsInDarkTheme.current + + return when (this) { + MarketRatingType.GOLD -> + if (isDarkTheme) Color(GOLD_PLACE_COLOR_NIGHT) else Color(GOLD_PLACE_COLOR_LIGHT) + + MarketRatingType.SILVER -> + if (isDarkTheme) Color(SILVER_PLACE_COLOR_NIGHT) else Color(SILVER_PLACE_COLOR_LIGHT) + + MarketRatingType.BRONZE -> + if (isDarkTheme) Color(BRONZE_PLACE_COLOR_NIGHT) else Color(BRONZE_PLACE_COLOR_LIGHT) + + MarketRatingType.OTHER -> + TangemTheme.colors2.graphic.neutral.primary + } +} + +@Composable +private fun mapRatingToColor(marketRatingType: MarketRatingType): Color = marketRatingType.baseColor() + +@Composable +private fun mapRatingToCardColor(marketRatingType: MarketRatingType): Color { + return when (marketRatingType) { + MarketRatingType.OTHER -> TangemTheme.colors2.surface.level3 + else -> marketRatingType.baseColor().copy(alpha = 0.3f) + } +} + +private const val GOLD_PLACE_COLOR_NIGHT = 0xFFFBEE76 +private const val GOLD_PLACE_COLOR_LIGHT = 0xFFD9B900 +private const val SILVER_PLACE_COLOR_NIGHT = 0xFFAABEF7 +private const val SILVER_PLACE_COLOR_LIGHT = 0xFF6680CC +private const val BRONZE_PLACE_COLOR_NIGHT = 0xFFFF9976 +private const val BRONZE_PLACE_COLOR_LIGHT = 0xFFCC7F66 + +@Suppress("LongMethod") +@Preview(widthDp = 360, heightDp = 1500, showBackground = true) +@Preview(widthDp = 360, heightDp = 1500, showBackground = true, locale = "ru") +@Preview(widthDp = 360, heightDp = 1500, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun MetricsCardsPreview() { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level2) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + MarketCapCard( + item = InfoPointUMV2.MarketCap( + capitalizationValue = stringReference("$ 1.2 T"), + onInfoClick = {}, + ), + ) + + TradingVolumeCard( + item = InfoPointUMV2.TradingVolume( + tradingValue = stringReference("$ 45.2 M"), + liquidity = 0.75f, + trendingVolumeLiquidityType = TrendingVolumeLiquidityType.HIGH, + onInfoClick = {}, + ), + ) + + TradingVolumeCard( + item = InfoPointUMV2.TradingVolume( + tradingValue = stringReference("$ 12.1 M"), + liquidity = 0.45f, + trendingVolumeLiquidityType = TrendingVolumeLiquidityType.MEDIUM, + onInfoClick = {}, + ), + ) + + TradingVolumeCard( + item = InfoPointUMV2.TradingVolume( + tradingValue = stringReference("$ 2.3 M"), + liquidity = 0.15f, + trendingVolumeLiquidityType = TrendingVolumeLiquidityType.LOW, + onInfoClick = {}, + ), + ) + + MarketPositionCard( + item = InfoPointUMV2.MarketPosition( + position = "1", + rangeValue = 0.02f, + marketRatingType = MarketRatingType.GOLD, + onInfoClick = {}, + ), + ) + + MarketPositionCard( + item = InfoPointUMV2.MarketPosition( + position = "2", + rangeValue = 0.05f, + marketRatingType = MarketRatingType.SILVER, + onInfoClick = {}, + ), + ) + + MarketPositionCard( + item = InfoPointUMV2.MarketPosition( + position = "3", + rangeValue = 0.08f, + marketRatingType = MarketRatingType.BRONZE, + onInfoClick = {}, + ), + ) + + MarketPositionCard( + item = InfoPointUMV2.MarketPosition( + position = "42", + rangeValue = 0.42f, + marketRatingType = MarketRatingType.OTHER, + onInfoClick = {}, + ), + ) + + FDVCard( + item = InfoPointUMV2.FullyDilutedValuation( + value = stringReference("$ 1.5 T"), + onInfoClick = {}, + ), + ) + + CirculatingSupplyCard( + item = InfoPointUMV2.CirculatingSupply( + currentValue = stringReference("12.5 B POL"), + maxValue = stringReference("21 B POL"), + fillValue = 0.6f, + onInfoClick = {}, + ), + ) + + CirculatingSupplyCard( + item = InfoPointUMV2.CirculatingSupply( + currentValue = stringReference("18.9 M ETH"), + maxValue = null, + fillValue = null, + onInfoClick = {}, + ), + ) + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt index 70929c2ab5..dd7c78854c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt @@ -27,6 +27,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.utils.PreviewShimmerContainer import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.components.ContainerWithDivider import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreUM @Composable @@ -79,39 +80,44 @@ private fun SecurityScoreBlockV1(state: SecurityScoreUM, modifier: Modifier = Mo @Composable private fun SecurityScoreBlockV2(state: SecurityScoreUM, modifier: Modifier = Modifier) { - TangemRowContainer(modifier = modifier) { - Text( - modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), - text = "${state.score}", - color = TangemTheme.colors2.text.neutral.primary, - style = TangemTheme.typography2.headingBold28, - ) + ContainerWithDivider( + modifier = modifier, + showDivider = true, + ) { + TangemRowContainer(modifier = Modifier.padding(top = 20.dp, bottom = 24.dp)) { + Text( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), + text = "${state.score}", + color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.headingBold28, + ) - InformationTextBlock( - modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_BOTTOM), - text = resourceReference(R.string.markets_token_details_security_score), - onInfoClick = state.onInfoClick, - textColor = TangemTheme.colors2.text.neutral.primary, - informationTextBlockIconPosition = InformationTextBlockIconPosition.END, - ) + InformationTextBlock( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_BOTTOM), + text = resourceReference(R.string.markets_token_details_security_score), + onInfoClick = state.onInfoClick, + textColor = TangemTheme.colors2.text.neutral.primary, + informationTextBlockIconPosition = InformationTextBlockIconPosition.END, + ) - Text( - modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM), - text = state.description.resolveReference(), - style = TangemTheme.typography2.captionSemibold12, - color = TangemTheme.colors2.text.neutral.tertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM), + text = state.description.resolveReference(), + style = TangemTheme.typography2.captionSemibold12, + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - ScoreStarsBlock( - modifier = Modifier - .padding(bottom = 16.dp) - .layoutId(layoutId = TangemRowLayoutId.END_TOP), - score = state.score, - scoreTextStyle = TangemTheme.typography.body1, - horizontalSpacing = TangemTheme.dimens.spacing8, - ) + ScoreStarsBlock( + modifier = Modifier + .padding(bottom = 16.dp) + .layoutId(layoutId = TangemRowLayoutId.END_TOP), + score = state.score, + scoreTextStyle = TangemTheme.typography.body1, + horizontalSpacing = TangemTheme.dimens.spacing8, + ) + } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt index 47d19a1751..de92450ef8 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -307,9 +307,7 @@ private fun LazyListScope.loadingInfoBlocksV2() { } item("insights-loading") { - InsightsBlockPlaceholder( - modifier = Modifier.blockPaddings(), - ) + InsightsBlockPlaceholder(modifier = Modifier.blockPaddings()) } item(key = "listedOn-loading") { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt index 70c4db0103..ed7261d399 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt @@ -104,6 +104,7 @@ internal object MarketsTokenDetailsPreview { infoPoint, infoPoint, ), + metricsV2 = null, ), pricePerformance = PricePerformanceUM( h24 = PricePerformanceUM.Value( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MetricsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MetricsUM.kt index 75552e0a4a..4279c29373 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MetricsUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MetricsUM.kt @@ -1,7 +1,68 @@ package com.tangem.features.feed.ui.market.detailed.state +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList internal data class MetricsUM( val metrics: ImmutableList, -) \ No newline at end of file + val metricsV2: MetricsV2UM?, +) + +@Immutable +internal sealed interface InfoPointUMV2 { + + @Immutable + data class MarketCap( + val capitalizationValue: TextReference, + val onInfoClick: () -> Unit, + ) : InfoPointUMV2 + + @Immutable + data class TradingVolume( + val tradingValue: TextReference, + val liquidity: Float, + val trendingVolumeLiquidityType: TrendingVolumeLiquidityType, + val onInfoClick: () -> Unit, + ) : InfoPointUMV2 + + @Immutable + data class MarketPosition( + val position: String, + val rangeValue: Float, + val marketRatingType: MarketRatingType, + val onInfoClick: () -> Unit, + ) : InfoPointUMV2 + + @Immutable + data class FullyDilutedValuation( + val value: TextReference, + val onInfoClick: () -> Unit, + ) : InfoPointUMV2 + + @Immutable + data class CirculatingSupply( + val currentValue: TextReference, + val maxValue: TextReference?, + val fillValue: Float?, + val onInfoClick: () -> Unit, + ) : InfoPointUMV2 +} + +internal data class MetricsV2UM( + val rows: ImmutableList, +) { + + internal data class Row( + val first: InfoPointUMV2, + val second: InfoPointUMV2?, + ) +} + +internal enum class TrendingVolumeLiquidityType { + HIGH, MEDIUM, LOW, +} + +internal enum class MarketRatingType { + GOLD, SILVER, BRONZE, OTHER +} \ No newline at end of file From 8beb80f5a773ba4a5e8a57ad76249afe8df4d9eb Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Mar 2026 13:48:50 +0300 Subject: [PATCH 53/60] Updated on 2026-08-14 --- .../tap/di/domain/QrScanningDomainModule.kt | 3 + core/res/src/main/res/values/strings.xml | 8 + domain/qr-scanning/build.gradle.kts | 1 + .../domain/qrscanning/models/QrSendTarget.kt | 1 + .../usecases/ResolveQrSendTargetsUseCase.kt | 69 ++-- .../send/v2/api/NetworkSelectionComponent.kt | 36 +++ .../DefaultNetworkSelectionComponent.kt | 38 +++ .../di/NetworkSelectionFeatureModule.kt | 20 ++ .../di/NetworkSelectionModelModule.kt | 20 ++ .../entity/NetworkSelectionUM.kt | 33 ++ .../model/NetworkSelectionModel.kt | 264 +++++++++++++++ .../ui/NetworkSelectionScreen.kt | 300 ++++++++++++++++++ .../features/send/v2/send/model/SendModel.kt | 19 +- 13 files changed, 778 insertions(+), 34 deletions(-) create mode 100644 features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NetworkSelectionComponent.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/DefaultNetworkSelectionComponent.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionFeatureModule.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionModelModule.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/entity/NetworkSelectionUM.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/ui/NetworkSelectionScreen.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt index 45044583d2..bc33cbeb88 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt @@ -1,6 +1,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase @@ -39,10 +40,12 @@ internal object QrScanningDomainModule { fun provideResolveQrSendTargetsUseCase( multiAccountListSupplier: MultiAccountListSupplier, qrScanningEventsRepository: QrScanningEventsRepository, + userWalletsListRepository: UserWalletsListRepository, ): ResolveQrSendTargetsUseCase { return ResolveQrSendTargetsUseCase( multiAccountListSupplier = multiAccountListSupplier, qrScanningEventsRepository = qrScanningEventsRepository, + userWalletsListRepository = userWalletsListRepository, ) } } \ No newline at end of file diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index f60477cf1c..020150535e 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -217,6 +217,7 @@ Add Add to portfolio Add token + Add tokens Added Address All @@ -325,6 +326,7 @@ NFT No No address + No results Not Added Not available Not now @@ -383,6 +385,7 @@ To To %s Today + Token to send %d token %d tokens @@ -395,6 +398,7 @@ I understand I understand, continue There was an error. Please try again. + Unlock Unreachable Unstake Due to %1$s limitations only %2$d UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount. @@ -1211,6 +1215,10 @@ Memo: %s Invalid Memo Network fee coverage + + %d token isn\'t compatible with this address + %d tokens aren\'t compatible with this address + Nonce Unique number for each transaction. Use it to resend or cancel a pending transaction. Enter nonce… diff --git a/domain/qr-scanning/build.gradle.kts b/domain/qr-scanning/build.gradle.kts index 564ec92b53..2679da7164 100644 --- a/domain/qr-scanning/build.gradle.kts +++ b/domain/qr-scanning/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { /** Domain */ api(projects.domain.models) implementation(projects.domain.account) + implementation(projects.domain.common) implementation(projects.domain.qrScanning.models) implementation(projects.domain.tokens.models) diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt index d802a5f2ec..d0443ffdd7 100644 --- a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt @@ -35,6 +35,7 @@ sealed class QrSendTarget { val accountId: AccountId, val accountName: AccountName, val currencies: List, + val hiddenTokensCount: Int = 0, ) } diff --git a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt index 2d436c4ceb..2e5cea6b3a 100644 --- a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt +++ b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository import java.math.BigDecimal @@ -14,41 +15,41 @@ import com.tangem.domain.qrscanning.models.QrSendTarget class ResolveQrSendTargetsUseCase( private val multiAccountListSupplier: MultiAccountListSupplier, private val qrScanningEventsRepository: QrScanningEventsRepository, + private val userWalletsListRepository: UserWalletsListRepository, ) { suspend operator fun invoke(qrCode: String): QrSendTarget { val allAccountLists = multiAccountListSupplier.getSyncOrNull(Unit).orEmpty() + val userWallets = userWalletsListRepository.userWalletsSync() + val walletNamesMap = userWallets.associate { it.walletId to it.name } - val currencyEntries = allAccountLists.flatMap { accountList -> - accountList.accounts - .filterIsInstance() - .flatMap { account -> - account.cryptoCurrencies.map { currency -> - currency to CurrencyLocation( - userWalletId = accountList.userWalletId, - walletName = accountList.userWalletId.stringValue, - accountId = account.accountId, - accountName = account.accountName, - ) - } + val allCurrencies = mutableListOf() + val currencyLocations = mutableMapOf>() + val totalPerAccount = mutableMapOf() + + for (accountList in allAccountLists) { + for (account in accountList.accounts.filterIsInstance()) { + val location = CurrencyLocation( + walletName = walletNamesMap[account.accountId.userWalletId] + ?: account.accountId.userWalletId.stringValue, + accountId = account.accountId, + accountName = account.accountName, + ) + totalPerAccount[account.accountId] = account.cryptoCurrencies.size + for (currency in account.cryptoCurrencies) { + allCurrencies.add(currency) + currencyLocations.getOrPut(currency.id) { mutableListOf() }.add(location) } + } } - val allCurrencies = currencyEntries.map { it.first } - val currencyLocations = currencyEntries.groupBy( - keySelector = { it.first.id }, - valueTransform = { it.second }, - ) - val classified = qrScanningEventsRepository.classify(qrCode, allCurrencies) + val portfolioIndex = PortfolioIndex(currencyLocations, totalPerAccount) - return resolve(classified, currencyLocations) + return resolve(classified, portfolioIndex) } - private fun resolve( - classified: ClassifiedQrContent, - currencyLocations: Map>, - ): QrSendTarget { + private fun resolve(classified: ClassifiedQrContent, portfolioIndex: PortfolioIndex): QrSendTarget { return when (classified) { is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri) is ClassifiedQrContent.Unknown -> QrSendTarget.Unknown(classified.raw) @@ -57,14 +58,14 @@ class ResolveQrSendTargetsUseCase( amount = null, memo = null, matchingCurrencies = classified.matchingCurrencies, - currencyLocations = currencyLocations, + portfolioIndex = portfolioIndex, ) is ClassifiedQrContent.PaymentUri -> resolveAddressTarget( address = classified.address, amount = classified.amount, memo = classified.memo, matchingCurrencies = classified.matchingCurrencies, - currencyLocations = currencyLocations, + portfolioIndex = portfolioIndex, ) } } @@ -74,9 +75,9 @@ class ResolveQrSendTargetsUseCase( amount: BigDecimal?, memo: String?, matchingCurrencies: List, - currencyLocations: Map>, + portfolioIndex: PortfolioIndex, ): QrSendTarget { - val walletGroups = buildWalletGroups(matchingCurrencies, currencyLocations) + val walletGroups = buildWalletGroups(matchingCurrencies, portfolioIndex) val singleGroup = walletGroups.singleOrNull() val singleCurrency = singleGroup?.accounts?.singleOrNull()?.currencies?.singleOrNull() @@ -101,15 +102,15 @@ class ResolveQrSendTargetsUseCase( private fun buildWalletGroups( matchingCurrencies: List, - currencyLocations: Map>, + portfolioIndex: PortfolioIndex, ): List { val walletMap = linkedMapOf() val uniqueCurrencies = matchingCurrencies.distinctBy { it.id } for (currency in uniqueCurrencies) { - val locations = currencyLocations[currency.id] ?: continue + val locations = portfolioIndex.currencyLocations[currency.id].orEmpty() for (location in locations) { - val walletInfo = walletMap.getOrPut(location.userWalletId) { + val walletInfo = walletMap.getOrPut(location.accountId.userWalletId) { WalletInfo(location.walletName, linkedMapOf()) } val accountInfo = walletInfo.accounts.getOrPut(location.accountId) { @@ -124,18 +125,24 @@ class ResolveQrSendTargetsUseCase( userWalletId = walletId, walletName = walletInfo.walletName, accounts = walletInfo.accounts.map { (accountId, accountInfo) -> + val total = portfolioIndex.totalPerAccount[accountId] ?: 0 QrSendTarget.Multiple.AccountGroup( accountId = accountId, accountName = accountInfo.accountName, currencies = accountInfo.currencies, + hiddenTokensCount = total - accountInfo.currencies.size, ) }, ) } } + private class PortfolioIndex( + val currencyLocations: Map>, + val totalPerAccount: Map, + ) + private data class CurrencyLocation( - val userWalletId: UserWalletId, val walletName: String, val accountId: AccountId, val accountName: AccountName, diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NetworkSelectionComponent.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NetworkSelectionComponent.kt new file mode 100644 index 0000000000..3214424d10 --- /dev/null +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NetworkSelectionComponent.kt @@ -0,0 +1,36 @@ +package com.tangem.features.send.v2.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableDialogComponent +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import java.math.BigDecimal + +interface NetworkSelectionComponent : ComposableDialogComponent { + + data class Params( + val address: String, + val amount: BigDecimal?, + val memo: String?, + val walletGroups: List, + val onTokenSelected: (UserWalletId, CryptoCurrency) -> Unit, + val onDismiss: () -> Unit, + ) { + data class WalletGroup( + val userWalletId: UserWalletId, + val walletName: String, + val accounts: List, + ) + + data class AccountGroup( + val accountId: AccountId, + val accountName: AccountName, + val currencies: List, + val hiddenTokensCount: Int = 0, + ) + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/DefaultNetworkSelectionComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/DefaultNetworkSelectionComponent.kt new file mode 100644 index 0000000000..a154ed6312 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/DefaultNetworkSelectionComponent.kt @@ -0,0 +1,38 @@ +package com.tangem.features.send.v2.networkselection + +import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.send.v2.api.NetworkSelectionComponent +import com.tangem.features.send.v2.networkselection.model.NetworkSelectionModel +import com.tangem.features.send.v2.networkselection.ui.NetworkSelectionScreen +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultNetworkSelectionComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: NetworkSelectionComponent.Params, +) : NetworkSelectionComponent, AppComponentContext by context { + + private val model: NetworkSelectionModel = getOrCreateModel(params) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun Dialog() { + val state = model.uiState.collectAsStateWithLifecycle() + NetworkSelectionScreen(state = state.value, onDismiss = ::dismiss) + } + + @AssistedFactory + interface Factory : NetworkSelectionComponent.Factory { + override fun create( + context: AppComponentContext, + params: NetworkSelectionComponent.Params, + ): DefaultNetworkSelectionComponent + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionFeatureModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionFeatureModule.kt new file mode 100644 index 0000000000..8c099c5bfa --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionFeatureModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.send.v2.networkselection.di + +import com.tangem.features.send.v2.api.NetworkSelectionComponent +import com.tangem.features.send.v2.networkselection.DefaultNetworkSelectionComponent +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 NetworkSelectionFeatureModule { + + @Binds + @Singleton + fun bindNetworkSelectionComponentFactory( + impl: DefaultNetworkSelectionComponent.Factory, + ): NetworkSelectionComponent.Factory +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionModelModule.kt new file mode 100644 index 0000000000..e1c8e50691 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.send.v2.networkselection.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.send.v2.networkselection.model.NetworkSelectionModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface NetworkSelectionModelModule { + + @Binds + @IntoMap + @ClassKey(NetworkSelectionModel::class) + fun provideNetworkSelectionModel(model: NetworkSelectionModel): Model +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/entity/NetworkSelectionUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/entity/NetworkSelectionUM.kt new file mode 100644 index 0000000000..680e6b27bf --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/entity/NetworkSelectionUM.kt @@ -0,0 +1,33 @@ +package com.tangem.features.send.v2.networkselection.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class NetworkSelectionUM( + val searchBar: SearchBarUM, + val walletGroups: ImmutableList, + val isBalanceHidden: Boolean, +) + +@Immutable +internal data class WalletGroupUM( + val userWalletId: UserWalletId, + val walletName: String, + val isExpanded: Boolean, + val onExpandToggle: () -> Unit, + val accounts: ImmutableList, +) + +@Immutable +internal data class AccountGroupUM( + val accountName: TextReference, + val iconState: CurrencyIconState.CryptoPortfolio?, + val tokens: ImmutableList, + val hiddenTokensCount: Int, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt new file mode 100644 index 0000000000..1f937110c0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt @@ -0,0 +1,264 @@ +package com.tangem.features.send.v2.networkselection.model + +import androidx.compose.runtime.Stable +import com.tangem.common.getTotalCryptoAmount +import com.tangem.common.getTotalFiatAmount +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.common.ui.account.AccountIconItemStateConverter +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.currency.icon.CurrencyIconStateBuilder +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.models.StatusSource +import com.tangem.common.ui.account.toUM +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.send.v2.api.NetworkSelectionComponent +import com.tangem.features.send.v2.networkselection.entity.AccountGroupUM +import com.tangem.features.send.v2.networkselection.entity.NetworkSelectionUM +import com.tangem.features.send.v2.networkselection.entity.WalletGroupUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@Stable +@ModelScoped +internal class NetworkSelectionModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, +) : Model() { + + private val params: NetworkSelectionComponent.Params = paramsContainer.require() + + private val searchQuery = MutableStateFlow("") + private val expandedWallets = MutableStateFlow( + params.walletGroups.firstOrNull() + ?.userWalletId + ?.let(::setOf) + .orEmpty(), + ) + + val uiState: StateFlow = createUiStateFlow() + + private fun createUiStateFlow(): StateFlow { + val appCurrencyFlow = getSelectedAppCurrencyUseCase.invokeOrDefault() + val statusListFlow = multiAccountStatusListSupplier() + val balanceHidingFlow = getBalanceHidingSettingsUseCase() + + return combine( + flow = searchQuery, + flow2 = expandedWallets, + flow3 = appCurrencyFlow, + flow4 = statusListFlow, + flow5 = balanceHidingFlow, + ) { query, expanded, appCurrency, statusLists, balanceHidingSettings -> + buildState( + query = query, + expandedWallets = expanded, + appCurrency = appCurrency, + statusLists = statusLists, + isBalanceHidden = balanceHidingSettings.isBalanceHidden, + ) + }.stateIn( + scope = modelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = buildInitialState(), + ) + } + + private fun buildState( + query: String, + expandedWallets: Set, + appCurrency: AppCurrency, + statusLists: List, + isBalanceHidden: Boolean, + ): NetworkSelectionUM { + val walletGroups = params.walletGroups.mapNotNull { walletGroup -> + buildWalletGroup( + walletGroup = walletGroup, + query = query, + expandedWallets = expandedWallets, + appCurrency = appCurrency, + statusLists = statusLists, + ) + }.toImmutableList() + + return NetworkSelectionUM( + searchBar = createSearchBar(query), + walletGroups = walletGroups, + isBalanceHidden = isBalanceHidden, + ) + } + + private fun buildWalletGroup( + walletGroup: NetworkSelectionComponent.Params.WalletGroup, + query: String, + expandedWallets: Set, + appCurrency: AppCurrency, + statusLists: List, + ): WalletGroupUM? { + val statusList = statusLists.find { it.userWalletId == walletGroup.userWalletId } + val tokenBuildContext = TokenMappingParams( + userWalletId = walletGroup.userWalletId, + appCurrency = appCurrency, + statusMap = buildStatusMap(statusList), + ) + + val accounts = walletGroup.accounts.mapNotNull { accountGroup -> + val iconState = getAccountIcon(accountGroup.accountId, statusList) + buildAccountGroup( + accountGroup = accountGroup, + query = query, + context = tokenBuildContext, + iconState = iconState, + ) + }.toImmutableList() + + if (accounts.isEmpty()) return null + + return WalletGroupUM( + userWalletId = walletGroup.userWalletId, + walletName = walletGroup.walletName, + isExpanded = walletGroup.userWalletId in expandedWallets, + onExpandToggle = { toggleWalletExpanded(walletGroup.userWalletId) }, + accounts = accounts, + ) + } + + private fun buildStatusMap(statusList: AccountStatusList?): Map { + if (statusList == null) return emptyMap() + return statusList.accountStatuses + .filterCryptoPortfolio() + .flatMap { it.flattenCurrencies() } + .associateBy { it.currency.id } + } + + private fun buildAccountGroup( + accountGroup: NetworkSelectionComponent.Params.AccountGroup, + query: String, + context: TokenMappingParams, + iconState: CurrencyIconState.CryptoPortfolio?, + ): AccountGroupUM? { + val tokens = accountGroup.currencies + .filter { matchesQuery(it, query) } + .map { currency -> buildTokenItem(currency, context) } + .toImmutableList() + + if (tokens.isEmpty()) return null + + return AccountGroupUM( + accountName = accountGroup.accountName.toUM().value, + iconState = iconState, + tokens = tokens, + hiddenTokensCount = accountGroup.hiddenTokensCount, + ) + } + + private fun getAccountIcon( + accountId: AccountId, + statusList: AccountStatusList?, + ): CurrencyIconState.CryptoPortfolio? { + if (statusList == null) return null + val account = statusList.accountStatuses + .filterCryptoPortfolio() + .find { it.accountId == accountId } + ?.account ?: return null + return AccountIconItemStateConverter(size = AccountIconSize.ExtraSmall).convert(account) + } + + private fun matchesQuery(currency: CryptoCurrency, query: String): Boolean { + if (query.isBlank()) return true + val lowerQuery = query.lowercase() + return currency.name.lowercase().contains(lowerQuery) || + currency.symbol.lowercase().contains(lowerQuery) || + currency.network.name.lowercase().contains(lowerQuery) + } + + private fun buildTokenItem(currency: CryptoCurrency, context: TokenMappingParams): TokenItemState { + val status = context.statusMap[currency.id] + if (status == null) { + return TokenItemState.Loading(id = currency.id.value) + } + + val cryptoAmount = status.getTotalCryptoAmount() + val fiatAmount = status.getTotalFiatAmount() + val isFlickering = status.value.sources.total == StatusSource.CACHE + + return TokenItemState.Content( + id = currency.id.value, + iconState = CurrencyIconStateBuilder.build(currency), + titleState = TokenItemState.TitleState.Content( + text = stringReference(currency.name), + ), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = stringReference(currency.network.name), + ), + fiatAmountState = TokenItemState.FiatAmountState.Content( + text = fiatAmount.format { + fiat( + fiatCurrencyCode = context.appCurrency.code, + fiatCurrencySymbol = context.appCurrency.symbol, + ) + }, + isFlickering = isFlickering, + ), + subtitle2State = TokenItemState.Subtitle2State.TextContent( + text = cryptoAmount.format { crypto(currency) }, + isFlickering = isFlickering, + ), + onItemClick = { params.onTokenSelected(context.userWalletId, currency) }, + onItemLongClick = null, + ) + } + + private fun toggleWalletExpanded(walletId: UserWalletId) { + expandedWallets.update { current -> + if (walletId in current) current - walletId else current + walletId + } + } + + private fun createSearchBar(query: String): SearchBarUM { + return SearchBarUM( + placeholderText = resourceReference(com.tangem.core.ui.R.string.common_search_tokens), + query = query, + onQueryChange = { searchQuery.value = it }, + isActive = query.isNotEmpty(), + onActiveChange = {}, + ) + } + + private fun buildInitialState(): NetworkSelectionUM { + return NetworkSelectionUM( + searchBar = createSearchBar(""), + walletGroups = persistentListOf(), + isBalanceHidden = true, + ) + } + + private data class TokenMappingParams( + val userWalletId: UserWalletId, + val appCurrency: AppCurrency, + val statusMap: Map, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/ui/NetworkSelectionScreen.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/ui/NetworkSelectionScreen.kt new file mode 100644 index 0000000000..aae3e17376 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/ui/NetworkSelectionScreen.kt @@ -0,0 +1,300 @@ +package com.tangem.features.send.v2.networkselection.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.fields.SearchBar +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.pluralStringResourceSafe +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.send.v2.networkselection.entity.AccountGroupUM +import com.tangem.features.send.v2.networkselection.entity.NetworkSelectionUM +import com.tangem.features.send.v2.networkselection.entity.WalletGroupUM + +private const val CHEVRON_EXPANDED_ROTATION = 180f +private const val CHEVRON_COLLAPSED_ROTATION = 0f + +@Composable +internal fun NetworkSelectionScreen(state: NetworkSelectionUM, onDismiss: () -> Unit) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(TangemTheme.colors.background.tertiary) + .systemBarsPadding() + .imePadding(), + ) { + TangemTopAppBar( + title = stringResourceSafe(R.string.common_token_send), + startButton = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_close_24, + onClicked = onDismiss, + ), + ) + SearchBar( + state = state.searchBar, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing22), + ) + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) + if (state.walletGroups.isEmpty() && state.searchBar.query.isNotBlank()) { + NetworkSelectionEmpty(modifier = Modifier.weight(1f)) + } else { + NetworkSelectionContent(state = state) + } + } + } +} + +@Composable +private fun NetworkSelectionEmpty(modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + ) { + Text( + text = stringResourceSafe(R.string.common_no_results), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun NetworkSelectionContent(state: NetworkSelectionUM) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = TangemTheme.dimens.spacing12), + ) { + state.walletGroups.forEach { walletGroup -> + item(key = "wallet_header_${walletGroup.userWalletId}") { + WalletHeader(walletGroup = walletGroup) + } + walletGroup.accounts.forEach { accountGroup -> + accountGroupItems( + walletGroup = walletGroup, + accountGroup = accountGroup, + isBalanceHidden = state.isBalanceHidden, + ) + } + } + } +} + +private fun LazyListScope.accountGroupItems( + walletGroup: WalletGroupUM, + accountGroup: AccountGroupUM, + isBalanceHidden: Boolean, +) { + val hasHiddenTokens = accountGroup.hiddenTokensCount > 0 + val lastIndex = accountGroup.tokens.lastIndex.inc() + if (hasHiddenTokens) 1 else 0 + item(key = "account_${walletGroup.userWalletId}_${accountGroup.accountName}") { + AnimatedVisibility( + visible = walletGroup.isExpanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + AccountHeader( + accountGroup = accountGroup, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = 0, + lastIndex = lastIndex, + radius = TangemTheme.dimens.radius14, + backgroundColor = TangemTheme.colors.background.action, + ), + ) + } + } + itemsIndexed( + items = accountGroup.tokens, + key = { _, token -> "token_${walletGroup.userWalletId}_${token.id}" }, + ) { index, tokenState -> + val indexWithHeader = index.inc() + AnimatedVisibility( + visible = walletGroup.isExpanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + TokenItem( + state = tokenState, + isBalanceHidden = isBalanceHidden, + reorderableTokenListState = null, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = indexWithHeader, + lastIndex = lastIndex, + radius = TangemTheme.dimens.radius14, + backgroundColor = TangemTheme.colors.background.action, + ), + ) + } + } + if (hasHiddenTokens) { + item( + key = "hidden_${walletGroup.userWalletId}_${accountGroup.accountName}", + ) { + AnimatedVisibility( + visible = walletGroup.isExpanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + HiddenTokensFooter( + count = accountGroup.hiddenTokensCount, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = lastIndex, + lastIndex = lastIndex, + radius = TangemTheme.dimens.radius14, + backgroundColor = TangemTheme.colors.background.action, + ), + ) + } + } + } +} + +@Composable +private fun WalletHeader(walletGroup: WalletGroupUM) { + val chevronRotation by animateFloatAsState( + targetValue = if (walletGroup.isExpanded) CHEVRON_COLLAPSED_ROTATION else CHEVRON_EXPANDED_ROTATION, + label = "chevron_rotation", + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = walletGroup.onExpandToggle) + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = walletGroup.walletName, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.weight(1f), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(TangemTheme.dimens.size24) + .background( + color = TangemTheme.colors.button.secondary, + shape = CircleShape, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_chevron_up_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier + .size(TangemTheme.dimens.size16) + .rotate(chevronRotation), + ) + } + } +} + +@Composable +private fun AccountHeader(accountGroup: AccountGroupUM, modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding( + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing10, + bottom = TangemTheme.dimens.spacing6, + end = TangemTheme.dimens.spacing12, + ), + ) { + accountGroup.iconState?.let { iconState -> + CurrencyIcon( + state = iconState, + modifier = Modifier.size(TangemTheme.dimens.size18), + ) + Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing6)) + } + Text( + text = accountGroup.accountName.resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +@Composable +private fun HiddenTokensFooter(count: Int, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + HorizontalDivider( + thickness = 0.5.dp, + color = TangemTheme.colors.stroke.primary, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing14), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing10, + bottom = TangemTheme.dimens.spacing10, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_eye_off_outline_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier.size(TangemTheme.dimens.size16), + ) + Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing8)) + Text( + text = pluralStringResourceSafe( + id = R.plurals.send_network_selection_hidden_tokens, + count = count, + count, + ), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} \ No newline at end of file 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 db07b4492e..b2cf810c08 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 @@ -281,9 +281,11 @@ internal class SendModel @Inject constructor( } is PredefinedValues.Content.QrCode -> { val predefinedAmount = predefinedValues.amount?.parseBigDecimalOrNull() + val amount = predefinedAmount + ?: (uiState.value.amountUM as? AmountState.Data)?.amountTextField?.cryptoAmount?.value + ?: error("Invalid amount") createTransferTransactionUseCase( - amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus) - ?: error("Invalid amount"), + amount = amount.convertToSdkAmount(cryptoCurrencyStatus), memo = predefinedValues.memo, destination = predefinedValues.address, userWalletId = userWallet.walletId, @@ -422,13 +424,24 @@ internal class SendModel @Inject constructor( feeCryptoCurrencyStatusFlow, ) { cryptoCurrencyStatus, feeCryptoCurrencyStatus -> if (isAvailableForSend && currentRoute.value == initialRoute) { - router.replaceAll(Confirm) + if (isPredefinedAmountExceedsBalance(cryptoCurrencyStatus)) { + router.replaceAll(Amount(isEditMode = false)) + } else { + router.replaceAll(Confirm) + } } else if (isUnavailableForSend) { showAlertError() } }.launchIn(modelScope) } + private fun isPredefinedAmountExceedsBalance(cryptoCurrencyStatus: CryptoCurrencyStatus): Boolean { + val predefinedAmount = (predefinedValues as? PredefinedValues.Content)?.amount + ?.parseBigDecimalOrNull() ?: return false + val balance = cryptoCurrencyStatus.value.amount ?: return false + return predefinedAmount > balance + } + private fun CryptoCurrencyStatus.hasAvailableStatus(): Boolean = this.value is CryptoCurrencyStatus.Loaded || this.value is CryptoCurrencyStatus.Custom || this.value is CryptoCurrencyStatus.NoQuote From 43d6e92a8926125ff007e4df3e4f8eae637e9d2c Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 17 Mar 2026 13:10:09 +0200 Subject: [PATCH 54/60] Updated on 2026-08-14 --- common/routing/build.gradle.kts | 1 + .../com/tangem/common/routing/LinkHandler.kt | 35 ++++++ .../configs/feature_toggles_config.json | 4 + .../api/tangemTech/TangemTechApi.kt | 18 +++ .../promobanners/DismissPromoBannerRequest.kt | 10 ++ .../DismissPromoBannerResponse.kt | 12 ++ .../PromoBannerDisplaysResponse.kt | 24 ++++ .../components/notifications/Notification.kt | 55 ++++++-- .../notifications/NotificationConfig.kt | 2 + features/promo-banners/api/build.gradle.kts | 15 +++ .../api/NewPromoBannersFeatureToggles.kt | 5 + .../api/PromoBannersBlockComponent.kt | 20 +++ features/promo-banners/impl/build.gradle.kts | 50 ++++++++ .../analytics/PromoBannerAnalyticsEvent.kt | 24 ++++ .../PromoBannerDisplayDTOConverter.kt | 35 ++++++ ...omoBannerDisplayToNotificationConverter.kt | 36 ++++++ .../impl/model/PromoBannerDisplay.kt | 14 +++ .../impl/model/PromoBannerNotificationUM.kt | 8 ++ .../impl/model/PromoBannerPriority.kt | 8 ++ .../impl/model/PromoBannersBlockUM.kt | 8 ++ .../DefaultPromoBannersRepository.kt | 42 +++++++ .../impl/repository/PromoBannersRepository.kt | 10 ++ .../DefaultNewPromoBannersFeatureToggles.kt | 13 ++ .../PromoBannerDisplayDTOConverterTest.kt | 77 ++++++++++++ ...annerDisplayToNotificationConverterTest.kt | 118 ++++++++++++++++++ settings.gradle.kts | 3 + 26 files changed, 639 insertions(+), 8 deletions(-) create mode 100644 common/routing/src/main/kotlin/com/tangem/common/routing/LinkHandler.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/PromoBannerDisplaysResponse.kt create mode 100644 features/promo-banners/api/build.gradle.kts create mode 100644 features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/NewPromoBannersFeatureToggles.kt create mode 100644 features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt create mode 100644 features/promo-banners/impl/build.gradle.kts create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/analytics/PromoBannerAnalyticsEvent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverter.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverter.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerDisplay.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerNotificationUM.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerPriority.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockUM.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultNewPromoBannersFeatureToggles.kt create mode 100644 features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverterTest.kt create mode 100644 features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverterTest.kt diff --git a/common/routing/build.gradle.kts b/common/routing/build.gradle.kts index b34c827c74..df9824a0d4 100644 --- a/common/routing/build.gradle.kts +++ b/common/routing/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { /* Core */ implementation(projects.core.decompose) implementation(projects.core.configToggles) + implementation(projects.core.navigation) /* Domain */ implementation(projects.domain.qrScanning.models) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/LinkHandler.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/LinkHandler.kt new file mode 100644 index 0000000000..094ecd23eb --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/LinkHandler.kt @@ -0,0 +1,35 @@ +package com.tangem.common.routing + +import android.net.Uri +import timber.log.Timber + +/** + * Routes in-app content links (deep links and external URLs) + * - `tangem://` scheme → parsed to AppRoute and pushed via AppRouter + * - `https://` / `http://` → opened in external browser via UrlOpener + * - Unknown scheme → logged and ignored + */ +class LinkHandler( + private val appRouter: AppRouter, +) { + + fun navigate(link: String) { + val uri = Uri.parse(link) + handleTangemDeepLink(uri) + } + + private fun handleTangemDeepLink(uri: Uri) { + val route = parseDeepLinkToRoute(uri) + if (route != null) { + appRouter.push(route) + } else { + Timber.w("ContentLinkHandler: unrecognized tangem deep link: %s", uri) + } + } + + @Suppress("UnusedParameter", "FunctionOnlyReturningConstant") + private fun parseDeepLinkToRoute(uri: Uri): AppRoute? { + // TODO [REDACTED_TASK_KEY] refactor deepling routing + return null + } +} \ 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 30e91a42ae..46aea1e987 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 @@ -59,5 +59,9 @@ { "name": "MAIN_SCREEN_QR_SCANNING_ENABLED", "version": "undefined" + }, + { + "name": "NEW_PROMO_BANNERS_ENABLED", + "version": "undefined" } ] 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 aa8748eb2c..916be59d74 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 @@ -5,6 +5,9 @@ import com.tangem.datasource.api.promotion.models.PromoBannerResponse import com.tangem.datasource.api.promotion.models.PromoBannerV2Response import com.tangem.datasource.api.promotion.models.StoryContentResponse import com.tangem.datasource.api.tangemTech.models.* +import com.tangem.datasource.api.tangemTech.models.promobanners.PromoBannerDisplaysResponse +import com.tangem.datasource.api.tangemTech.models.promobanners.DismissPromoBannerRequest +import com.tangem.datasource.api.tangemTech.models.promobanners.DismissPromoBannerResponse 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.SaveWalletAccountsResponse @@ -197,6 +200,21 @@ interface TangemTechApi { ): ApiResponse // endregion + // region promo banners + @GET("v1/banner/displays") + suspend fun getPromoBannerDisplays( + @Query("walletId") walletId: String, + @Query("placeholder") placeholder: String, + @Query("locale") locale: String, + ): ApiResponse + + @PATCH("v1/displays/{displayId}") + suspend fun dismissPromoBannerDisplay( + @Path("displayId") displayId: String, + @Body body: DismissPromoBannerRequest, + ): ApiResponse + // endregion + /** * Stores transaction hash in cache to prevent duplicate push * notifications for yield operations (deposit, withdraw, send). diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerRequest.kt new file mode 100644 index 0000000000..1bb3d5ac18 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerRequest.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.tangemTech.models.promobanners + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DismissPromoBannerRequest( + @Json(name = "walletId") val walletId: String, + @Json(name = "isDismissed") val isDismissed: Boolean, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerResponse.kt new file mode 100644 index 0000000000..7434487ff5 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerResponse.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.api.tangemTech.models.promobanners + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DismissPromoBannerResponse( + @Json(name = "displayId") val displayId: String, + @Json(name = "walletId") val walletId: String, + @Json(name = "isDismissed") val isDismissed: Boolean, + @Json(name = "dismissedAt") val dismissedAt: String?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/PromoBannerDisplaysResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/PromoBannerDisplaysResponse.kt new file mode 100644 index 0000000000..ca1a8e9e5e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/PromoBannerDisplaysResponse.kt @@ -0,0 +1,24 @@ +package com.tangem.datasource.api.tangemTech.models.promobanners + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PromoBannerDisplaysResponse( + @Json(name = "items") val items: List, +) + +@Suppress("BooleanPropertyNaming") +@JsonClass(generateAdapter = true) +data class PromoBannerDisplayDTO( + @Json(name = "id") val id: String, + @Json(name = "placeholder") val placeholder: String, + @Json(name = "priority") val priority: String, + @Json(name = "title") val title: String, + @Json(name = "subtitle") val subtitle: String, + @Json(name = "iconUrl") val iconUrl: String?, + @Json(name = "deeplink") val deeplink: String?, + @Json(name = "buttonEnabled") val buttonEnabled: Boolean, + @Json(name = "buttonText") val buttonText: String?, + @Json(name = "dismissable") val dismissable: Boolean, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt index d1b06a5538..bd01f82aaa 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 @@ -2,6 +2,7 @@ package com.tangem.core.ui.components.notifications import android.content.res.Configuration import androidx.annotation.DrawableRes +import coil.compose.SubcomposeAsyncImage import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image import androidx.compose.foundation.LocalIndication @@ -86,6 +87,7 @@ fun Notification( subtitleColor = subtitleColor, showArrowIcon = isEnabled && config.shouldShowArrowIcon, hasCloseButton = config.onCloseClick != null, + iconUrl = config.iconUrl, ) } } @@ -150,16 +152,29 @@ private fun MainContent( subtitleColor: Color, showArrowIcon: Boolean, hasCloseButton: Boolean, + iconUrl: String? = null, ) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - iconResId = iconResId, - tint = iconTint, - modifier = Modifier - .size(size = iconSize) - .align(alignment = Alignment.Top) - .testTag(NotificationTestTags.ICON), - ) + if (iconUrl != null) { + UrlIcon( + iconUrl = iconUrl, + fallbackIconResId = iconResId, + fallbackIconTint = iconTint, + modifier = Modifier + .size(size = iconSize) + .align(alignment = Alignment.Top) + .testTag(NotificationTestTags.ICON), + ) + } else { + Icon( + iconResId = iconResId, + tint = iconTint, + modifier = Modifier + .size(size = iconSize) + .align(alignment = Alignment.Top) + .testTag(NotificationTestTags.ICON), + ) + } SpacerW(width = TangemTheme.dimens.spacing10) @@ -212,6 +227,30 @@ private fun Icon(@DrawableRes iconResId: Int, tint: Color?, modifier: Modifier = } } +@Composable +private fun UrlIcon( + iconUrl: String, + @DrawableRes fallbackIconResId: Int, + fallbackIconTint: Color?, + modifier: Modifier = Modifier, +) { + SubcomposeAsyncImage( + model = iconUrl, + contentDescription = null, + modifier = modifier.clip(CircleShape), + loading = { + CircleShimmer(modifier = Modifier.matchParentSize()) + }, + error = { + Icon( + iconResId = fallbackIconResId, + tint = fallbackIconTint, + modifier = Modifier.matchParentSize(), + ) + }, + ) +} + @Composable internal fun TextsBlock( title: TextReference?, 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 9da575b6c2..d15a939542 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 @@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.TextReference * * @property subtitle subtitle * @property iconResId icon resource id + * @property iconUrl icon URL for remote images * @property title title * @property backgroundResId background resource id * @property buttonsState buttons state @@ -21,6 +22,7 @@ import com.tangem.core.ui.extensions.TextReference data class NotificationConfig( val subtitle: TextReference, @DrawableRes val iconResId: Int, + val iconUrl: String? = null, val title: TextReference? = null, @DrawableRes val backgroundResId: Int? = null, val buttonsState: ButtonsState? = null, diff --git a/features/promo-banners/api/build.gradle.kts b/features/promo-banners/api/build.gradle.kts new file mode 100644 index 0000000000..a81f1d0c23 --- /dev/null +++ b/features/promo-banners/api/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.promobanners.api" +} + +dependencies { + implementation(projects.core.decompose) + implementation(projects.core.ui) + implementation(projects.domain.models) +} \ No newline at end of file diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/NewPromoBannersFeatureToggles.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/NewPromoBannersFeatureToggles.kt new file mode 100644 index 0000000000..bc392c04ba --- /dev/null +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/NewPromoBannersFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.features.promobanners.api + +interface NewPromoBannersFeatureToggles { + val isNewPromoBannersEnabled: Boolean +} \ No newline at end of file diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt new file mode 100644 index 0000000000..f9525a6ac5 --- /dev/null +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt @@ -0,0 +1,20 @@ +package com.tangem.features.promobanners.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +interface PromoBannersBlockComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val placeholder: Placeholder, + ) + + enum class Placeholder(val value: String) { + MAIN("main"), + FEED("shtorka"), + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts new file mode 100644 index 0000000000..cee5719108 --- /dev/null +++ b/features/promo-banners/impl/build.gradle.kts @@ -0,0 +1,50 @@ +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.promobanners.impl" +} + +dependencies { + /** Project - API */ + implementation(projects.features.promoBanners.api) + + /** Domain */ + implementation(projects.domain.models) + + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + implementation(projects.core.utils) + implementation(projects.core.datasource) + implementation(projects.core.configToggles) + + /** Common */ + implementation(projects.common.routing) + + /** Compose */ + implementation(deps.compose.foundation) + implementation(deps.compose.ui) + implementation(deps.lifecycle.compose) + + /** Other */ + implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) + implementation(deps.timber) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /** Tests */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.truth) +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/analytics/PromoBannerAnalyticsEvent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/analytics/PromoBannerAnalyticsEvent.kt new file mode 100644 index 0000000000..fd6944d386 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/analytics/PromoBannerAnalyticsEvent.kt @@ -0,0 +1,24 @@ +package com.tangem.features.promobanners.impl.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent + +internal sealed class PromoBannerAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Banner", event = event, params = params) { + + data class Shown(val bannerId: String) : PromoBannerAnalyticsEvent( + event = "Banner Shown", + params = mapOf("banner_id" to bannerId), + ) + + data class Clicked(val bannerId: String) : PromoBannerAnalyticsEvent( + event = "Banner Clicked", + params = mapOf("banner_id" to bannerId), + ) + + data class Dismissed(val bannerId: String) : PromoBannerAnalyticsEvent( + event = "Banner Dismissed", + params = mapOf("banner_id" to bannerId), + ) +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverter.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverter.kt new file mode 100644 index 0000000000..13e19d5361 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverter.kt @@ -0,0 +1,35 @@ +package com.tangem.features.promobanners.impl.converters + +import com.tangem.datasource.api.tangemTech.models.promobanners.PromoBannerDisplayDTO +import com.tangem.features.promobanners.impl.model.PromoBannerDisplay +import com.tangem.features.promobanners.impl.model.PromoBannerPriority +import com.tangem.utils.converter.Converter +import java.util.Locale + +internal class PromoBannerDisplayDTOConverter : Converter { + + override fun convert(value: PromoBannerDisplayDTO): PromoBannerDisplay { + return PromoBannerDisplay( + id = value.id, + placeholder = value.placeholder, + priority = convertPriority(value.priority), + title = value.title, + subtitle = value.subtitle, + iconUrl = value.iconUrl, + deeplink = value.deeplink, + isButtonEnabled = value.buttonEnabled, + buttonText = value.buttonText, + isDismissable = value.dismissable, + ) + } + + private fun convertPriority(value: String): PromoBannerPriority { + return when (value.uppercase(Locale.ROOT)) { + "IMPORTANT" -> PromoBannerPriority.IMPORTANT + "HIGH" -> PromoBannerPriority.HIGH + "MEDIUM" -> PromoBannerPriority.MEDIUM + "LOW" -> PromoBannerPriority.LOW + else -> PromoBannerPriority.LOW + } + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverter.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverter.kt new file mode 100644 index 0000000000..0e81007163 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverter.kt @@ -0,0 +1,36 @@ +package com.tangem.features.promobanners.impl.converters + +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.promobanners.impl.model.PromoBannerDisplay +import com.tangem.features.promobanners.impl.model.PromoBannerNotificationUM + +internal class PromoBannerDisplayToNotificationConverter { + + fun convert( + banner: PromoBannerDisplay, + onDeeplinkClick: (String?) -> Unit, + onDismiss: (String) -> Unit, + ): PromoBannerNotificationUM { + return PromoBannerNotificationUM( + displayId = banner.id, + config = NotificationConfig( + title = TextReference.Str(banner.title), + subtitle = TextReference.Str(banner.subtitle), + iconResId = com.tangem.core.ui.R.drawable.ic_alert_circle_24, + iconUrl = banner.iconUrl, + onCloseClick = if (banner.isDismissable) { + { onDismiss(banner.id) } + } else { + null + }, + buttonsState = banner.buttonText?.takeIf { banner.isButtonEnabled }?.let { text -> + NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = TextReference.Str(text), + onClick = { onDeeplinkClick(banner.deeplink) }, + ) + }, + ), + ) + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerDisplay.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerDisplay.kt new file mode 100644 index 0000000000..6f96a1cf00 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerDisplay.kt @@ -0,0 +1,14 @@ +package com.tangem.features.promobanners.impl.model + +internal data class PromoBannerDisplay( + val id: String, + val placeholder: String, + val priority: PromoBannerPriority, + val title: String, + val subtitle: String, + val iconUrl: String?, + val deeplink: String?, + val isButtonEnabled: Boolean, + val buttonText: String?, + val isDismissable: Boolean, +) \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerNotificationUM.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerNotificationUM.kt new file mode 100644 index 0000000000..1d244d419c --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerNotificationUM.kt @@ -0,0 +1,8 @@ +package com.tangem.features.promobanners.impl.model + +import com.tangem.core.ui.components.notifications.NotificationConfig + +internal data class PromoBannerNotificationUM( + val displayId: String, + val config: NotificationConfig, +) \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerPriority.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerPriority.kt new file mode 100644 index 0000000000..959f3c3b44 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerPriority.kt @@ -0,0 +1,8 @@ +package com.tangem.features.promobanners.impl.model + +internal enum class PromoBannerPriority(val order: Int) { + IMPORTANT(order = 0), + HIGH(order = 1), + MEDIUM(order = 2), + LOW(order = 3), +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockUM.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockUM.kt new file mode 100644 index 0000000000..8a311ac7d7 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockUM.kt @@ -0,0 +1,8 @@ +package com.tangem.features.promobanners.impl.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal data class PromoBannersBlockUM( + val banners: ImmutableList = persistentListOf(), +) \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt new file mode 100644 index 0000000000..166ef40064 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt @@ -0,0 +1,42 @@ +package com.tangem.features.promobanners.impl.repository + +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.promobanners.DismissPromoBannerRequest +import com.tangem.features.promobanners.impl.converters.PromoBannerDisplayDTOConverter +import com.tangem.features.promobanners.impl.model.PromoBannerDisplay +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class DefaultPromoBannersRepository @Inject constructor( + private val tangemTechApi: TangemTechApi, + private val dispatchers: CoroutineDispatcherProvider, +) : PromoBannersRepository { + + private val converter = PromoBannerDisplayDTOConverter() + + override suspend fun getBanners(walletId: String, placeholder: String, locale: String): List = + withContext(dispatchers.io) { + tangemTechApi.getPromoBannerDisplays( + walletId = walletId, + placeholder = placeholder, + locale = locale, + ).getOrThrow() + .items + .map(converter::convert) + .sortedBy { it.priority.order } + } + + override suspend fun dismissBanner(walletId: String, displayId: String) { + withContext(dispatchers.io) { + val request = DismissPromoBannerRequest( + walletId = walletId, + isDismissed = true, + ) + tangemTechApi.dismissPromoBannerDisplay(displayId, request).getOrThrow() + } + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt new file mode 100644 index 0000000000..564b71625f --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt @@ -0,0 +1,10 @@ +package com.tangem.features.promobanners.impl.repository + +import com.tangem.features.promobanners.impl.model.PromoBannerDisplay + +internal interface PromoBannersRepository { + + suspend fun getBanners(walletId: String, placeholder: String, locale: String): List + + suspend fun dismissBanner(walletId: String, displayId: String) +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultNewPromoBannersFeatureToggles.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultNewPromoBannersFeatureToggles.kt new file mode 100644 index 0000000000..a93f31bc75 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultNewPromoBannersFeatureToggles.kt @@ -0,0 +1,13 @@ +package com.tangem.features.promobanners.impl.toggles + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles +import javax.inject.Inject + +internal class DefaultNewPromoBannersFeatureToggles @Inject constructor( + private val featureTogglesManager: FeatureTogglesManager, +) : NewPromoBannersFeatureToggles { + + override val isNewPromoBannersEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("NEW_PROMO_BANNERS_ENABLED") +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverterTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverterTest.kt new file mode 100644 index 0000000000..91eb08c41a --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverterTest.kt @@ -0,0 +1,77 @@ +package com.tangem.features.promobanners.impl.converters + +import com.tangem.datasource.api.tangemTech.models.promobanners.PromoBannerDisplayDTO +import com.tangem.features.promobanners.impl.model.PromoBannerPriority +import org.junit.jupiter.api.Test +import com.google.common.truth.Truth.assertThat + +class PromoBannerDisplayDTOConverterTest { + + private val converter = PromoBannerDisplayDTOConverter() + + @Test + fun `should convert DTO to domain model`() { + val dto = PromoBannerDisplayDTO( + id = "123", + placeholder = "MAIN", + priority = "HIGH", + title = "Test Banner", + subtitle = "Test subtitle", + iconUrl = "https://example.com/icon.png", + deeplink = "tangem://wallet", + buttonEnabled = true, + buttonText = "Click me", + dismissable = true, + ) + + val result = converter.convert(dto) + + assertThat(result.id).isEqualTo("123") + assertThat(result.placeholder).isEqualTo("MAIN") + assertThat(result.priority).isEqualTo(PromoBannerPriority.HIGH) + assertThat(result.title).isEqualTo("Test Banner") + assertThat(result.subtitle).isEqualTo("Test subtitle") + assertThat(result.iconUrl).isEqualTo("https://example.com/icon.png") + assertThat(result.deeplink).isEqualTo("tangem://wallet") + assertThat(result.isButtonEnabled).isTrue() + assertThat(result.buttonText).isEqualTo("Click me") + assertThat(result.isDismissable).isTrue() + } + + @Test + fun `should pass placeholder as is`() { + val dto = createDTO(placeholder = "UNKNOWN") + val result = converter.convert(dto) + assertThat(result.placeholder).isEqualTo("UNKNOWN") + } + + @Test + fun `should map unknown priority to LOW`() { + val dto = createDTO(priority = "UNKNOWN") + val result = converter.convert(dto) + assertThat(result.priority).isEqualTo(PromoBannerPriority.LOW) + } + + @Test + fun `should handle SHTORKA placeholder`() { + val dto = createDTO(placeholder = "SHTORKA") + val result = converter.convert(dto) + assertThat(result.placeholder).isEqualTo("SHTORKA") + } + + private fun createDTO( + placeholder: String = "MAIN", + priority: String = "MEDIUM", + ) = PromoBannerDisplayDTO( + id = "1", + placeholder = placeholder, + priority = priority, + title = "Title", + subtitle = "Subtitle", + iconUrl = null, + deeplink = null, + buttonEnabled = false, + buttonText = null, + dismissable = false, + ) +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverterTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverterTest.kt new file mode 100644 index 0000000000..3ae0b738dc --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverterTest.kt @@ -0,0 +1,118 @@ +package com.tangem.features.promobanners.impl.converters + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.promobanners.impl.model.PromoBannerDisplay +import com.tangem.features.promobanners.impl.model.PromoBannerPriority +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class PromoBannerDisplayToNotificationConverterTest { + + private val converter = PromoBannerDisplayToNotificationConverter() + + @Test + fun `should convert banner with all fields`() { + val banner = createBanner( + id = "b1", + deeplink = "tangem://wallet", + isButtonEnabled = true, + buttonText = "Open", + isDismissable = true, + ) + var clickedDeeplink: String? = "not_called" + var dismissedId: String? = null + + val result = converter.convert( + banner = banner, + onDeeplinkClick = { clickedDeeplink = it }, + onDismiss = { dismissedId = it }, + ) + + assertThat(result.displayId).isEqualTo("b1") + assertThat(result.config.title).isEqualTo(TextReference.Str("Title")) + assertThat(result.config.subtitle).isEqualTo(TextReference.Str("Subtitle")) + assertThat(result.config.iconUrl).isEqualTo("https://icon.png") + assertThat(result.config.onClick).isNull() + assertThat(result.config.onCloseClick).isNotNull() + assertThat(result.config.buttonsState).isNotNull() + + // Button click navigates via deeplink + val buttonState = result.config.buttonsState as com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState.SecondaryButtonConfig + buttonState.onClick.invoke() + assertThat(clickedDeeplink).isEqualTo("tangem://wallet") + + result.config.onCloseClick!!.invoke() + assertThat(dismissedId).isEqualTo("b1") + } + + @Test + fun `should always have null onClick - banner itself is not clickable`() { + val banner = createBanner(deeplink = "tangem://wallet") + + val result = converter.convert( + banner = banner, + onDeeplinkClick = {}, + onDismiss = {}, + ) + + assertThat(result.config.onClick).isNull() + } + + @Test + fun `should not set onCloseClick when not dismissable`() { + val banner = createBanner(isDismissable = false) + + val result = converter.convert( + banner = banner, + onDeeplinkClick = {}, + onDismiss = {}, + ) + + assertThat(result.config.onCloseClick).isNull() + } + + @Test + fun `should not set button when button is disabled`() { + val banner = createBanner(isButtonEnabled = false, buttonText = "Open") + + val result = converter.convert( + banner = banner, + onDeeplinkClick = {}, + onDismiss = {}, + ) + + assertThat(result.config.buttonsState).isNull() + } + + @Test + fun `should not set button when buttonText is null`() { + val banner = createBanner(isButtonEnabled = true, buttonText = null) + + val result = converter.convert( + banner = banner, + onDeeplinkClick = {}, + onDismiss = {}, + ) + + assertThat(result.config.buttonsState).isNull() + } + + private fun createBanner( + id: String = "id", + deeplink: String? = "https://example.com", + isButtonEnabled: Boolean = false, + buttonText: String? = null, + isDismissable: Boolean = true, + ) = PromoBannerDisplay( + id = id, + placeholder = "main", + priority = PromoBannerPriority.MEDIUM, + title = "Title", + subtitle = "Subtitle", + iconUrl = "https://icon.png", + deeplink = deeplink, + isButtonEnabled = isButtonEnabled, + buttonText = buttonText, + isDismissable = isDismissable, + ) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index f77aab113a..76cb4af3e4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -305,6 +305,9 @@ include(":features:approval:impl") include(":features:feed:api") include(":features:feed:impl") + +include(":features:promo-banners:api") +include(":features:promo-banners:impl") // endregion Feature modules // region Domain modules From bbf13f1ae8b6c50db70dca7a83066fb7909ae3f4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Mar 2026 11:27:36 +0400 Subject: [PATCH 55/60] Updated on 2026-08-14 --- .../api/tangemTech/TangemTechApi.kt | 17 +----------- .../tokens/UserTokensBackwardCompatibility.kt | 17 ------------ .../data/wallets/DefaultWalletsRepository.kt | 2 +- .../wallets/DefaultWalletsRepositoryTest.kt | 6 ++--- .../status/di/AccountStatusUseCaseModule.kt | 9 +++---- ...eV2.kt => GetWalletTotalBalanceUseCase.kt} | 2 +- domain/kyc/models/.gitignore | 1 - domain/kyc/models/build.gradle.kts | 9 ------- domain/tokens/detekt-baseline-debug.xml | 26 ------------------- domain/tokens/models/detekt-baseline-main.xml | 13 ---------- .../model/analytics/PromoAnalyticsEvent.kt | 2 +- .../analytics/TokenExchangeAnalyticsEvent.kt | 2 +- .../analytics/TokenOnrampAnalyticsEvent.kt | 2 +- .../analytics/TokenReceiveAnalyticsEvent.kt | 2 +- .../TokenReceiveNewAnalyticsEvent.kt | 2 +- .../analytics/TokenScreenAnalyticsEvent.kt | 2 +- .../model/remove/RemoveCurrencyError.kt | 7 ----- .../tokens/model/staking/YieldExtentions.kt | 7 ----- ...GetBalanceNotEnoughForFeeWarningUseCase.kt | 8 +++--- .../tokens/GetCryptoCurrencyActionsUseCase.kt | 4 +-- .../domain/tokens/GetCurrencyCheckUseCase.kt | 12 +++++---- .../MultiWalletCryptoCurrenciesSupplier.kt | 1 + .../tokens/actions/BaseActionsFactory.kt | 1 + .../tokens/actions/CommonActionsFactory.kt | 13 ++++++---- .../actions/OutdatedDataActionsFactory.kt | 2 +- .../actions/UnreachableActionsFactory.kt | 2 +- .../domain/tokens/model/TokenActionsState.kt | 2 +- .../operations/PriceChangeCalculator.kt | 6 ++--- .../tokens/wallet/WalletBalanceFetcher.kt | 6 ++--- .../impl/model/PortfolioTokenUMConverter.kt | 2 +- .../TokenDetailsActionButtonsConverter.kt | 2 +- .../wallet/utils/DefaultUserWalletsFetcher.kt | 6 ++--- 32 files changed, 53 insertions(+), 142 deletions(-) rename domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/{GetWalletTotalBalanceUseCaseV2.kt => GetWalletTotalBalanceUseCase.kt} (97%) delete mode 100644 domain/kyc/models/.gitignore delete mode 100644 domain/kyc/models/build.gradle.kts delete mode 100644 domain/tokens/detekt-baseline-debug.xml delete mode 100644 domain/tokens/models/detekt-baseline-main.xml delete mode 100644 domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/remove/RemoveCurrencyError.kt delete mode 100644 domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/staking/YieldExtentions.kt 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 916be59d74..330762e864 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 @@ -45,15 +45,6 @@ interface TangemTechApi { @GET("v1/geo") suspend fun getUserCountryCode(): GeoResponse - @GET("v1/user-tokens/{user-id}") - suspend fun getUserTokens(@Path(value = "user-id") userId: String): ApiResponse - - @PUT("v1/user-tokens/{user-id}") - suspend fun saveUserTokens( - @Path(value = "user-id") userId: String, - @Body userTokens: UserTokensResponse, - ): ApiResponse - @PUT("/v1/wallets/{walletId}/tokens") suspend fun saveTokens( @Path(value = "walletId") userId: String, @@ -138,14 +129,8 @@ interface TangemTechApi { @PATCH("v1/user-wallets/wallets/{wallet_id}") suspend fun updateWallet(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse - @POST("v1/user-wallets/wallets/create-and-connect-by-appuid/{application_id}") - suspend fun associateApplicationIdWithWallets( - @Path("application_id") applicationId: String, - @Body body: List, - ): ApiResponse - @PUT("/v1/user-wallets/applications/{application_id}/wallets") - suspend fun associateApplicationIdWithWalletsV2( + suspend fun associateApplicationIdWithWallets( @Path("application_id") applicationId: String, @Body body: AssociateApplicationIdWithWalletsBody, ): ApiResponse diff --git a/data/common/src/main/kotlin/com/tangem/data/common/tokens/UserTokensBackwardCompatibility.kt b/data/common/src/main/kotlin/com/tangem/data/common/tokens/UserTokensBackwardCompatibility.kt index f9c12f4d03..1ba380c53d 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/tokens/UserTokensBackwardCompatibility.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/tokens/UserTokensBackwardCompatibility.kt @@ -11,23 +11,6 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse */ class UserTokensBackwardCompatibility { - fun applyCompatibilityAndGetUpdated(userTokensResponse: UserTokensResponse): UserTokensResponse { - return userTokensResponse.copy( - tokens = userTokensResponse.tokens.map { token -> - val oldSavedId = NETWORKS_TO_OLD_SAVED_IDS[token.networkId] - if (oldSavedId != null && token.id == oldSavedId) { - Blockchain.fromNetworkId(token.networkId)?.let { blockchain -> - token.copy( - id = blockchain.toCoinId(), - ) - } ?: token - } else { - token - } - }, - ) - } - fun applyCompatibilityAndGetUpdated(tokens: List): List { return tokens.map { token -> val oldSavedId = NETWORKS_TO_OLD_SAVED_IDS[token.networkId] 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 8d90aedc75..e2fac0c166 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 @@ -362,7 +362,7 @@ internal class DefaultWalletsRepository( override suspend fun associateWallets(applicationId: String, wallets: List) = withContext(dispatchers.io) { val associateApplicationIdWithWallets: suspend () -> ApiResponse = { - tangemTechApi.associateApplicationIdWithWalletsV2( + tangemTechApi.associateApplicationIdWithWallets( applicationId = applicationId, body = AssociateApplicationIdWithWalletsBody( walletIds = wallets.map { it.walletId.stringValue }.distinct(), diff --git a/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt b/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt index 74d60d237c..639b115e55 100644 --- a/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt +++ b/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt @@ -186,7 +186,7 @@ class DefaultWalletsRepositoryTest { inner class AssociateWallets { @Test - fun `should convert and send to API V2`() = runTest { + fun `should convert and send to associateApplicationIdWithWallets`() = runTest { // Arrange val applicationId = "test_app_id" val wallet1Id = "1234567890abcdef" @@ -202,7 +202,7 @@ class DefaultWalletsRepositoryTest { ) coEvery { - tangemTechApi.associateApplicationIdWithWalletsV2(eq(applicationId), any()) + tangemTechApi.associateApplicationIdWithWallets(eq(applicationId), any()) } returns ApiResponse.Success(Unit) // Act @@ -210,7 +210,7 @@ class DefaultWalletsRepositoryTest { // Assert coVerify(exactly = 1) { - tangemTechApi.associateApplicationIdWithWalletsV2( + tangemTechApi.associateApplicationIdWithWallets( applicationId = eq(applicationId), body = match { body -> body.walletIds.size == 2 && diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt index e2b9696c89..31d9b0d3b4 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt @@ -3,13 +3,11 @@ package com.tangem.domain.account.status.di import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier -import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.usecase.* import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier @@ -23,6 +21,7 @@ import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.derivations.DerivationsRepository +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -76,10 +75,10 @@ internal object AccountStatusUseCaseModule { @Provides @Singleton - fun provideGetWalletTotalBalanceUseCaseV2( + fun provideGetWalletTotalBalanceUseCase( multiAccountStatusListSupplier: MultiAccountStatusListSupplier, - ): GetWalletTotalBalanceUseCaseV2 { - return GetWalletTotalBalanceUseCaseV2( + ): GetWalletTotalBalanceUseCase { + return GetWalletTotalBalanceUseCase( multiAccountStatusListSupplier = multiAccountStatusListSupplier, ) } diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetWalletTotalBalanceUseCaseV2.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetWalletTotalBalanceUseCase.kt similarity index 97% rename from domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetWalletTotalBalanceUseCaseV2.kt rename to domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetWalletTotalBalanceUseCase.kt index 0bae08d46c..8bc2138974 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetWalletTotalBalanceUseCaseV2.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetWalletTotalBalanceUseCase.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.map * * @param multiAccountStatusListSupplier Supplier that provides the status list for multiple accounts. */ -class GetWalletTotalBalanceUseCaseV2( +class GetWalletTotalBalanceUseCase( private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier, ) { diff --git a/domain/kyc/models/.gitignore b/domain/kyc/models/.gitignore deleted file mode 100644 index 796b96d1c4..0000000000 --- a/domain/kyc/models/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/domain/kyc/models/build.gradle.kts b/domain/kyc/models/build.gradle.kts deleted file mode 100644 index 6b18f3f83f..0000000000 --- a/domain/kyc/models/build.gradle.kts +++ /dev/null @@ -1,9 +0,0 @@ -plugins { - alias(deps.plugins.kotlin.jvm) - alias(deps.plugins.kotlin.serialization) - id("configuration") -} - -dependencies { - implementation(deps.kotlin.serialization) -} \ No newline at end of file diff --git a/domain/tokens/detekt-baseline-debug.xml b/domain/tokens/detekt-baseline-debug.xml deleted file mode 100644 index 3c148b5f78..0000000000 --- a/domain/tokens/detekt-baseline-debug.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - BooleanPropertyNaming:TokenActionsState.kt$TokenActionsState.ActionState.Swap$val showBadge: Boolean - CanBeNonNullable:BaseActionsFactory.kt$BaseActionsFactory$requirementsDeferred: Deferred<AssetRequirementsCondition?>? - CanBeNonNullable:CommonActionsFactory.kt$CommonActionsFactory$swapUnavailableReasonDeferred: Deferred<ScenarioUnavailabilityReason>? - ExplicitCollectionElementAccessMethod:GetWalletTotalBalanceUseCase.kt$GetWalletTotalBalanceUseCase$walletBalanceCache.put(userWalletId, content) - MultilineLambdaItParameter:BaseCurrencyStatusOperations.kt$BaseCurrencyStatusOperations${ singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(rawCurrencyId = it)) .firstOrNull() } - MultilineLambdaItParameter:BaseCurrencyStatusOperations.kt$BaseCurrencyStatusOperations${ val exception = IllegalStateException("$it") Error.DataError(exception) } - MultilineLambdaItParameter:FetchCurrencyStatusUseCase.kt$FetchCurrencyStatusUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -> raise(IllegalStateException("$it")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } return@either } - MultilineLambdaItParameter:GetBalanceNotEnoughForFeeWarningUseCase.kt$GetBalanceNotEnoughForFeeWarningUseCase${ it is CryptoCurrency.Token && it.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) && it.network.derivationPath == tokenStatus.currency.network.derivationPath } - MultilineLambdaItParameter:GetCryptoCurrencyActionsUseCase.kt$GetCryptoCurrencyActionsUseCase${ TokenActionsState( walletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus, states = it.toList(), ) } - MultilineLambdaItParameter:GetWalletTotalBalanceUseCase.kt$GetWalletTotalBalanceUseCase${ Timber.e("failed to load balances with error: $it") TotalFiatBalance.Failed } - MultilineLambdaItParameter:PriceChangeCalculator.kt$PriceChangeCalculator${ val weight = it.value.fiatAmount.orZero().divide(balance, 2, RoundingMode.HALF_UP) val priceChange = it.value.priceChange.orZero() weight * priceChange } - MultilineLambdaItParameter:WalletBalanceFetcher.kt$WalletBalanceFetcher${ val stakingId = stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it) if (stakingId.isLeft { it is StakingIdFactory.Error.UnableToGetAddress }) { Timber.e("Unable to get staking ID for user wallet $userWalletId and currency ${it.id}") } stakingId } - NamedArguments:ApplyTokenListSortingUseCase.kt$ApplyTokenListSortingUseCase$saveTokens(userWalletId, currencies, isGrouped, isSortedByBalance) - NoNameShadowing:WalletBalanceFetcher.kt$WalletBalanceFetcher${ it is StakingIdFactory.Error.UnableToGetAddress } - NullableBooleanCheck:GetCurrencyCheckUseCase.kt$GetCurrencyCheckUseCase$recipientAddress?.let { currencyChecksRepository.checkIfAccountFunded( userWalletId, network, recipientAddress, ) } ?: false - SuspendFunWithFlowReturnType:BaseCurrencyStatusOperations.kt$BaseCurrencyStatusOperations$suspend - SuspendFunWithFlowReturnType:GetNetworkCoinStatusUseCase.kt$GetNetworkCoinStatusUseCase$suspend - SuspendFunWithFlowReturnType:GetSingleCryptoCurrencyStatusUseCase.kt$GetSingleCryptoCurrencyStatusUseCase$suspend - UnnecessaryAbstractClass:MultiWalletCryptoCurrenciesSupplier.kt$MultiWalletCryptoCurrenciesSupplier$MultiWalletCryptoCurrenciesSupplier - UnsafeCallOnNullableType:CommonActionsFactory.kt$CommonActionsFactory$swapUnavailableReasonDeferred!! - - diff --git a/domain/tokens/models/detekt-baseline-main.xml b/domain/tokens/models/detekt-baseline-main.xml deleted file mode 100644 index 80759d1dfb..0000000000 --- a/domain/tokens/models/detekt-baseline-main.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - ObjectExtendsThrowable:RemoveCurrencyError.kt$RemoveCurrencyError$HasLinkedTokens : RemoveCurrencyError - UseEmptyCounterpart:PromoAnalyticsEvent.kt$PromoAnalyticsEvent$mapOf() - UseEmptyCounterpart:TokenExchangeAnalyticsEvent.kt$TokenExchangeAnalyticsEvent$mapOf() - UseEmptyCounterpart:TokenOnrampAnalyticsEvent.kt$TokenOnrampAnalyticsEvent$mapOf() - UseEmptyCounterpart:TokenReceiveAnalyticsEvent.kt$TokenReceiveAnalyticsEvent$mapOf() - UseEmptyCounterpart:TokenReceiveNewAnalyticsEvent.kt$TokenReceiveNewAnalyticsEvent$mapOf() - UseEmptyCounterpart:TokenScreenAnalyticsEvent.kt$TokenScreenAnalyticsEvent$mapOf() - - diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/PromoAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/PromoAnalyticsEvent.kt index df7fde5ad2..56073246a8 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/PromoAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/PromoAnalyticsEvent.kt @@ -5,7 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsParam sealed class PromoAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent(category = "Promotion", event = event, params = params) { data class NoticePromotionBanner( private val source: AnalyticsParam.ScreensSources, diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt index 6206a29df4..18356a99ed 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt @@ -8,7 +8,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM class TokenExchangeAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent("Token", event, params) { class CexTxStatusOpened(token: String, provider: String) : TokenScreenAnalyticsEvent( diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenOnrampAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenOnrampAnalyticsEvent.kt index 8261c75b29..838e4dfa75 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenOnrampAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenOnrampAnalyticsEvent.kt @@ -7,7 +7,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM sealed class TokenOnrampAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent("Onramp", event, params) { class OnrampStatusOpened(tokenSymbol: String, provider: String, fiatCurrency: String) : TokenOnrampAnalyticsEvent( diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveAnalyticsEvent.kt index 7de884bf84..d0767447dd 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveAnalyticsEvent.kt @@ -5,7 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM sealed class TokenReceiveAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent("Token / Receive", event, params) { class ReceiveScreenOpened(token: String) : TokenReceiveAnalyticsEvent( diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt index 5916c680aa..bd66565b40 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt @@ -9,7 +9,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM sealed class TokenReceiveNewAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent("Token / Receive", event, params) { class ReceiveScreenOpened( diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt index 41a0965407..c2c7e8aa7c 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt @@ -14,7 +14,7 @@ import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason */ sealed class TokenScreenAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent("Token", event, params) { /** Legacy event. It has a unique category, but it also is sent on TokenScreen */ diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/remove/RemoveCurrencyError.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/remove/RemoveCurrencyError.kt deleted file mode 100644 index 8af9ed12ea..0000000000 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/remove/RemoveCurrencyError.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tangem.domain.tokens.model.remove - -sealed class RemoveCurrencyError : Throwable() { - data class DataError(override val cause: Throwable) : RemoveCurrencyError() - - object HasLinkedTokens : RemoveCurrencyError() -} \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/staking/YieldExtentions.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/staking/YieldExtentions.kt deleted file mode 100644 index 1005b86690..0000000000 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/staking/YieldExtentions.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tangem.domain.tokens.model.staking - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.staking.model.stakekit.Yield - -fun Yield.getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?) = - tokens.firstOrNull { rawCurrencyId?.value == it.coinGeckoId } ?: token \ No newline at end of file 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 4f7dbc4e74..c825f0b25a 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 @@ -89,10 +89,10 @@ class GetBalanceNotEnoughForFeeWarningUseCase( ) .orEmpty() - val token = tokens.find { - it is CryptoCurrency.Token && - it.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) && - it.network.derivationPath == tokenStatus.currency.network.derivationPath + val token = tokens.find { currency -> + currency is CryptoCurrency.Token && + currency.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) && + currency.network.derivationPath == tokenStatus.currency.network.derivationPath } return if (token != null) { diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt index 3fa0898e2b..670d069a89 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt @@ -106,11 +106,11 @@ class GetCryptoCurrencyActionsUseCase( } } } - .map { + .map { states -> TokenActionsState( walletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus, - states = it.toList(), + states = states.toList(), ) } .flowOn(dispatchers.default) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyCheckUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyCheckUseCase.kt index e1073f1bd0..8cd50559ec 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyCheckUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyCheckUseCase.kt @@ -36,13 +36,15 @@ class GetCurrencyCheckUseCase( currencyStatus = feeCurrencyStatus, balanceAfterTransaction = feeCurrencyBalanceAfterTransaction ?: BigDecimal.ZERO, ) - val isAccountFunded = recipientAddress?.let { + val isAccountFunded = if (recipientAddress != null) { currencyChecksRepository.checkIfAccountFunded( - userWalletId, - network, - recipientAddress, + userWalletId = userWalletId, + network = network, + address = recipientAddress, ) - } ?: false + } else { + false + } val utxoAmountLimit = if (currency is CryptoCurrency.Coin && amount != null && fee != null) { currencyChecksRepository.checkUtxoAmountLimit( userWalletId = userWalletId, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletCryptoCurrenciesSupplier.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletCryptoCurrenciesSupplier.kt index 23b3fa5580..a6aef73dd6 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletCryptoCurrenciesSupplier.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletCryptoCurrenciesSupplier.kt @@ -14,6 +14,7 @@ import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer as Producer * [REDACTED_AUTHOR] */ +@Suppress("UnnecessaryAbstractClass") abstract class MultiWalletCryptoCurrenciesSupplier( override val factory: FlowProducer.Factory, override val keyCreator: (Producer.Params) -> String, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt index c78daffbe3..b69c0ad6d6 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt @@ -137,6 +137,7 @@ internal open class BaseActionsFactory( * @param isAddressAvailable indicates whether the address is available * @param requirementsDeferred a deferred object containing the asset requirements condition */ + @Suppress("CanBeNonNullable") protected suspend fun ActionAvailabilityBuilder.addReceiveAction( isAddressAvailable: Boolean, requirementsDeferred: Deferred?, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt index e90b41cbd4..a5ee00426b 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt @@ -140,6 +140,7 @@ internal class CommonActionsFactory( } } + @Suppress("CanBeNonNullable") private suspend fun createSwapAction( userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus, @@ -153,7 +154,7 @@ internal class CommonActionsFactory( if (!isMultiCurrency) { return ActionState.Swap( unavailabilityReason = ScenarioUnavailabilityReason.SingleWallet, - showBadge = false, + shouldShowBadge = false, ) } @@ -161,21 +162,23 @@ internal class CommonActionsFactory( cryptoCurrency.isCustom -> { ActionState.Swap( unavailabilityReason = ScenarioUnavailabilityReason.CustomToken(cryptoCurrency.name), - showBadge = false, + shouldShowBadge = false, ) } cryptoCurrencyStatus.value is CryptoCurrencyStatus.NoQuote -> { ActionState.Swap( unavailabilityReason = ScenarioUnavailabilityReason.TokenNoQuotes(cryptoCurrency.name), - showBadge = false, + shouldShowBadge = false, ) } else -> { - val reason = swapUnavailableReasonDeferred!!.await() + val reason = requireNotNull(swapUnavailableReasonDeferred) { + "swapUnavailableReasonDeferred must not be null for available swap action" + }.await() return ActionState.Swap( unavailabilityReason = reason, - showBadge = reason == ScenarioUnavailabilityReason.None && shouldShowSwapStories, + shouldShowBadge = reason == ScenarioUnavailabilityReason.None && shouldShowSwapStories, ) } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt index 91ae07242d..9c5659e1d0 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt @@ -150,7 +150,7 @@ internal class OutdatedDataActionsFactory( sources.networkSource == StatusSource.ONLY_CACHE -> ScenarioUnavailabilityReason.UsedOutdatedData else -> ScenarioUnavailabilityReason.DataLoading // CACHE source always when loading }, - showBadge = false, + shouldShowBadge = false, ) } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt index 411ee415a2..11a8155d1d 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt @@ -64,7 +64,7 @@ internal class UnreachableActionsFactory( ActionState.Send(unavailabilityReason = ScenarioUnavailabilityReason.Unreachable), ActionState.Swap( unavailabilityReason = ScenarioUnavailabilityReason.Unreachable, - showBadge = false, + shouldShowBadge = false, ), ActionState.Sell(unavailabilityReason = ScenarioUnavailabilityReason.Unreachable), ActionState.Stake(unavailabilityReason = ScenarioUnavailabilityReason.Unreachable, option = null), diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/TokenActionsState.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/TokenActionsState.kt index 1cea4893f6..1f3284661e 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/TokenActionsState.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/TokenActionsState.kt @@ -29,7 +29,7 @@ data class TokenActionsState( data class Swap( override val unavailabilityReason: ScenarioUnavailabilityReason, - val showBadge: Boolean, + val shouldShowBadge: Boolean, ) : ActionState() data class Send(override val unavailabilityReason: ScenarioUnavailabilityReason) : ActionState() diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculator.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculator.kt index 41ba3d4e24..5b4ee8efdf 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculator.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculator.kt @@ -50,9 +50,9 @@ object PriceChangeCalculator { return createZeroPriceChange(source = walletTotalFiatBalance.source).lceContent() } - val total = statuses.sumOf { - val weight = it.value.fiatAmount.orZero().divide(balance, 2, RoundingMode.HALF_UP) - val priceChange = it.value.priceChange.orZero() + val total = statuses.sumOf { status -> + val weight = status.value.fiatAmount.orZero().divide(balance, 2, RoundingMode.HALF_UP) + val priceChange = status.value.priceChange.orZero() weight * priceChange }.stripTrailingZeros() diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt index 51ea9c7ea3..e20203e84e 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt @@ -189,11 +189,11 @@ class WalletBalanceFetcher internal constructor( userWalletId: UserWalletId, currencies: Set, ): Either = either { - val maybeStakingIds = currencies.map { - val stakingId = stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it) + val maybeStakingIds = currencies.map { currency -> + val stakingId = stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = currency) if (stakingId.isLeft { it is StakingIdFactory.Error.UnableToGetAddress }) { - Timber.e("Unable to get staking ID for user wallet $userWalletId and currency ${it.id}") + Timber.e("Unable to get staking ID for user wallet $userWalletId and currency ${currency.id}") } stakingId diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt index 8041823bc4..2a078e38ed 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt @@ -111,7 +111,7 @@ internal class PortfolioTokenUMConverter( if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { when (action) { is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy - is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(action.showBadge) + is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(action.shouldShowBadge) is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(action.apy) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsActionButtonsConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsActionButtonsConverter.kt index 50052f3557..c7b29743fa 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsActionButtonsConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsActionButtonsConverter.kt @@ -54,7 +54,7 @@ internal class TokenDetailsActionButtonsConverter( TokenDetailsActionButton.Swap( dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None, onClick = { clickIntents.onSwapClick(action.unavailabilityReason) }, - showBadge = action.showBadge, + showBadge = action.shouldShowBadge, ) } else -> { 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 0242c1bf72..b014375088 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 @@ -6,7 +6,7 @@ import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.domain.account.status.usecase.GetWalletTotalBalanceUseCaseV2 +import com.tangem.domain.account.status.usecase.GetWalletTotalBalanceUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.error.SelectedAppCurrencyError import com.tangem.domain.appcurrency.model.AppCurrency @@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.* @Suppress("LongParameterList") internal class DefaultUserWalletsFetcher @AssistedInject constructor( getWalletsUseCase: GetWalletsUseCase, - private val getWalletTotalBalanceUseCaseV2: GetWalletTotalBalanceUseCaseV2, + private val getWalletTotalBalanceUseCase: GetWalletTotalBalanceUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, @Assisted private val onWalletClick: (UserWalletId) -> Unit, @@ -99,7 +99,7 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( // We should not load balances in auth mode flowOf(Lce.Loading(walletIds.associateWith { TotalFiatBalance.Loading })) } else { - getWalletTotalBalanceUseCaseV2(userWalletIds = walletIds) + getWalletTotalBalanceUseCase(userWalletIds = walletIds) } } From b37737ce8c805553b112978ef729669630066806 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 17 Mar 2026 22:16:55 +0700 Subject: [PATCH 56/60] Updated on 2026-08-14 --- .../api/express/TangemExpressApi.kt | 6 +++-- .../data/swap/DefaultSwapRepositoryV2.kt | 27 ++++++++++++++++--- .../domain/swap/models/SwapAmountType.kt | 6 +++++ .../domain/swap/models/SwapQuoteModel.kt | 2 ++ .../tangem/domain/swap/models/SwapRateMode.kt | 7 +++++ .../tangem/domain/swap/SwapRepositoryV2.kt | 12 ++++++--- .../domain/swap/usecase/GetSwapDataUseCase.kt | 7 +++-- .../swap/usecase/GetSwapQuoteUseCase.kt | 15 ++++++++--- .../swap/usecase/SelectInitialPairUseCase.kt | 18 ++++++------- .../v2/impl/amount/entity/SwapAmountUM.kt | 6 +---- .../amount/model/SwapAmountClickIntents.kt | 2 +- .../v2/impl/amount/model/SwapAmountModel.kt | 11 +++++--- .../impl/amount/model/SwapAmountQuoteUtils.kt | 2 +- .../converter/SwapAmountFieldConverter.kt | 2 +- .../SwapAmountBalanceHiddenTransformer.kt | 2 +- .../SwapAmountPrimaryReadyStateTransformer.kt | 2 +- ...wapAmountSecondaryReadyStateTransformer.kt | 2 +- .../SwapAmountSelectQuoteTransformer.kt | 2 +- .../impl/amount/ui/SwapAmountBlockContent.kt | 2 +- .../v2/impl/amount/ui/SwapAmountContent.kt | 2 +- .../ui/preview/SwapAmountClickIntentsStub.kt | 2 +- .../ui/preview/SwapAmountContentPreview.kt | 2 +- .../swap/v2/impl/common/ConfirmData.kt | 2 ++ .../confirm/model/SendWithSwapConfirmModel.kt | 2 ++ .../confirm/model/SwapTransactionSender.kt | 4 ++- 25 files changed, 102 insertions(+), 45 deletions(-) create mode 100644 domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapAmountType.kt create mode 100644 domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapRateMode.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt index d4e2600da3..b64ceb1ec2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt @@ -41,7 +41,8 @@ interface TangemExpressApi { @Query("fromNetwork") fromNetwork: String, @Query("toContractAddress") toContractAddress: String, @Query("toNetwork") toNetwork: String, - @Query("fromAmount") fromAmount: String, + @Query("fromAmount") fromAmount: String?, + @Query("toAmount") toAmount: String? = null, @Query("fromDecimals") fromDecimals: Int, @Query("toDecimals") toDecimals: Int, @Query("providerId") providerId: String, @@ -57,7 +58,8 @@ interface TangemExpressApi { @Query("toContractAddress") toContractAddress: String, @Query("fromAddress") fromAddress: String, @Query("toNetwork") toNetwork: String, - @Query("fromAmount") fromAmount: String, + @Query("fromAmount") fromAmount: String?, + @Query("toAmount") toAmount: String? = null, @Query("fromDecimals") fromDecimals: Int, @Query("toDecimals") toDecimals: Int, @Query("providerId") providerId: String, diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt index 0386fa396c..e4bbdeebee 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt @@ -32,6 +32,7 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusProducer import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.swap.SwapRepositoryV2 import com.tangem.domain.swap.models.* +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.CoroutineScope @@ -177,12 +178,26 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( userWallet: UserWallet, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, - fromAmount: BigDecimal, + amount: BigDecimal, + amountType: SwapAmountType, provider: ExpressProvider, rateType: ExpressRateType, ): SwapQuoteModel = withContext(coroutineDispatcher.io) { val response = tangemExpressApi.getExchangeQuote( - fromAmount = fromAmount.movePointRight(fromCryptoCurrency.decimals).toString(), + fromAmount = if (amountType == SwapAmountType.From) { + amount.movePointRight( + fromCryptoCurrency.decimals, + ).toString() + } else { + null + }, + toAmount = if (amountType == SwapAmountType.To) { + amount.movePointRight( + toCryptoCurrency.decimals, + ).toString() + } else { + null + }, fromNetwork = fromCryptoCurrency.network.backendId, fromContractAddress = fromCryptoCurrency.getContractAddress(), fromDecimals = fromCryptoCurrency.decimals, @@ -199,10 +214,12 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( ).getOrThrow() val toTokenAmount = requireNotNull(response.toAmount.toBigDecimalOrNull()?.movePointLeft(response.toDecimals)) + val fromTokenAmount = response.fromAmount.toBigDecimalOrNull()?.movePointLeft(response.fromDecimals) return@withContext SwapQuoteModel( provider = provider, toTokenAmount = toTokenAmount, + fromTokenAmount = fromTokenAmount, allowanceContract = response.allowanceContract, ) } @@ -211,7 +228,8 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, toCryptoCurrency: CryptoCurrency, - fromAmount: String, + amount: String, + amountType: SwapAmountType, toAddress: String, toExtraId: String?, expressProvider: ExpressProvider, @@ -241,7 +259,8 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( toAddress = toAddress, fromDecimals = fromCurrency.decimals, toDecimals = toCryptoCurrency.decimals, - fromAmount = fromAmount, + fromAmount = if (amountType == SwapAmountType.From) amount else null, + toAmount = if (amountType == SwapAmountType.To) amount else null, providerId = expressProvider.providerId, rateType = rateType.name.lowercase(), requestId = requestId, diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapAmountType.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapAmountType.kt new file mode 100644 index 0000000000..4a39ff5a50 --- /dev/null +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapAmountType.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.swap.models + +enum class SwapAmountType { + From, + To, +} \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapQuoteModel.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapQuoteModel.kt index a808f07e67..3a65c92b9c 100644 --- a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapQuoteModel.kt +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapQuoteModel.kt @@ -8,10 +8,12 @@ import java.math.BigDecimal * * @property provider swap provider * @property toTokenAmount amount of token you want to receive + * @property fromTokenAmount amount of from-token required (for fixed rate quotes) * @property allowanceContract whether swap occurs via third token */ data class SwapQuoteModel( val provider: ExpressProvider, val toTokenAmount: BigDecimal, + val fromTokenAmount: BigDecimal?, val allowanceContract: String?, ) \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapRateMode.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapRateMode.kt new file mode 100644 index 0000000000..b535fed8e6 --- /dev/null +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapRateMode.kt @@ -0,0 +1,7 @@ +package com.tangem.domain.swap.models + +enum class SwapRateMode { + FLOAT_ONLY, + FIXED_ONLY, + FLOAT_AND_FIXED, +} \ No newline at end of file diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt b/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt index 09bbb443fe..b456087ac4 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt @@ -52,7 +52,8 @@ interface SwapRepositoryV2 { * @param userWallet selected user wallet * @param fromCryptoCurrency currency being swapped from * @param toCryptoCurrency currency being swapped to - * @param fromAmount swap amount + * @param amount swap amount + * @param amountType specifies whether amount is fromAmount or toAmount * @param provider selected express provider * @param rateType rate type */ @@ -60,7 +61,8 @@ interface SwapRepositoryV2 { userWallet: UserWallet, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, - fromAmount: BigDecimal, + amount: BigDecimal, + amountType: SwapAmountType, provider: ExpressProvider, rateType: ExpressRateType, ): SwapQuoteModel @@ -71,7 +73,8 @@ interface SwapRepositoryV2 { * @param userWallet selected user wallet * @param fromCryptoCurrencyStatus currency status being swapped from * @param toCryptoCurrency currency being swapped to - * @param fromAmount swap amount + * @param amount swap amount + * @param amountType specifies whether amount is fromAmount or toAmount * @param toAddress destination address * @param expressProvider selected swap provider * @param rateType selected provider rate type @@ -81,7 +84,8 @@ interface SwapRepositoryV2 { userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, toCryptoCurrency: CryptoCurrency, - fromAmount: String, + amount: String, + amountType: SwapAmountType, toAddress: String, toExtraId: String?, expressProvider: ExpressProvider, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt index fed0e324ab..448e5ddcd5 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt @@ -10,6 +10,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.SwapErrorResolver import com.tangem.domain.swap.SwapRepositoryV2 +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapDataModel @Suppress("LongParameterList") @@ -21,7 +22,8 @@ class GetSwapDataUseCase( suspend operator fun invoke( userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, - fromAmount: String, + amount: String, + amountType: SwapAmountType, toCryptoCurrency: CryptoCurrency, toAddress: String, toExtraId: String?, @@ -32,7 +34,8 @@ class GetSwapDataUseCase( swapRepositoryV2.getSwapData( userWallet = userWallet, fromCryptoCurrencyStatus = fromCryptoCurrencyStatus, - fromAmount = fromAmount, + amount = amount, + amountType = amountType, toCryptoCurrency = toCryptoCurrency, toAddress = toAddress, toExtraId = toExtraId, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapQuoteUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapQuoteUseCase.kt index 1c7659ab36..be11d55e46 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapQuoteUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapQuoteUseCase.kt @@ -7,6 +7,7 @@ import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.swap.SwapErrorResolver import com.tangem.domain.swap.SwapRepositoryV2 +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapQuoteModel import com.tangem.domain.models.wallet.UserWallet import java.math.BigDecimal @@ -14,6 +15,7 @@ import java.math.BigDecimal /** * Get swap quote for selected pair */ +@Suppress("LongParameterList") class GetSwapQuoteUseCase( private val swapRepositoryV2: SwapRepositoryV2, private val swapErrorResolver: SwapErrorResolver, @@ -23,23 +25,28 @@ class GetSwapQuoteUseCase( * @param userWallet selected user wallet * @param fromCryptoCurrency currency swap from * @param toCryptoCurrency currency swap to - * @param fromAmount swap amount + * @param amount swap amount + + * @param rateType rate type for the quote. Float API does not support [SwapAmountType.To]. * @param provider swap provider */ suspend operator fun invoke( userWallet: UserWallet, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, - fromAmount: BigDecimal, + amount: BigDecimal, + amountType: SwapAmountType, + rateType: ExpressRateType, provider: ExpressProvider, ): Either = Either.catch { swapRepositoryV2.getSwapQuote( userWallet = userWallet, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, - fromAmount = fromAmount, + amount = amount, + amountType = amountType, provider = provider, - rateType = ExpressRateType.Float, // todo rate type + rateType = rateType, ) }.mapLeft(swapErrorResolver::resolve) } \ No newline at end of file diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SelectInitialPairUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SelectInitialPairUseCase.kt index 4efb56dffe..aedc62d206 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SelectInitialPairUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SelectInitialPairUseCase.kt @@ -1,9 +1,9 @@ package com.tangem.domain.swap.usecase 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.swap.SwapTransactionRepository +import com.tangem.domain.swap.models.SwapCryptoCurrency import com.tangem.domain.swap.models.SwapCurrencies import com.tangem.domain.swap.models.SwapCurrenciesGroup import com.tangem.domain.swap.models.SwapDirection @@ -30,22 +30,22 @@ class SelectInitialPairUseCase( secondaryCryptoCurrency: CryptoCurrency?, swapCurrencies: SwapCurrencies, swapDirection: SwapDirection, - ): CryptoCurrencyStatus? { + ): SwapCryptoCurrency? { val swapCurrenciesGroup = swapCurrencies.getGroupWithDirection(swapDirection) return tryToGetAlreadySelectedCurrency(secondaryCryptoCurrency, swapCurrenciesGroup) ?: tryGetFromCache(userWallet, primaryCryptoCurrency, swapCurrenciesGroup) ?: tryGetWithMaxAmount(swapCurrenciesGroup) - ?: swapCurrenciesGroup.available.firstOrNull()?.currencyStatus + ?: swapCurrenciesGroup.available.firstOrNull() } private fun tryToGetAlreadySelectedCurrency( secondaryCryptoCurrency: CryptoCurrency?, swapCurrenciesGroup: SwapCurrenciesGroup, - ): CryptoCurrencyStatus? { + ): SwapCryptoCurrency? { return secondaryCryptoCurrency?.let { swapCurrenciesGroup.available.firstOrNull { currency -> secondaryCryptoCurrency.id == currency.currencyStatus.currency.id - }?.currencyStatus + } } } @@ -53,19 +53,19 @@ class SelectInitialPairUseCase( userWallet: UserWallet, primaryCryptoCurrency: CryptoCurrency, swapCurrenciesGroup: SwapCurrenciesGroup, - ): CryptoCurrencyStatus? { + ): SwapCryptoCurrency? { val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(userWallet.walletId) ?: return null return if (id != primaryCryptoCurrency.id.value) { - swapCurrenciesGroup.available.find { it.currencyStatus.currency.id.value == id }?.currencyStatus + swapCurrenciesGroup.available.find { it.currencyStatus.currency.id.value == id } } else { null } } - private fun tryGetWithMaxAmount(swapCurrenciesGroup: SwapCurrenciesGroup): CryptoCurrencyStatus? { + private fun tryGetWithMaxAmount(swapCurrenciesGroup: SwapCurrenciesGroup): SwapCryptoCurrency? { return swapCurrenciesGroup.available.maxByOrNull { it.currencyStatus.value.fiatAmount.orZero() - }?.currencyStatus + } } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt index 4cbc2867a6..c749636dc6 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt @@ -7,6 +7,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapCurrencies import com.tangem.domain.swap.models.SwapDirection import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM @@ -92,9 +93,4 @@ sealed class PriceImpactUM { data object Empty : PriceImpactUM() data class Value(val value: Float) : PriceImpactUM() -} - -enum class SwapAmountType { - From, - To, } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountClickIntents.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountClickIntents.kt index cf6b75586e..0e9ba829e8 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountClickIntents.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountClickIntents.kt @@ -1,7 +1,7 @@ package com.tangem.features.swap.v2.impl.amount.model import com.tangem.common.ui.amountScreen.AmountScreenClickIntents -import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType +import com.tangem.domain.swap.models.SwapAmountType internal interface SwapAmountClickIntents : AmountScreenClickIntents { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt index 75b3107ff5..2b3fc14c6d 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -20,6 +20,7 @@ import com.tangem.datasource.local.swap.SwapBestRateAnimationStore import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.notifications.ShouldShowNotificationUseCase @@ -43,7 +44,6 @@ import com.tangem.features.swap.v2.impl.amount.SwapAmountReduceListener import com.tangem.features.swap.v2.impl.amount.SwapAmountUpdateListener import com.tangem.features.swap.v2.impl.amount.analytics.SwapAmountAnalyticEvents import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM -import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.model.converter.SwapQuoteUMConverter import com.tangem.features.swap.v2.impl.amount.model.transformers.* @@ -525,7 +525,7 @@ internal class SwapAmountModel @Inject constructor( private fun initPairs(swapCurrencies: SwapCurrencies, secondaryCryptoCurrency: CryptoCurrency?) { modelScope.launch { - val secondaryStatus = selectInitialPairUseCase( + val swapCryptoCurrency = selectInitialPairUseCase( primaryCryptoCurrency = primaryCryptoCurrency, secondaryCryptoCurrency = secondaryCryptoCurrency, userWallet = userWallet, @@ -533,6 +533,8 @@ internal class SwapAmountModel @Inject constructor( swapDirection = params.swapDirection, ) + val secondaryStatus = swapCryptoCurrency?.currencyStatus + val primaryStatus = (uiState.value as? SwapAmountUM.Content)?.primaryCryptoCurrencyStatus if (secondaryStatus != null && primaryStatus != null) { initCurrencies(primaryStatus, secondaryStatus) @@ -594,6 +596,7 @@ internal class SwapAmountModel @Inject constructor( } } + @Suppress("LongMethod") private fun loadQuotes(isSilentReload: Boolean) { val state = uiState.value as? SwapAmountUM.Content ?: return if (state.secondaryCryptoCurrencyStatus == null) return @@ -629,7 +632,9 @@ internal class SwapAmountModel @Inject constructor( userWallet = userWallet, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, - fromAmount = fromAmountValue, + amount = fromAmountValue, + amountType = SwapAmountType.From, + rateType = ExpressRateType.Float, provider = provider, ).fold( ifLeft = { error -> diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountQuoteUtils.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountQuoteUtils.kt index 9bc9fb3314..f3b1614ce2 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountQuoteUtils.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountQuoteUtils.kt @@ -5,9 +5,9 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapDirection import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM -import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.utils.extensions.isZero import com.tangem.utils.isNullOrZero diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt index ae17646047..9adad0ddeb 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt @@ -15,10 +15,10 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapDirection import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM -import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType import com.tangem.utils.StringsSigns.DOT @Suppress("LongParameterList") diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountBalanceHiddenTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountBalanceHiddenTransformer.kt index 56a7fab934..ede7a349fb 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountBalanceHiddenTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountBalanceHiddenTransformer.kt @@ -5,9 +5,9 @@ import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapDirection import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM -import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountFieldConverter import com.tangem.utils.transformer.Transformer diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountPrimaryReadyStateTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountPrimaryReadyStateTransformer.kt index aa09d11164..bb5046a48d 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountPrimaryReadyStateTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountPrimaryReadyStateTransformer.kt @@ -6,10 +6,10 @@ import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapCurrencies import com.tangem.domain.swap.models.SwapDirection import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM -import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountFieldConverter import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt index f8f8600421..545c13db6e 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt @@ -6,9 +6,9 @@ import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapCurrencies import com.tangem.domain.swap.models.SwapDirection -import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountFieldConverter import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt index 03058c869b..3f3f9ac158 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt @@ -5,8 +5,8 @@ import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM -import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.model.SwapAmountQuoteUtils.calculatePriceImpact import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountErrorConverter diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt index 008e402a09..b4df59fefc 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt @@ -37,9 +37,9 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM -import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountContentPreview import com.tangem.features.swap.v2.impl.chooseprovider.ui.SwapChooseProviderContent diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountContent.kt index 61ec2095f4..b314e610b9 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountContent.kt @@ -39,9 +39,9 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM -import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.model.SwapAmountClickIntents import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountClickIntentsStub diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountClickIntentsStub.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountClickIntentsStub.kt index 4c55f4759a..4a7380480b 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountClickIntentsStub.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountClickIntentsStub.kt @@ -1,6 +1,6 @@ package com.tangem.features.swap.v2.impl.amount.ui.preview -import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.amount.model.SwapAmountClickIntents internal object SwapAmountClickIntentsStub : SwapAmountClickIntents { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt index 798b7fd4d0..b4ebcff7cb 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt @@ -12,10 +12,10 @@ import com.tangem.domain.express.models.ExpressRateType 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.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapCurrencies import com.tangem.domain.swap.models.SwapDirection import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM -import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import com.tangem.utils.StringsSigns diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/ConfirmData.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/ConfirmData.kt index e0fd2912f4..7da47a57aa 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/ConfirmData.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/ConfirmData.kt @@ -4,6 +4,7 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.transaction.error.GetFeeError import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import java.math.BigDecimal @@ -21,4 +22,5 @@ internal data class ConfirmData( val fromAccount: Account?, val quote: SwapQuoteUM?, val rateType: ExpressRateType?, + val amountType: SwapAmountType, ) \ No newline at end of file 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 99fcf5c1e4..bea5544651 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 @@ -29,6 +29,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapDirection.Companion.withSwapDirection import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.transaction.error.GetFeeError @@ -158,6 +159,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( fromAccount = params.accountFlow.value, quote = amountUM?.selectedQuote, rateType = amountUM?.swapRateType, + amountType = amountUM?.selectedAmountType ?: SwapAmountType.From, ) } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt index 3618110f82..41f709edb1 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt @@ -11,6 +11,7 @@ import com.tangem.domain.express.models.ExpressProviderType import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapDataModel import com.tangem.domain.swap.models.SwapDataTransactionModel import com.tangem.domain.swap.models.SwapTxType @@ -101,7 +102,8 @@ internal class SwapTransactionSender @AssistedInject constructor( val swapData = getSwapDataUseCase( userWallet = userWallet, fromCryptoCurrencyStatus = fromStatus, - fromAmount = fromAmount.toStringWithRightOffset(fromStatus.currency.decimals), + amount = fromAmount.toStringWithRightOffset(fromStatus.currency.decimals), + amountType = SwapAmountType.From, toCryptoCurrency = toStatus.currency, toAddress = destination, toExtraId = confirmData.enteredMemo, From 5da322576ba2d70b02ecc45defa5fffaa206a4d5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 17 Mar 2026 08:19:37 -0700 Subject: [PATCH 57/60] Updated on 2026-08-14 --- features/payment/api/.gitignore | 1 + features/payment/api/build.gradle.kts | 12 ++++++++++++ features/payment/impl/.gitignore | 1 + features/payment/impl/build.gradle.kts | 12 ++++++++++++ features/virtual-accounts/details/api/.gitignore | 1 + .../virtual-accounts/details/api/build.gradle.kts | 12 ++++++++++++ features/virtual-accounts/details/impl/.gitignore | 1 + .../virtual-accounts/details/impl/build.gradle.kts | 12 ++++++++++++ features/virtual-accounts/main/api/.gitignore | 1 + features/virtual-accounts/main/api/build.gradle.kts | 12 ++++++++++++ features/virtual-accounts/main/impl/.gitignore | 1 + .../virtual-accounts/main/impl/build.gradle.kts | 12 ++++++++++++ features/virtual-accounts/onboarding/api/.gitignore | 1 + .../onboarding/api/build.gradle.kts | 12 ++++++++++++ .../virtual-accounts/onboarding/impl/.gitignore | 1 + .../onboarding/impl/build.gradle.kts | 12 ++++++++++++ settings.gradle.kts | 13 +++++++++++++ 17 files changed, 117 insertions(+) create mode 100644 features/payment/api/.gitignore create mode 100644 features/payment/api/build.gradle.kts create mode 100644 features/payment/impl/.gitignore create mode 100644 features/payment/impl/build.gradle.kts create mode 100644 features/virtual-accounts/details/api/.gitignore create mode 100644 features/virtual-accounts/details/api/build.gradle.kts create mode 100644 features/virtual-accounts/details/impl/.gitignore create mode 100644 features/virtual-accounts/details/impl/build.gradle.kts create mode 100644 features/virtual-accounts/main/api/.gitignore create mode 100644 features/virtual-accounts/main/api/build.gradle.kts create mode 100644 features/virtual-accounts/main/impl/.gitignore create mode 100644 features/virtual-accounts/main/impl/build.gradle.kts create mode 100644 features/virtual-accounts/onboarding/api/.gitignore create mode 100644 features/virtual-accounts/onboarding/api/build.gradle.kts create mode 100644 features/virtual-accounts/onboarding/impl/.gitignore create mode 100644 features/virtual-accounts/onboarding/impl/build.gradle.kts diff --git a/features/payment/api/.gitignore b/features/payment/api/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/payment/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/payment/api/build.gradle.kts b/features/payment/api/build.gradle.kts new file mode 100644 index 0000000000..b7fff54008 --- /dev/null +++ b/features/payment/api/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.payment.api" +} + +dependencies { +} \ No newline at end of file diff --git a/features/payment/impl/.gitignore b/features/payment/impl/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/payment/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/payment/impl/build.gradle.kts b/features/payment/impl/build.gradle.kts new file mode 100644 index 0000000000..327e8c0d22 --- /dev/null +++ b/features/payment/impl/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.payment.impl" +} + +dependencies { +} \ No newline at end of file diff --git a/features/virtual-accounts/details/api/.gitignore b/features/virtual-accounts/details/api/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/virtual-accounts/details/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/virtual-accounts/details/api/build.gradle.kts b/features/virtual-accounts/details/api/build.gradle.kts new file mode 100644 index 0000000000..ccb34f0307 --- /dev/null +++ b/features/virtual-accounts/details/api/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.virtualaccount.details.api" +} + +dependencies { +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/.gitignore b/features/virtual-accounts/details/impl/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/virtual-accounts/details/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/virtual-accounts/details/impl/build.gradle.kts b/features/virtual-accounts/details/impl/build.gradle.kts new file mode 100644 index 0000000000..902b8634e2 --- /dev/null +++ b/features/virtual-accounts/details/impl/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.virtualaccount.details.impl" +} + +dependencies { +} \ No newline at end of file diff --git a/features/virtual-accounts/main/api/.gitignore b/features/virtual-accounts/main/api/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/virtual-accounts/main/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/virtual-accounts/main/api/build.gradle.kts b/features/virtual-accounts/main/api/build.gradle.kts new file mode 100644 index 0000000000..7cb5ec24bc --- /dev/null +++ b/features/virtual-accounts/main/api/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.virtualaccount.main.api" +} + +dependencies { +} \ No newline at end of file diff --git a/features/virtual-accounts/main/impl/.gitignore b/features/virtual-accounts/main/impl/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/virtual-accounts/main/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/virtual-accounts/main/impl/build.gradle.kts b/features/virtual-accounts/main/impl/build.gradle.kts new file mode 100644 index 0000000000..e7c483cb69 --- /dev/null +++ b/features/virtual-accounts/main/impl/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.virtualaccount.main.impl" +} + +dependencies { +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/api/.gitignore b/features/virtual-accounts/onboarding/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/virtual-accounts/onboarding/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/api/build.gradle.kts b/features/virtual-accounts/onboarding/api/build.gradle.kts new file mode 100644 index 0000000000..bd895bec0a --- /dev/null +++ b/features/virtual-accounts/onboarding/api/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.virtualaccount.onboarding.api" +} + +dependencies { +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/.gitignore b/features/virtual-accounts/onboarding/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/build.gradle.kts b/features/virtual-accounts/onboarding/impl/build.gradle.kts new file mode 100644 index 0000000000..76108a7e60 --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.virtualaccount.onboarding.impl" +} + +dependencies { +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 76cb4af3e4..ee0ad7a24b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -308,6 +308,19 @@ include(":features:feed:impl") include(":features:promo-banners:api") include(":features:promo-banners:impl") + +include(":features:payment:api") +include(":features:payment:impl") + +/* Virtual Accounts */ +include(":features:virtual-accounts:onboarding:api") +include(":features:virtual-accounts:onboarding:impl") + +include(":features:virtual-accounts:main:api") +include(":features:virtual-accounts:main:impl") + +include(":features:virtual-accounts:details:api") +include(":features:virtual-accounts:details:impl") // endregion Feature modules // region Domain modules From c037bec366325afd1cbabd15a60d102b43bf3257 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 17 Mar 2026 20:20:52 +0500 Subject: [PATCH 58/60] Updated on 2026-08-14 --- .../core/ui/ds/button/GhostTangemButton.kt | 1 + .../core/ui/ds/button/TangemButtonInternal.kt | 6 +- .../ui/ds/field/search/TangemSearchField.kt | 346 ++++++++++++++++++ .../tangem/core/ui/ds/topbar/TangemTopBar.kt | 225 ++++++------ .../ui/ds/topbar/TangemTopBarActionContent.kt | 53 +++ .../core/ui/ds/topbar/TangemTopBarType.kt | 45 +++ .../tangem/core/ui/res/TangemThemeRedesign.kt | 26 +- .../ui/src/main/res/drawable/ic_search_24.xml | 7 +- .../res/drawable/ic_search_default_24.xml | 5 + .../storybook/entity/StoryBookPage.kt | 6 + .../storybook/page/searchfield/Build.kt | 18 + .../searchfield/TangemSearchFieldStory.kt | 151 ++++++++ .../storybook/ui/StoryBookListScreen.kt | 2 + .../storybook/ui/StoryBookScreen.kt | 3 + .../ui/OrganizeTokensContent.kt | 4 +- 15 files changed, 782 insertions(+), 116 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBarActionContent.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBarType.kt create mode 100644 core/ui/src/main/res/drawable/ic_search_default_24.xml create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/searchfield/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/searchfield/TangemSearchFieldStory.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt index 93369d7b46..f135158d06 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt @@ -80,6 +80,7 @@ fun GhostTangemButton( contentColor = contentColor, isEnabled = isEnabled, isLoading = isLoading, + hasPadding = false, size = size, iconPosition = iconPosition, iconRes = iconRes, diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt index a4574014be..af41dd86cc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt @@ -43,8 +43,9 @@ private const val LOADING_ANIMATION_DURATION = 150 * @param text TextReference for the button label. * @param iconRes Drawable resource ID for the icon to be displayed in the button. * @param iconPosition Position of the icon (Start or End). - * @param isEnabled Boolean indicating whether the button is enabled. + * @param isEnabled Boolean indicating whether the button is enabled. * @param isLoading Boolean indicating whether the button is in a loading state. + * @param hasPadding Boolean indicating whether the button should have padding around its content. * @param contentColor Color of the button content (text and icon). * @param size TangemButtonSize defining the size of the button. * @@ -60,6 +61,7 @@ internal fun TangemButtonInternal( iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, isEnabled: Boolean = true, isLoading: Boolean = false, + hasPadding: Boolean = true, contentColor: Color = TangemTheme.colors2.text.neutral.primary, size: TangemButtonSize = TangemButtonSize.X15, ) { @@ -72,7 +74,7 @@ internal fun TangemButtonInternal( .conditionalCompose(text == null) { width(size.toHeightDp()) } - .conditionalCompose(text != null) { + .conditionalCompose(text != null && hasPadding) { padding(size.toPaddingDp()) } .animateContentSize(), diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt new file mode 100644 index 0000000000..1635ee15f2 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt @@ -0,0 +1,346 @@ +package com.tangem.core.ui.ds.field.search + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.R +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.ds.button.GhostTangemButton +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.BaseSearchBarTestTags +import com.tangem.core.ui.test.SearchBarTestTags + +/** + * Shape options for [TangemSearchField]. + */ +enum class TangemFieldShape { + RoundedCorners, + Circle, + ; + + @ReadOnlyComposable + @Composable + internal fun toShape(): Shape { + return when (this) { + RoundedCorners -> RoundedCornerShape(TangemTheme.dimens2.x4) + Circle -> CircleShape + } + } +} + +/** + * Custom search field component that provides a user-friendly interface for searching and filtering content. + * + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8445-19217&m=dev) + * + * @param state The state of the search field, including the current query, placeholder text, and active status. + * @param shape The shape of the search field, which can be either rounded corners or a circle. + * @param modifier The modifier to be applied to the search field. + * @param enabled Whether the search field is enabled or not. + * @param focusRequester The [FocusRequester] used to request focus on the search field when it becomes active. + */ +@Composable +fun TangemSearchField( + state: SearchBarUM, + shape: TangemFieldShape, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val interactionSource = remember { MutableInteractionSource() } + var isInitialComposition by rememberSaveable { mutableStateOf(true) } + LaunchedEffect(Unit) { + isInitialComposition = false + } + BasicTextField( + modifier = modifier + .heightIn(min = TangemTheme.dimens2.x11) + .onFocusChanged { focusState -> + if (!isInitialComposition) { + if (focusState.isFocused) { + state.onActiveChange(true) + } else { + state.onActiveChange(false) + } + } + } + .focusRequester(focusRequester) + .testTag(BaseSearchBarTestTags.SEARCH_BAR), + enabled = enabled, + value = state.query, + onValueChange = state.onQueryChange, + interactionSource = interactionSource, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search, + ), + keyboardActions = KeyboardActions( + onSearch = { + keyboardController?.hide() + focusManager.clearFocus() + }, + ), + singleLine = true, + maxLines = 1, + textStyle = TangemTheme.typography2.bodySemibold16.copy( + color = TangemTheme.colors2.text.neutral.primary, + ), + cursorBrush = SolidColor(TangemTheme.colors2.graphic.neutral.primary), + decorationBox = @Composable { innerTextField -> + DecorationBox( + state = state, + shape = shape, + innerTextField = innerTextField, + focusManager = focusManager, + interactionSource = interactionSource, + color = TangemTheme.colors2.field.backgroundDefault, + keyboardController = keyboardController, + ) + }, + ) +} + +@Suppress("LongParameterList") +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DecorationBox( + state: SearchBarUM, + shape: TangemFieldShape, + focusManager: FocusManager, + interactionSource: MutableInteractionSource, + color: Color, + keyboardController: SoftwareKeyboardController?, + innerTextField: @Composable () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + val isFocused by interactionSource.collectIsFocusedAsState() + val alignmentBias by animateFloatAsState( + targetValue = if (isFocused) -1f else 0f, + animationSpec = tween(), + label = "AlignmentBias", + ) + + Box( + contentAlignment = BiasAlignment(horizontalBias = alignmentBias, verticalBias = 0f), + modifier = Modifier + .weight(1f) + .background(color, shape.toShape()) + .padding(TangemTheme.dimens2.x3), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + Icon( + modifier = Modifier + .size(TangemTheme.dimens2.x5) + .testTag(SearchBarTestTags.ICON), + painter = painterResource(id = R.drawable.ic_search_default_24), + tint = TangemTheme.colors2.graphic.neutral.tertiaryConstant, + contentDescription = null, + ) + Field( + state = state, + innerTextField = innerTextField, + ) + ClearButton( + state = state, + ) + } + } + CancelButton( + state = state, + keyboardController = keyboardController, + focusManager = focusManager, + isActive = isFocused, + ) + } +} + +@Composable +private fun Field(state: SearchBarUM, innerTextField: @Composable () -> Unit) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .heightIn(min = TangemTheme.dimens2.x5) + .width(IntrinsicSize.Max), + ) { + innerTextField() + + val placeholderOpacity by remember(state.query) { + derivedStateOf { + if (state.query.isNotEmpty()) { + 0f + } else { + 1f + } + } + } + + Text( + text = state.placeholderText.resolveReference(), + color = TangemTheme.colors2.text.neutral.tertiary, + style = TangemTheme.typography2.bodySemibold16, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .testTag(SearchBarTestTags.PLACEHOLDER_TEXT) + .alpha(placeholderOpacity), + ) + } +} + +@Composable +private fun ClearButton(state: SearchBarUM) { + if (state.query.isNotEmpty()) { + Box(modifier = Modifier.fillMaxWidth()) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_close_new_20), + tint = TangemTheme.colors2.graphic.neutral.tertiary, + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterEnd) + .size(TangemTheme.dimens2.x5) + .clip(CircleShape) + .clickable( + onClick = state.onClearClick, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false), + ) + .testTag(SearchBarTestTags.CLEAR_BUTTON), + ) + } + } +} + +@Composable +private fun CancelButton( + state: SearchBarUM, + isActive: Boolean, + focusManager: FocusManager, + keyboardController: SoftwareKeyboardController?, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = isActive, + enter = expandHorizontally(), + exit = shrinkHorizontally(), + modifier = modifier, + ) { + GhostTangemButton( + text = resourceReference(R.string.common_cancel), + size = TangemButtonSize.X9, + modifier = Modifier.padding(start = TangemTheme.dimens2.x3), + onClick = { + if (state.query.isNotEmpty()) { + state.onQueryChange("") + } + focusManager.clearFocus() + keyboardController?.hide() + state.onActiveChange(false) + state.onClearClick() + }, + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemSearchField_Preview(@PreviewParameter(TangemSearchFieldPreviewProvider::class) params: SearchBarUM) { + var state by remember { mutableStateOf(params) } + TangemThemePreviewRedesign { + Column( + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x4), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(TangemTheme.dimens2.x4), + ) { + TangemFieldShape.entries.fastForEach { shape -> + TangemSearchField( + state = state.copy( + onActiveChange = { state = state.copy(isActive = it) }, + onQueryChange = { state = state.copy(query = it) }, + onClearClick = { state = state.copy(query = "") }, + ), + shape = shape, + ) + } + } + } +} + +private class TangemSearchFieldPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SearchBarUM( + placeholderText = stringReference("Crypto, news and more"), + query = "BTC", + onQueryChange = {}, + isActive = true, + onActiveChange = {}, + ), + SearchBarUM( + placeholderText = stringReference("Crypto, news and more"), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = {}, + ), + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt index ecb96f61cf..9b687679f5 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt @@ -5,23 +5,18 @@ import androidx.annotation.DrawableRes import androidx.compose.animation.* import androidx.compose.foundation.background 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.runtime.remember 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.lerp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.unit.Dp import com.tangem.core.ui.R import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme @@ -48,6 +43,7 @@ fun TangemTopBar( title: TextReference? = null, subtitle: TextReference? = null, startAction: TangemTopBarActionUM? = null, + type: TangemTopBarType = TangemTopBarType.Default, endActions: ImmutableList = persistentListOf(), @DrawableRes titleIconRes: Int? = null, ) { @@ -55,17 +51,21 @@ fun TangemTopBar( title = title, subtitle = subtitle, titleIconRes = titleIconRes, + type = type, modifier = modifier, startContent = if (startAction != null) { - { TangemTopBarActionContent(startAction) } + { TangemTopBarActionContent(actionUM = startAction, type = type) } } else { null }, endContent = if (endActions.isNotEmpty()) { { - Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x5)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x5), + ) { endActions.forEach { action -> - TangemTopBarActionContent(action) + TangemTopBarActionContent(actionUM = action, type = type) } } } @@ -88,54 +88,88 @@ fun TangemTopBar( @Composable fun TangemTopBar( modifier: Modifier = Modifier, + type: TangemTopBarType = TangemTopBarType.Default, title: TextReference? = null, subtitle: TextReference? = null, @DrawableRes titleIconRes: Int? = null, startContent: @Composable (() -> Unit)? = null, endContent: @Composable (() -> Unit)? = null, ) { - Box( + TangemTopBar( + modifier = modifier, + type = type, + startContent = startContent, + endContent = endContent, + content = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5), + ) { + TangemTopBarTitle(title = title, titleIconRes = titleIconRes) + AnimatedVisibility( + visible = subtitle != null, + label = "Subtitle Visibility", + ) { + val wrappedSubtitle = remember(this) { requireNotNull(subtitle) } + Text( + text = wrappedSubtitle.resolveAnnotatedReference(), + color = TangemTheme.colors2.text.neutral.secondary, + style = TangemTheme.typography2.bodyRegular15, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } + } + }, + ) +} + +/** + * A top bar composable that displays a title and optional start and end icons. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8435-74860&m=dev) + * + * @param modifier Modifier to be applied to the top bar. + * @param type Type of the top bar, which determines its size and padding. + * @param content Composable content to be displayed in the center of the top bar + * @param startContent Optional composable content to be displayed at the start (left) of the top bar. + * @param endContent Optional composable content to be displayed at the end (right) of the top bar. + */ +@Composable +fun TangemTopBar( + modifier: Modifier = Modifier, + type: TangemTopBarType = TangemTopBarType.Default, + content: @Composable () -> Unit, + startContent: @Composable (() -> Unit)? = null, + endContent: @Composable (() -> Unit)? = null, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, modifier = modifier .fillMaxWidth() - .height(TangemTheme.dimens2.x16) - .padding(TangemTheme.dimens2.x4, TangemTheme.dimens2.x3), + .heightIn(min = type.getSize()) + .padding(type.getPadding()), ) { - AnimatedVisibility( - visible = startContent != null, - modifier = Modifier.align(Alignment.CenterStart), + AnimatedContent( + targetState = startContent != null, + modifier = Modifier.size(TangemTheme.dimens2.x11), label = "Start Content Visibility", - ) { - startContent?.invoke() - } - - Column( - modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5), - ) { - TangemTopBarTitle(title = title, titleIconRes = titleIconRes) - AnimatedVisibility( - visible = subtitle != null, - label = "Subtitle Visibility", - ) { - val wrappedSubtitle = remember(this) { requireNotNull(subtitle) } - Text( - text = wrappedSubtitle.resolveAnnotatedReference(), - color = TangemTheme.colors2.text.neutral.secondary, - style = TangemTheme.typography2.bodyRegular15, - textAlign = TextAlign.Center, - maxLines = 1, - ) + ) { isVisible -> + if (isVisible) { + startContent?.invoke() } } - AnimatedVisibility( - visible = endContent != null, - modifier = Modifier.align(Alignment.CenterEnd), + + content() + + AnimatedContent( + targetState = endContent != null, + modifier = Modifier.size(TangemTheme.dimens2.x11), label = "End Content Visibility", - ) { - endContent?.invoke() + ) { isVisible -> + if (isVisible) { + endContent?.invoke() + } } } } @@ -151,7 +185,6 @@ private fun TangemTopBarTitle(title: TextReference?, @DrawableRes titleIconRes: val wrappedTitle = remember(this) { requireNotNull(title) } Row( - modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy( space = TangemTheme.dimens2.x1, alignment = Alignment.CenterHorizontally, @@ -184,33 +217,6 @@ private fun TangemTopBarTitle(title: TextReference?, @DrawableRes titleIconRes: } } -@Composable -fun TangemTopBarActionContent( - actionUM: TangemTopBarActionUM, - modifier: Modifier = Modifier, - iconSize: Dp = TangemTheme.dimens2.x8, -) { - val background = lerp( - start = Color.Transparent, - stop = TangemTheme.colors2.button.backgroundSecondary, - fraction = actionUM.ghostModeProgress, - ) - val padding = (TangemTheme.dimens2.x10 - iconSize) / 2 - Icon( - imageVector = ImageVector.vectorResource(id = actionUM.iconRes), - contentDescription = null, - tint = TangemTheme.colors2.graphic.neutral.primary, - modifier = modifier - .size(TangemTheme.dimens2.x10) - .clip(CircleShape) - .conditional(actionUM.isActionable) { background(background) } - .conditionalCompose(actionUM.isActionable && actionUM.onClick != null) { - clickableSingle(onClick = requireNotNull(actionUM.onClick)) - } - .padding(padding), - ) -} - // region Preview @Composable @Preview(showBackground = true, widthDp = 375) @@ -221,6 +227,7 @@ private fun TangemTopBar_Preview(@PreviewParameter(PreviewProvider::class) param title = params.title, subtitle = params.subtitle, titleIconRes = params.titleIconRes, + type = TangemTopBarType.BottomSheet, modifier = Modifier.background(TangemTheme.colors2.surface.level1), startContent = params.startActionUM?.let { { TangemTopBarActionContent(it) } }, endContent = if (params.endActions.isNotEmpty()) { @@ -256,11 +263,13 @@ private class PreviewProvider : PreviewParameterProvider TangemTheme.dimens2.x16 + BottomSheet -> TangemTheme.dimens2.x19 + } + } + + @ReadOnlyComposable + @Composable + fun getSideContentSize(): Dp { + return when (this) { + Default -> TangemTheme.dimens2.x8 + BottomSheet -> TangemTheme.dimens2.x7 + } + } + + @ReadOnlyComposable + @Composable + fun getPadding(): PaddingValues { + return when (this) { + Default -> PaddingValues(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x3) + BottomSheet -> PaddingValues(TangemTheme.dimens2.x4) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt index a410eeb471..51aacf9640 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt @@ -3,6 +3,8 @@ package com.tangem.core.ui.res import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -31,15 +33,27 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) { LocalTangemTypography2 provides TangemTypography2(InterFamily), LocalRootBackgroundColor provides remember(rootBackgroundColor) { mutableStateOf(rootBackgroundColor) }, ) { - ProvideHaze { - Box(Modifier.hazeSourceTangem(zIndex = 1f)) { - content() + CompositionLocalProvider( + LocalTextSelectionColors provides TangemTextSelectionColors2, + ) { + ProvideHaze { + Box(Modifier.hazeSourceTangem(zIndex = 1f)) { + content() + } } } } } } +private val TangemTextSelectionColors2: TextSelectionColors + @Composable + @ReadOnlyComposable + get() = TextSelectionColors( + handleColor = TangemTheme.colors2.text.neutral.primary, + backgroundColor = TangemTheme.colors2.text.neutral.primary.copy(alpha = 0.3f), + ) + @Composable @ReadOnlyComposable private fun lightThemeColors2(): TangemColors2 { @@ -48,7 +62,7 @@ private fun lightThemeColors2(): TangemColors2 { primary = TangemColorPalette.Dark6, primaryInverted = TangemColorPalette.White, secondary = TangemColorPalette.Dark2, - tertiary = TangemColorPalette.Dark3, + tertiary = TangemColorPalette.Dark1, primaryInvertedConstant = TangemColorPalette.White, ), status = TangemColors2.Text.Status( @@ -67,7 +81,7 @@ private fun lightThemeColors2(): TangemColors2 { tertiary = TangemColorPalette.Dark3, quaternary = TangemColorPalette.Light4, primaryInvertedConstant = TangemColorPalette.White, - tertiaryConstant = TangemColorPalette.Dark3, + tertiaryConstant = TangemColorPalette.Dark1, ), status = TangemColors2.Graphic.Status( accent = TangemColorPalette.Azure, @@ -243,7 +257,7 @@ private fun darkThemeColors2(): TangemColors2 { secondary = TangemColorPalette.Light5, tertiary = TangemColorPalette.Dark3, quaternary = TangemColorPalette.Dark3, - tertiaryConstant = TangemColorPalette.Dark3, + tertiaryConstant = TangemColorPalette.Dark1, primaryInvertedConstant = TangemColorPalette.White, ), status = TangemColors2.Graphic.Status( diff --git a/core/ui/src/main/res/drawable/ic_search_24.xml b/core/ui/src/main/res/drawable/ic_search_24.xml index 0e1e91bcd9..2fa4918529 100644 --- a/core/ui/src/main/res/drawable/ic_search_24.xml +++ b/core/ui/src/main/res/drawable/ic_search_24.xml @@ -1,10 +1,9 @@ - + diff --git a/core/ui/src/main/res/drawable/ic_search_default_24.xml b/core/ui/src/main/res/drawable/ic_search_default_24.xml new file mode 100644 index 0000000000..510a7cb21a --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_search_default_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt index 66cb344ef0..1efc678bcb 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt @@ -1,6 +1,7 @@ package com.tangem.feature.tester.presentation.storybook.entity import com.tangem.core.ui.ds.badge.TangemBadgeColor +import com.tangem.core.ui.ds.field.search.TangemFieldShape import com.tangem.core.ui.ds.message.TangemMessageEffect internal sealed interface StoryBookPage @@ -53,4 +54,9 @@ internal data class TangemContextMenuStory( internal data class TangemHeaderRowStory( val isBalanceHidden: Boolean, val onBalanceHiddenToggle: () -> Unit, +) : StoryBookPage + +internal data class TangemSearchFieldStory( + val selectedShape: TangemFieldShape, + val onShapeChange: (TangemFieldShape) -> Unit, ) : StoryBookPage \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/searchfield/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/searchfield/Build.kt new file mode 100644 index 0000000000..591019540e --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/searchfield/Build.kt @@ -0,0 +1,18 @@ +package com.tangem.feature.tester.presentation.storybook.page.searchfield + +import com.tangem.core.ui.ds.field.search.TangemFieldShape +import com.tangem.feature.tester.presentation.storybook.entity.TangemSearchFieldStory +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemSearchFieldStory { + return TangemSearchFieldStory( + selectedShape = TangemFieldShape.RoundedCorners, + onShapeChange = { shape -> + updateStory { it.copy(selectedShape = shape) } + }, + ) +} + +internal val tangemSearchFieldStoryFactory + get() = storyPageFactory(StateUpdater::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/searchfield/TangemSearchFieldStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/searchfield/TangemSearchFieldStory.kt new file mode 100644 index 0000000000..bf3fa6b999 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/searchfield/TangemSearchFieldStory.kt @@ -0,0 +1,151 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.searchfield + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.ds.field.search.TangemFieldShape +import com.tangem.core.ui.ds.field.search.TangemSearchField +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemSearchFieldStory + +@Composable +internal fun TangemSearchFieldStory(state: TangemSearchFieldStory, modifier: Modifier = Modifier) { + var query by remember { mutableStateOf("") } + var isActive by remember { mutableStateOf(false) } + + LazyColumn( + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .fillMaxSize() + .statusBarsPadding() + .background(TangemTheme.colors2.surface.level1), + ) { + stickyHeader("shape_toggle") { + ShapeToggle( + selected = state.selectedShape, + onSelect = state.onShapeChange, + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level1) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + + item("empty") { + VariantSection(label = "Empty") { + TangemSearchField( + state = SearchBarUM( + placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), + query = query, + onQueryChange = { query = it }, + isActive = isActive, + onActiveChange = { isActive = it }, + onClearClick = { query = "" }, + ), + shape = state.selectedShape, + ) + } + } + + item("prefilled") { + VariantSection(label = "Pre-filled") { + var prefilledQuery by remember { mutableStateOf("Bitcoin") } + var isPrefilledActive by remember { mutableStateOf(false) } + TangemSearchField( + state = SearchBarUM( + placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), + query = prefilledQuery, + onQueryChange = { prefilledQuery = it }, + isActive = isPrefilledActive, + onActiveChange = { isPrefilledActive = it }, + onClearClick = { prefilledQuery = "" }, + ), + shape = state.selectedShape, + ) + } + } + } +} + +@Composable +private fun ShapeToggle( + selected: TangemFieldShape, + onSelect: (TangemFieldShape) -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(50) + Row( + modifier = modifier + .clip(shape) + .background(TangemTheme.colors2.surface.level2) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.secondary, + shape = shape, + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + TangemFieldShape.entries.forEach { fieldShape -> + ShapeChip( + label = fieldShape.name, + selected = fieldShape == selected, + onClick = { onSelect(fieldShape) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun ShapeChip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val chipShape = RoundedCornerShape(50) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(chipShape) + .background( + if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2, + ) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.caption2, + color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun VariantSection(label: String, content: @Composable ColumnScope.() -> Unit) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + content() + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt index 6fc4111d73..b413ec4bce 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt @@ -23,6 +23,7 @@ import com.tangem.feature.tester.presentation.storybook.page.contextmenu.tangemC import com.tangem.feature.tester.presentation.storybook.page.headerrow.tangemHeaderRowStoryFactory import com.tangem.feature.tester.presentation.storybook.page.message.tangemMessageStoryFactory import com.tangem.feature.tester.presentation.storybook.page.opportunities.opportunitiesBGStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.searchfield.tangemSearchFieldStoryFactory import com.tangem.feature.tester.presentation.storybook.page.tabs.tangemSegmentedPickerStoryFactory import com.tangem.feature.tester.presentation.storybook.page.tokenrow.tangemTokenRowStoryFactory @@ -39,6 +40,7 @@ private fun buildStories() = listOf( StoryItem(title = "🪙 Token Row", factory = tangemTokenRowStoryFactory), StoryItem(title = "📑 Header Row", factory = tangemHeaderRowStoryFactory), StoryItem(title = "📋 Context Menu", factory = tangemContextMenuStoryFactory), + StoryItem(title = "🔍 Search Field", factory = tangemSearchFieldStoryFactory), ) @Composable diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt index f3e58f0000..14e0c65291 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt @@ -14,6 +14,7 @@ import com.tangem.feature.tester.presentation.storybook.entity.TangemCheckboxSto import com.tangem.feature.tester.presentation.storybook.entity.TangemHeaderRowStory import com.tangem.feature.tester.presentation.storybook.entity.TangemContextMenuStory import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemSearchFieldStory import com.tangem.feature.tester.presentation.storybook.entity.TangemSegmentedPickerStory import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowStory import com.tangem.feature.tester.presentation.storybook.page.background.NorthernLightsStory @@ -26,6 +27,7 @@ import com.tangem.feature.tester.presentation.storybook.page.tabs.TangemSegmente import com.tangem.feature.tester.presentation.storybook.page.tokenrow.TangemTokenRowStory import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory import com.tangem.feature.tester.presentation.storybook.page.contextmenu.TangemContextMenuStory +import com.tangem.feature.tester.presentation.storybook.page.searchfield.TangemSearchFieldStory @Composable internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) { @@ -48,6 +50,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) is TangemTokenRowStory -> TangemTokenRowStory(state = storyState) is TangemHeaderRowStory -> TangemHeaderRowStory(state = storyState) is TangemContextMenuStory -> TangemContextMenuStory(state = storyState) + is TangemSearchFieldStory -> TangemSearchFieldStory(state = storyState) } } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt index 120fd7a0b8..71cc0d0a57 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt @@ -38,6 +38,7 @@ import com.tangem.core.ui.ds.row.token.TangemTokenRow import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds.topbar.TangemTopBarActionContent import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM +import com.tangem.core.ui.ds.topbar.TangemTopBarType import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.reordarable.ReorderableItem import com.tangem.core.ui.res.TangemTheme @@ -77,6 +78,7 @@ internal fun OrganizeTokensContent( title = { TangemTopBar( title = resourceReference(R.string.organize_tokens_title), + type = TangemTopBarType.BottomSheet, endContent = { TangemTopBarActionContent( actionUM = TangemTopBarActionUM( @@ -85,7 +87,7 @@ internal fun OrganizeTokensContent( onClick = { isShowDropdownMenu = true }, ghostModeProgress = 0f, ), - iconSize = TangemTheme.dimens2.x7, + type = TangemTopBarType.BottomSheet, ) OrganizeDropDownMenu( organizeMenuUM = organizeTokensUM.organizeMenuUM, From c061cc88f105f4759d9a9cdd8e9916892a7181bf Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 18 Mar 2026 13:32:52 +0400 Subject: [PATCH 59/60] Updated on 2026-08-14 --- .../ApplicationInjectionExecutionRule.kt | 31 +-- core/config-toggles/build.gradle.kts | 120 +++------ .../configurations/TogglesGenerator.kt | 100 +++++++ .../configurations/TogglesGeneratorTest.kt | 253 ++++++++++++++++++ 4 files changed, 404 insertions(+), 100 deletions(-) create mode 100644 plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/TogglesGenerator.kt create mode 100644 plugins/configuration/src/test/kotlin/com/tangem/plugin/configuration/configurations/TogglesGeneratorTest.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/ApplicationInjectionExecutionRule.kt b/app/src/androidTest/kotlin/com/tangem/common/ApplicationInjectionExecutionRule.kt index 2445305b72..e41aee1ef2 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/ApplicationInjectionExecutionRule.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/ApplicationInjectionExecutionRule.kt @@ -1,6 +1,7 @@ package com.tangem.common import androidx.test.core.app.ApplicationProvider +import com.tangem.core.configtoggle.FeatureToggles import com.tangem.tap.ApplicationEntryPoint import com.tangem.tap.TangemApplication import dagger.hilt.android.testing.OnComponentReadyRunner @@ -10,7 +11,7 @@ import org.junit.runners.model.Statement import timber.log.Timber class ApplicationInjectionExecutionRule( - private val toggleStates: Map + private val toggleStates: Map, ) : TestRule { private val tangemApplication: TangemApplication @@ -25,7 +26,7 @@ class ApplicationInjectionExecutionRule( overrideFeatureToggles() OnComponentReadyRunner.addListener( - tangemApplication, ApplicationEntryPoint::class.java + tangemApplication, ApplicationEntryPoint::class.java, ) { _: ApplicationEntryPoint -> tangemApplication.preInit() tangemApplication.init() @@ -43,25 +44,15 @@ class ApplicationInjectionExecutionRule( @Suppress("UNCHECKED_CAST") private fun saveOriginalFeatureToggles() { try { - val featureTogglesClass = Class.forName("com.tangem.core.configtoggle.FeatureToggles") - val valuesField = featureTogglesClass.getDeclaredField("values") - valuesField.isAccessible = true - originalFeatureTogglesValues = valuesField.get(null) as Map + originalFeatureTogglesValues = FeatureToggles.values as Map } catch (e: Exception) { Timber.e("Failed to save original toggles values: ${e.message}") } } - @Suppress("UNCHECKED_CAST") private fun overrideFeatureToggles() { try { - val featureTogglesClass = Class.forName("com.tangem.core.configtoggle.FeatureToggles") - val valuesField = featureTogglesClass.getDeclaredField("values") - valuesField.isAccessible = true - - val originalValues = originalFeatureTogglesValues ?: - (valuesField.get(null) as Map) - + val originalValues = originalFeatureTogglesValues ?: FeatureToggles.values val newValues = originalValues.toMutableMap() toggleStates.forEach { (toggle, enabled) -> @@ -72,10 +63,12 @@ class ApplicationInjectionExecutionRule( } } - valuesField.set(null, newValues) + val companionClass = FeatureToggles.Companion::class.java + val valuesField = companionClass.getDeclaredField("values") + valuesField.isAccessible = true + valuesField.set(FeatureToggles.Companion, newValues) Timber.i("FeatureToggles.values updated: $toggleStates") - } catch (e: Exception) { Timber.e("FeatureToggles.values didn't change with error: ${e.message}") } @@ -84,10 +77,10 @@ class ApplicationInjectionExecutionRule( private fun restoreOriginalFeatureToggles() { try { if (originalFeatureTogglesValues != null) { - val featureTogglesClass = Class.forName("com.tangem.core.configtoggle.FeatureToggles") - val valuesField = featureTogglesClass.getDeclaredField("values") + val companionClass = FeatureToggles.Companion::class.java + val valuesField = companionClass.getDeclaredField("values") valuesField.isAccessible = true - valuesField.set(null, originalFeatureTogglesValues) + valuesField.set(FeatureToggles.Companion, originalFeatureTogglesValues) Timber.i("FeatureToggles.values restored") } } catch (e: Exception) { diff --git a/core/config-toggles/build.gradle.kts b/core/config-toggles/build.gradle.kts index 490d2c6fa8..4b01f4402d 100644 --- a/core/config-toggles/build.gradle.kts +++ b/core/config-toggles/build.gradle.kts @@ -1,6 +1,4 @@ -import com.squareup.kotlinpoet.* -import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy -import org.json.JSONArray +import com.tangem.plugin.configuration.configurations.TogglesGenerator plugins { alias(deps.plugins.android.library) @@ -11,10 +9,31 @@ plugins { id("configuration") } -buildscript { - dependencies { - classpath("com.squareup:kotlinpoet:1.15.0") - classpath("org.json:json:20231013") +abstract class GenerateTogglesTask : DefaultTask() { + + @get:InputFiles + abstract val configFiles: ConfigurableFileCollection + + @get:Input + abstract val fileNames: ListProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val files = configFiles.files.toList() + val names = fileNames.get() + + require(files.size == names.size) { + "configFiles (${files.size}) and fileNames (${names.size}) must have the same size" + } + + files.zip(names).forEach { (inputFile, fileName) -> + require(inputFile.exists()) { "Config file not found: ${inputFile.absolutePath}" } + logger.lifecycle("Generating toggles from ${inputFile.name}") + TogglesGenerator.generate(inputFile, outputDir.get().asFile, fileName) + } } } @@ -23,8 +42,20 @@ android { sourceSets["main"].java.srcDir("build/generated/source/toggles") } +/** Config file to generated enum class name mapping */ +val toggles = mapOf( + file("src/main/assets/configs/feature_toggles_config.json") to "FeatureToggles", + file("src/main/assets/configs/excluded_blockchains_config.json") to "ExcludedBlockchainToggles", +) + +val generateToggles = tasks.register("generateToggles") { + configFiles.from(toggles.keys) + fileNames.set(toggles.values.toList()) + outputDir.set(layout.buildDirectory.dir("generated/source/toggles")) +} + tasks.named("preBuild") { - dependsOn(generateFeatureToggles, generateExcludedBlockchainToggles) + dependsOn(generateToggles) } tasks.withType().configureEach { @@ -51,77 +82,4 @@ dependencies { testImplementation(projects.test.core) testRuntimeOnly(deps.test.junit5.engine) -} - -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.forEach { entry -> - add(entry) - 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) - - // Remove redundant public visibility modifiers - val generatedFile = File(outputPackageDir, "com/tangem/core/configtoggle/$generatedFileName.kt") - if (generatedFile.exists()) { - val content = generatedFile.readText() - val fixedContent = content - .replace("public object ", "object ") - .replace("public val ", "val ") - generatedFile.writeText(fixedContent) - } - } } \ No newline at end of file diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/TogglesGenerator.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/TogglesGenerator.kt new file mode 100644 index 0000000000..be4edcd2f0 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/TogglesGenerator.kt @@ -0,0 +1,100 @@ +package com.tangem.plugin.configuration.configurations + +import com.squareup.kotlinpoet.* +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.util.Locale + +/** + * Generator for toggle configuration Kotlin enum classes from JSON files. + * Parses a JSON array of `{ "name": "...", "version": "..." }` entries + * and generates an enum class with an entry for each toggle and a `values: Map` + * in companion object mapping toggle name to its version string. + * +[REDACTED_AUTHOR] + */ +object TogglesGenerator { + + private const val PACKAGE_NAME = "com.tangem.core.configtoggle" + + /** + * Generates a Kotlin enum class from a JSON toggles config file. + * + * @param inputFile JSON configuration file (array of objects with "name" and "version" fields) + * @param outputDir output directory for the generated Kotlin file + * @param fileName name of the generated Kotlin enum class (e.g. "FeatureToggles") + */ + fun generate(inputFile: File, outputDir: File, fileName: String) { + val jsonText = inputFile.readText() + val jsonArray = Json.parseToJsonElement(jsonText).jsonArray + + val entries = jsonArray.map { element -> + val obj = element.jsonObject + val name = obj.getValue("name").jsonPrimitive.content + val version = obj.getValue("version").jsonPrimitive.content + ToggleEntry(name = name, enumName = name.toEnumEntryName(), version = version) + } + + val mapInitializer = buildCodeBlock { + addStatement("mapOf(") + withIndent { + entries.forEach { entry -> + addStatement("%S to %S,", entry.name, entry.version) + } + } + add(")") + } + + val companionBuilder = TypeSpec.companionObjectBuilder() + .addProperty( + PropertySpec.builder("values", MAP.parameterizedBy(STRING, STRING)) + .initializer(mapInitializer) + .build(), + ) + + val enumBuilder = TypeSpec.enumBuilder(fileName) + .addKdoc("Generated from ${inputFile.name}\nAuto-generated - do not edit manually.") + .apply { + entries.forEach { entry -> + addEnumConstant(entry.enumName) + } + } + .addType(companionBuilder.build()) + + val fileSpec = FileSpec.builder(packageName = PACKAGE_NAME, fileName = fileName) + .indent(" ") + .addType(enumBuilder.build()) + .build() + + outputDir.mkdirs() + fileSpec.writeTo(outputDir) + + // Remove redundant public visibility modifiers + val generatedFile = File(outputDir, "${PACKAGE_NAME.replace('.', '/')}/$fileName.kt") + if (generatedFile.exists()) { + val content = generatedFile.readText() + val fixedContent = content + .replace("public enum class ", "enum class ") + .replace("public companion object", "companion object") + .replace("public val ", "val ") + generatedFile.writeText(fixedContent) + } + } + + /** + * Converts a toggle name to a valid Kotlin enum entry name. + * - Replaces `/`, `-`, `.`, spaces with `_` + * - Converts to UPPER_CASE + * - Empty string becomes `EMPTY` + */ + private fun String.toEnumEntryName(): String { + if (isBlank()) return "EMPTY" + return replace(Regex("[/\\-. ]"), "_").uppercase(Locale.ROOT) + } + + private data class ToggleEntry(val name: String, val enumName: String, val version: String) +} \ No newline at end of file diff --git a/plugins/configuration/src/test/kotlin/com/tangem/plugin/configuration/configurations/TogglesGeneratorTest.kt b/plugins/configuration/src/test/kotlin/com/tangem/plugin/configuration/configurations/TogglesGeneratorTest.kt new file mode 100644 index 0000000000..27d25144db --- /dev/null +++ b/plugins/configuration/src/test/kotlin/com/tangem/plugin/configuration/configurations/TogglesGeneratorTest.kt @@ -0,0 +1,253 @@ +package com.tangem.plugin.configuration.configurations + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * Tests for [TogglesGenerator]. + */ +class TogglesGeneratorTest { + + @TempDir + lateinit var tempDir: File + + private lateinit var outputDir: File + + @BeforeEach + fun setup() { + outputDir = File(tempDir, "output") + } + + @Test + fun `generate creates enum class with correct package`() { + // Arrange & Act + val code = generateAndReadOutput( + json = SINGLE_ENTRY_JSON, + fileName = "FeatureToggles", + ) + + // Assert + assertThat(code).contains("package com.tangem.core.configtoggle") + } + + @Test + fun `generate creates enum class with given name`() { + // Arrange & Act + val code = generateAndReadOutput( + json = SINGLE_ENTRY_JSON, + fileName = "FeatureToggles", + ) + + // Assert + assertThat(code).contains("enum class FeatureToggles {") + } + + @Test + fun `generate creates enum entries and companion object`() { + // Arrange & Act + val code = generateAndReadOutput( + json = SINGLE_ENTRY_JSON, + fileName = "FeatureToggles", + ) + + // Assert + assertThat(code).contains("STAKING_ETH_ENABLED,") + assertThat(code).contains("companion object {") + } + + @Test + fun `generate creates values map with string literal keys`() { + // Arrange & Act + val code = generateAndReadOutput( + json = SINGLE_ENTRY_JSON, + fileName = "FeatureToggles", + ) + + // Assert + assertThat(code).contains("val values: Map = mapOf(") + assertThat(code).contains(""""STAKING_ETH_ENABLED" to "undefined"""") + } + + @Test + fun `generate creates enum with multiple entries`() { + // Arrange + val json = """ + [ + { "name": "FEATURE_A", "version": "1.0" }, + { "name": "FEATURE_B", "version": "2.0" }, + { "name": "FEATURE_C", "version": "undefined" } + ] + """.trimIndent() + + // Act + val code = generateAndReadOutput(json, "TestToggles") + + // Assert + assertThat(code).contains("FEATURE_A,") + assertThat(code).contains("FEATURE_B,") + assertThat(code).contains("FEATURE_C,") + assertThat(code).contains(""""FEATURE_A" to "1.0"""") + assertThat(code).contains(""""FEATURE_B" to "2.0"""") + assertThat(code).contains(""""FEATURE_C" to "undefined"""") + } + + @Test + fun `generate handles empty array`() { + // Arrange & Act + val code = generateAndReadOutput( + json = "[]", + fileName = "EmptyToggles", + ) + + // Assert + assertThat(code).contains("enum class EmptyToggles {") + assertThat(code).contains("val values: Map = mapOf(") + } + + @Test + fun `generate converts slash in name to underscore in enum`() { + // Arrange + val json = """[{ "name": "NEXA/test", "version": "undefined" }]""" + + // Act + val code = generateAndReadOutput(json, "Toggles") + + // Assert + assertThat(code).contains("NEXA_TEST,") + assertThat(code).contains(""""NEXA/test" to "undefined"""") + } + + @Test + fun `generate converts dash in name to underscore in enum`() { + // Arrange + val json = """[{ "name": "vanar-chain", "version": "undefined" }]""" + + // Act + val code = generateAndReadOutput(json, "Toggles") + + // Assert + assertThat(code).contains("VANAR_CHAIN,") + assertThat(code).contains(""""vanar-chain" to "undefined"""") + } + + @Test + fun `generate uppercases lowercase names in enum`() { + // Arrange + val json = """[{ "name": "sonic", "version": "5.21.0" }]""" + + // Act + val code = generateAndReadOutput(json, "Toggles") + + // Assert + assertThat(code).contains("SONIC,") + assertThat(code).contains(""""sonic" to "5.21.0"""") + } + + @Test + fun `generate adds kdoc with source file reference`() { + // Arrange & Act + val code = generateAndReadOutput( + json = SINGLE_ENTRY_JSON, + fileName = "FeatureToggles", + ) + + // Assert + assertThat(code).contains("Generated from") + assertThat(code).contains("Auto-generated - do not edit manually") + } + + @Test + fun `generate removes public modifiers`() { + // Arrange & Act + val code = generateAndReadOutput( + json = SINGLE_ENTRY_JSON, + fileName = "FeatureToggles", + ) + + // Assert + assertThat(code).doesNotContain("public enum class") + assertThat(code).doesNotContain("public companion object") + assertThat(code).doesNotContain("public val") + } + + @Test + fun `generate handles real feature toggles config`() { + // Arrange + val json = """ + [ + { "name": "NEW_CARD_SCANNING_ENABLED", "version": "undefined" }, + { "name": "HOT_WALLET_CREATION_RESTRICTION_ENABLED", "version": "5.32.0" }, + { "name": "SWAP_MARKET_LIST_ENABLED", "version": "5.34" } + ] + """.trimIndent() + + // Act + val code = generateAndReadOutput(json, "FeatureToggles") + + // Assert + assertThat(code).contains("NEW_CARD_SCANNING_ENABLED,") + assertThat(code).contains("HOT_WALLET_CREATION_RESTRICTION_ENABLED,") + assertThat(code).contains("SWAP_MARKET_LIST_ENABLED,") + assertThat(code).contains(""""NEW_CARD_SCANNING_ENABLED" to "undefined"""") + assertThat(code).contains(""""HOT_WALLET_CREATION_RESTRICTION_ENABLED" to "5.32.0"""") + assertThat(code).contains(""""SWAP_MARKET_LIST_ENABLED" to "5.34"""") + } + + @Test + fun `generate handles real excluded blockchains config`() { + // Arrange + val json = """ + [ + { "name": "NEXA", "version": "undefined" }, + { "name": "NEXA/test", "version": "undefined" }, + { "name": "sonic", "version": "5.21.0" } + ] + """.trimIndent() + + // Act + val code = generateAndReadOutput(json, "ExcludedBlockchainToggles") + + // Assert + assertThat(code).contains("enum class ExcludedBlockchainToggles {") + assertThat(code).contains("NEXA,") + assertThat(code).contains("NEXA_TEST,") + assertThat(code).contains("SONIC,") + assertThat(code).contains(""""NEXA" to "undefined"""") + assertThat(code).contains(""""NEXA/test" to "undefined"""") + assertThat(code).contains(""""sonic" to "5.21.0"""") + } + + @Test + fun `generate produces different objects for different file names`() { + // Arrange + val json = SINGLE_ENTRY_JSON + + // Act + val code1 = generateAndReadOutput(json, "FeatureToggles") + val code2 = generateAndReadOutput(json, "ExcludedBlockchainToggles") + + // Assert + assertThat(code1).contains("enum class FeatureToggles {") + assertThat(code2).contains("enum class ExcludedBlockchainToggles {") + } + + private fun generateAndReadOutput(json: String, fileName: String): String { + val inputFile = File(tempDir, "config.json").apply { + writeText(json) + } + + TogglesGenerator.generate(inputFile, outputDir, fileName) + + val generatedFile = File(outputDir, "com/tangem/core/configtoggle/$fileName.kt") + + assertThat(generatedFile.exists()).isTrue() + return generatedFile.readText() + } + + private companion object { + const val SINGLE_ENTRY_JSON = """[{ "name": "STAKING_ETH_ENABLED", "version": "undefined" }]""" + } +} \ No newline at end of file From ed2c3c307834c25002c1bff0b9bc7279f75d2e5e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 18 Mar 2026 13:48:57 +0300 Subject: [PATCH 60/60] Updated on 2026-08-14 --- .../parser/Eip681PaymentUriParser.kt | 36 ++++++++++++------ .../parser/QrContentClassifierParser.kt | 5 +-- .../qrscanning/Eip681PaymentUriParserTest.kt | 18 ++++++++- .../usecases/ResolveQrSendTargetsUseCase.kt | 26 ++++++------- .../wallet/child/wallet/WalletComponent.kt | 38 +++++++++++++++++++ .../wallet/child/wallet/model/WalletModel.kt | 2 +- .../router/DefaultWalletRouter.kt | 25 ++++++++++++ .../presentation/router/InnerWalletRouter.kt | 4 ++ .../wallet/state/model/WalletDialogConfig.kt | 28 ++++++++++++++ 9 files changed, 153 insertions(+), 29 deletions(-) diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt index 3bc83d2a7c..3853849fcd 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt @@ -14,18 +14,22 @@ internal class Eip681PaymentUriParser( coins: List, allCurrencies: List, ): PaymentUriParser.ParseResult { - if (!qrCode.startsWith(SCHEME)) return PaymentUriParser.ParseResult.NotRecognized + if (!qrCode.startsWith(SCHEME)) { + return PaymentUriParser.ParseResult.NotRecognized + } val withoutScheme = qrCode.removePrefix(SCHEME) val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized val matchingCoins = findMatchingCoins(parsed.chainId, coins) - if (matchingCoins.isEmpty()) return PaymentUriParser.ParseResult.RecognizedButNoMatch + if (matchingCoins.isEmpty()) { + return PaymentUriParser.ParseResult.RecognizedButNoMatch + } val result = if (parsed.functionName == FUNCTION_TRANSFER) { resolveErc20Transfer(parsed, matchingCoins, allCurrencies) } else { - resolveNativeTransfer(parsed, matchingCoins) + resolveNativeTransfer(parsed, matchingCoins, allCurrencies) } return if (result != null) { PaymentUriParser.ParseResult.Success(result) @@ -37,6 +41,7 @@ internal class Eip681PaymentUriParser( private fun resolveNativeTransfer( parsed: Eip681Result, matchingCoins: List, + allCurrencies: List, ): ClassifiedQrContent.PaymentUri? { val valueWei = parsed.params[PARAM_VALUE]?.toBigDecimalOrNull() @@ -45,11 +50,20 @@ internal class Eip681PaymentUriParser( val decimals = matchingCoins.first().decimals val amount = valueWei?.fromSmallestUnit(decimals) + val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() + // If value is specified, this is a native coin transfer — return only coins + // If no value, it's just an address with scheme — return all currencies on the network + val matchingCurrencies = if (valueWei != null) { + matchingCoins + } else { + allCurrencies.filter { it.network.id in matchingNetworkIds } + } + return ClassifiedQrContent.PaymentUri( address = parsed.targetAddress, amount = amount, memo = null, - matchingCurrencies = matchingCoins, + matchingCurrencies = matchingCurrencies, ) } @@ -63,22 +77,22 @@ internal class Eip681PaymentUriParser( val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() - val tokens = allCurrencies.filterIsInstance() - - val token = tokens - .firstOrNull { token -> + val matchingTokens = allCurrencies.filterIsInstance() + .filter { token -> token.network.id in matchingNetworkIds && token.contractAddress.equals(contractAddress, ignoreCase = true) - } ?: return null + } + + if (matchingTokens.isEmpty()) return null val rawAmount = parsed.params[PARAM_UINT256]?.toBigDecimalOrNull() - val amount = rawAmount?.fromSmallestUnit(token.decimals) + val amount = rawAmount?.fromSmallestUnit(matchingTokens.first().decimals) return ClassifiedQrContent.PaymentUri( address = recipient, amount = amount, memo = null, - matchingCurrencies = listOf(token), + matchingCurrencies = matchingTokens, ) } diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt index 161619037d..6d970e3ec4 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt @@ -29,10 +29,9 @@ internal class QrContentClassifierParser( is PaymentUriParser.ParseResult.NotRecognized -> Unit } - val matchingNetworkIds = uniqueCoins + val matchingCoins = uniqueCoins .filter { coin -> blockchainDataProvider.validateAddress(coin.network, qrCode) } - .map { it.network.id } - .toSet() + val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() if (matchingNetworkIds.isNotEmpty()) { val matchingCurrencies = userCurrencies.filter { it.network.id in matchingNetworkIds } diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt index b270ef58c1..f78e4d22da 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt @@ -70,7 +70,7 @@ internal class Eip681PaymentUriParserTest { } @Test - fun `native transfer includes only coins, not tokens`() { + fun `native transfer with value returns only coins`() { every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") @@ -85,6 +85,22 @@ internal class Eip681PaymentUriParserTest { assertThat(result!!.matchingCurrencies).containsExactly(ethereumCoin) } + @Test + fun `native transfer without value returns all currencies on matching network`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + val result = parser.parse( + qrCode = "ethereum:0xRecipient@1", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin, usdcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.matchingCurrencies).containsExactly(ethereumCoin, usdcToken) + } + // endregion // region ERC-20 transfer diff --git a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt index 2e5cea6b3a..bfa7169dac 100644 --- a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt +++ b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt @@ -27,19 +27,19 @@ class ResolveQrSendTargetsUseCase( val currencyLocations = mutableMapOf>() val totalPerAccount = mutableMapOf() - for (accountList in allAccountLists) { - for (account in accountList.accounts.filterIsInstance()) { - val location = CurrencyLocation( - walletName = walletNamesMap[account.accountId.userWalletId] - ?: account.accountId.userWalletId.stringValue, - accountId = account.accountId, - accountName = account.accountName, - ) - totalPerAccount[account.accountId] = account.cryptoCurrencies.size - for (currency in account.cryptoCurrencies) { - allCurrencies.add(currency) - currencyLocations.getOrPut(currency.id) { mutableListOf() }.add(location) - } + val allAccounts = allAccountLists.flatMap { it.accounts }.filterIsInstance() + + for (account in allAccounts) { + val location = CurrencyLocation( + walletName = walletNamesMap[account.accountId.userWalletId] + ?: account.accountId.userWalletId.stringValue, + accountId = account.accountId, + accountName = account.accountName, + ) + totalPerAccount[account.accountId] = account.cryptoCurrencies.size + for (currency in account.cryptoCurrencies) { + allCurrencies.add(currency) + currencyLocations.getOrPut(currency.id) { mutableListOf() }.add(location) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index 840e428d26..8134a10127 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -30,10 +30,12 @@ import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen2 import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedComponent import com.tangem.feature.walletsettings.component.RenameWalletComponent +import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.feed.entry.components.FeedEntryComponent import com.tangem.features.pushnotifications.api.PushNotificationsBottomSheetComponent import com.tangem.features.pushnotifications.api.PushNotificationsParams +import com.tangem.features.send.v2.api.NetworkSelectionComponent import com.tangem.features.tokenreceive.TokenReceiveComponent import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent import dagger.assisted.Assisted @@ -52,6 +54,7 @@ internal class WalletComponent @AssistedInject constructor( private val pushNotificationsBottomSheetComponent: PushNotificationsBottomSheetComponent.Factory, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, private val yieldSupplyDepositedWarningComponent: YieldSupplyDepositedWarningComponent.Factory, + private val networkSelectionComponentFactory: NetworkSelectionComponent.Factory, private val designFeatureToggles: DesignFeatureToggles, ) : ComposableContentComponent, AppComponentContext by appComponentContext { @@ -156,6 +159,41 @@ internal class WalletComponent @AssistedInject constructor( ), ) } + is WalletDialogConfig.NetworkSelection -> { + networkSelectionComponentFactory.create( + context = childByContext(componentContext), + params = NetworkSelectionComponent.Params( + address = dialogConfig.address, + amount = dialogConfig.amount, + memo = dialogConfig.memo, + walletGroups = dialogConfig.walletGroups.map { walletGroup -> + NetworkSelectionComponent.Params.WalletGroup( + userWalletId = walletGroup.userWalletId, + walletName = walletGroup.walletName, + accounts = walletGroup.accounts.map { accountGroup -> + NetworkSelectionComponent.Params.AccountGroup( + accountId = accountGroup.accountId, + accountName = accountGroup.accountName, + currencies = accountGroup.currencies, + hiddenTokensCount = accountGroup.hiddenTokensCount, + ) + }, + ) + }, + onTokenSelected = { userWalletId, currency -> + model.innerWalletRouter.dialogNavigation.dismiss() + model.innerWalletRouter.openSend( + userWalletId = userWalletId, + currency = currency, + address = dialogConfig.address, + amount = dialogConfig.amount?.parseBigDecimal(currency.decimals), + tag = dialogConfig.memo, + ) + }, + onDismiss = model.innerWalletRouter.dialogNavigation::dismiss, + ), + ) + } } }, ) 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 dd76e89962..44c6eb55fc 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 @@ -776,7 +776,7 @@ internal class WalletModel @Inject constructor( ) } is QrSendTarget.Multiple -> { - // TODO: [REDACTED_TASK_KEY] Bottom sheet: Wallets (dropdown) → Accounts → Tokens + innerWalletRouter.openNetworkSelectionBottomSheet(target) } is QrSendTarget.Unknown -> { // TODO: [REDACTED_TASK_KEY] Error handling for unsupported and invalid QR codes diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index f123c9c3c1..b0da023377 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -13,6 +13,7 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.qrscanning.models.QrSendTarget import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayDetailsConfig @@ -197,6 +198,30 @@ internal class DefaultWalletRouter @Inject constructor( ) } + override fun openNetworkSelectionBottomSheet(target: QrSendTarget.Multiple) { + dialogNavigation.activate( + configuration = WalletDialogConfig.NetworkSelection( + address = target.address, + amount = target.amount, + memo = target.memo, + walletGroups = target.walletGroups.map { walletGroup -> + WalletDialogConfig.NetworkSelection.WalletGroupData( + userWalletId = walletGroup.userWalletId, + walletName = walletGroup.walletName, + accounts = walletGroup.accounts.map { accountGroup -> + WalletDialogConfig.NetworkSelection.AccountGroupData( + accountId = accountGroup.accountId, + accountName = accountGroup.accountName, + currencies = accountGroup.currencies, + hiddenTokensCount = accountGroup.hiddenTokensCount, + ) + }, + ) + }, + ), + ) + } + inner class OrganizeCallbacks : OrganizeTokensComponent.Callback { override fun onDismiss() { dialogNavigation.dismiss() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index d87153341b..52741298a7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -8,6 +8,7 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.qrscanning.models.QrSendTarget import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayDetailsConfig @@ -95,4 +96,7 @@ internal interface InnerWalletRouter { /** Open send screen with prefilled destination */ fun openSend(userWalletId: UserWalletId, currency: CryptoCurrency, address: String, amount: String?, tag: String?) + + /** Open network selection bottom sheet for multiple QR matches */ + fun openNetworkSelectionBottomSheet(target: QrSendTarget.Multiple) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt index 01e89e2b5e..a8844378c1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt @@ -1,11 +1,15 @@ package com.tangem.feature.wallet.presentation.wallet.state.model import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.serialization.BigDecimalSerializer import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.details.TokenAction import kotlinx.collections.immutable.ImmutableList import kotlinx.serialization.Serializable +import java.math.BigDecimal /** * Wallet dialog config. Used to show Decompose dialogs @@ -44,4 +48,28 @@ internal sealed interface WalletDialogConfig { @Serializable data class OrganizeTokens(val userWalletId: UserWalletId) : WalletDialogConfig + + @Serializable + data class NetworkSelection( + val address: String, + val amount: @Serializable(BigDecimalSerializer::class) BigDecimal?, + val memo: String?, + val walletGroups: List, + ) : WalletDialogConfig { + + @Serializable + data class WalletGroupData( + val userWalletId: UserWalletId, + val walletName: String, + val accounts: List, + ) + + @Serializable + data class AccountGroupData( + val accountId: AccountId, + val accountName: AccountName, + val currencies: List, + val hiddenTokensCount: Int = 0, + ) + } } \ No newline at end of file