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 f34e099e0e..fc81a2e8cb 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 @@ -301,6 +301,7 @@ internal class ChildFactory @Inject constructor( userWalletId = route.userWalletId, currency = route.currency, navigationAction = route.navigationAction, + shouldShowMarketBlock = route.shouldShowMarketBlock, ), componentFactory = tokenDetailsComponentFactory, ) 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 d0279e83a4..e65a45a9f9 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 @@ -65,6 +65,7 @@ sealed class AppRoute(val path: String) : Route { val userWalletId: UserWalletId, val currency: CryptoCurrency, val navigationAction: NavigationAction? = null, + val shouldShowMarketBlock: Boolean = true, ) : AppRoute(path = "/currency_details/${userWalletId.stringValue}/${currency.id.value}") @Serializable 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 index 985380f91c..ba70d244ec 100644 --- 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 @@ -3,6 +3,7 @@ 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.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -73,6 +74,7 @@ fun MarketListItemContentV2(model: MarketsListItemUM, modifier: Modifier = Modif tangemIconUM = TangemIconUM.Url(model.iconUrl, fallbackRes = R.drawable.ic_custom_token_44), modifier = Modifier .size(TangemTheme.dimens2.x10) + .clip(RoundedCornerShape(8.dp)) .layoutId(layoutId = TangemRowLayoutId.HEAD), ) diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActions.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActions.kt index 27d4bfac71..104176d4c6 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActions.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActions.kt @@ -1,9 +1,12 @@ package com.tangem.common.ui.markets.action import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf data class QuickActions( val actions: ImmutableList, val onQuickActionClick: (QuickActionUM) -> Unit, val onQuickActionLongClick: (QuickActionUM) -> Unit, + val disabledActions: ImmutableSet = persistentSetOf(), ) \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt index de1e9704d1..63add1f9fe 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt @@ -4,6 +4,7 @@ import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenActionsState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet object QuickActionsConverter { @@ -13,8 +14,9 @@ object QuickActionsConverter { isRedesignEnabled: Boolean, context: TokenActionsContext = TokenActionsContext.Markets, ): QuickActions { + val states = toQuickActionStates(cryptoData.actions, isRedesignEnabled, context) return QuickActions( - actions = toQuickActions(cryptoData.actions, isRedesignEnabled, context), + actions = states.map { it.action }.toImmutableList(), onQuickActionClick = { quickActionUM -> tokenActionsHandler.handle( action = quickActionUM.toHandledAction(), @@ -30,6 +32,7 @@ object QuickActionsConverter { ) } }, + disabledActions = states.filterNot { it.isEnabled }.map { it.action }.toImmutableSet(), ) } @@ -45,30 +48,52 @@ object QuickActionsConverter { } /** - * Returns available actions filtered to [context]'s allow-list and ordered by it. - * Omitting [context] (default [TokenActionsContext.Markets]) yields all available actions in source order; - * a context with a non-null [TokenActionsContext.allowedActionsInOrder] filters to and orders by that list. + * Returns actions filtered and ordered for [context]. + * Omitting [context] (default [TokenActionsContext.Markets]) yields only available actions in source order. + * A context with a non-null [TokenActionsContext.allowedActionsInOrder] returns that list's actions in order, + * including unavailable ones (they are meant to be shown disabled by the caller). */ fun toQuickActions( actions: List, isRedesignEnabled: Boolean, context: TokenActionsContext = TokenActionsContext.Markets, - ): ImmutableList { - val available = actions.filter { it.unavailabilityReason == ScenarioUnavailabilityReason.None } - val allowed = context.allowedActionsInOrder - ?: return available.mapNotNull { it.toQuickActionUM(isRedesignEnabled) }.toImmutableList() + ): ImmutableList = + toQuickActionStates(actions, isRedesignEnabled, context).map { it.action }.toImmutableList() - val byBsAction = available.associateBy { it.toBsAction() } - val hasExchange = byBsAction.containsKey(TokenActionsBSContentUM.Action.Exchange) + private fun toQuickActionStates( + actions: List, + isRedesignEnabled: Boolean, + context: TokenActionsContext, + ): List { + val allowed = context.allowedActionsInOrder + ?: return actions + .filter { it.unavailabilityReason == ScenarioUnavailabilityReason.None } + .mapNotNull { action -> + action.toQuickActionUM(isRedesignEnabled)?.let { QuickActionState(it, isEnabled = true) } + } + + val byBsAction = actions.associateBy { it.toBsAction() } + val isExchangeAvailable = byBsAction[TokenActionsBSContentUM.Action.Exchange] + ?.unavailabilityReason == ScenarioUnavailabilityReason.None return allowed.mapNotNull { action -> when (action) { TokenActionsBSContentUM.Action.SendWithSwap -> - if (hasExchange) swapAndSendUM(isRedesignEnabled) else null - else -> byBsAction[action]?.toQuickActionUM(isRedesignEnabled) + if (isExchangeAvailable) { + QuickActionState(swapAndSendUM(isRedesignEnabled), isEnabled = true) + } else { + null + } + else -> { + val state = byBsAction[action] ?: return@mapNotNull null + val um = state.toQuickActionUM(isRedesignEnabled) ?: return@mapNotNull null + QuickActionState(um, isEnabled = state.unavailabilityReason == ScenarioUnavailabilityReason.None) + } } - }.toImmutableList() + } } + private data class QuickActionState(val action: QuickActionUM, val isEnabled: Boolean) + private fun swapAndSendUM(isRedesignEnabled: Boolean): QuickActionUM = if (isRedesignEnabled) QuickActionUM.V2.SwapAndSend else QuickActionUM.V1.SwapAndSend diff --git a/common/ui-markets/src/test/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverterTest.kt b/common/ui-markets/src/test/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverterTest.kt index b0028b644c..d6cecd4063 100644 --- a/common/ui-markets/src/test/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverterTest.kt +++ b/common/ui-markets/src/test/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverterTest.kt @@ -127,4 +127,55 @@ internal class QuickActionsConverterTest { assertThat(result).doesNotContain(QuickActionUM.V2.SwapAndSend) assertThat(result).doesNotContain(QuickActionUM.V2.Exchange(shouldShowBadge = false)) } + + @Test + fun `GIVEN buy and swap unavailable WHEN context is AddFunds THEN they are still shown (disabled) not hidden`() { + // Arrange + val actions = listOf( + TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.BuyUnavailable(cryptoCurrencyName = "BTC")), + TokenActionsState.ActionState.Swap(ScenarioUnavailabilityReason.SingleWallet, shouldShowBadge = false), + TokenActionsState.ActionState.Receive(ScenarioUnavailabilityReason.None), + ) + + // Act + val result = QuickActionsConverter.toQuickActions( + actions = actions, + isRedesignEnabled = true, + context = TokenActionsContext.AddFunds, + ) + + // Assert + assertThat(result).containsExactly( + QuickActionUM.V2.Buy, + QuickActionUM.V2.Exchange(shouldShowBadge = false), + QuickActionUM.V2.Receive, + ).inOrder() + } + + @Test + fun `GIVEN swap and sell unavailable WHEN context is Transfer THEN swap and sell shown disabled and no swapAndSend`() { + // Arrange + val actions = listOf( + TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.None), + TokenActionsState.ActionState.Swap(ScenarioUnavailabilityReason.SingleWallet, shouldShowBadge = false), + TokenActionsState.ActionState.Sell( + ScenarioUnavailabilityReason.NotSupportedBySellService(cryptoCurrencyName = "BTC"), + ), + ) + + // Act + val result = QuickActionsConverter.toQuickActions( + actions = actions, + isRedesignEnabled = true, + context = TokenActionsContext.Transfer, + ) + + // Assert + assertThat(result).containsExactly( + QuickActionUM.V2.Send, + QuickActionUM.V2.Exchange(shouldShowBadge = false), + QuickActionUM.V2.Sell, + ).inOrder() + assertThat(result).doesNotContain(QuickActionUM.V2.SwapAndSend) + } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt b/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt index 9dd905225f..2e6d8cc553 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import com.tangem.common.ui.R +import com.tangem.common.ui.userwallet.CardImage +import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.ui.components.SpacerW12 import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.ds.row.TangemRowContainer @@ -111,6 +113,18 @@ fun PortfolioSelectRowV2( size = AccountIconSize.RedesignedDefault, ) } + } else if (state.imageState != null) { + Box( + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3), + contentAlignment = Alignment.Center, + ) { + CardImage( + modifier = Modifier.size(TangemTheme.dimens2.x10), + imageState = state.imageState, + ) + } } Row( @@ -156,6 +170,7 @@ data class PortfolioSelectUM( val isAccountMode: Boolean, val isMultiChoice: Boolean, val onClick: () -> Unit, + val imageState: UserWalletItemUM.ImageState? = null, ) @Preview(widthDp = 360, showBackground = true) diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt index 5440a2e92f..34386ffbda 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt @@ -42,6 +42,10 @@ sealed class MainScreenAnalyticsEvent( event = "Button - Add Funds", ) + class ButtonTransfer : MainScreenAnalyticsEvent( + event = "Button - Transfer", + ) + class LimitsClicked : MainScreenAnalyticsEvent( event = "Limits Clicked", ) diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/TransferAnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/TransferAnalyticsEvent.kt new file mode 100644 index 0000000000..c197e44afd --- /dev/null +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/TransferAnalyticsEvent.kt @@ -0,0 +1,23 @@ +package com.tangem.core.analytics.models.event + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam + +sealed class TransferAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Transfer", event = event, params = params) { + + class MethodScreenOpened(source: AnalyticsParam.ScreensSources) : TransferAnalyticsEvent( + event = "Method Screen Opened", + params = mapOf(AnalyticsParam.SOURCE to source.value), + ) + + class ButtonSell : TransferAnalyticsEvent(event = "Button - Sell") + + class ButtonSwap : TransferAnalyticsEvent(event = "Button - Swap") + + class ButtonSend : TransferAnalyticsEvent(event = "Button - Send") + + class ButtonSwapAndSend : TransferAnalyticsEvent(event = "Button - Swap&Send") +} \ 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 179cabe4d1..aa5cf5e68b 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 @@ -53,7 +53,7 @@ }, { "name": "TWI_1326_YIELD_MODE_SWAP_ENABLED", - "version": "6.0" + "version": "6.1" }, { "name": "ADDRESS_SYNC_ENABLED", @@ -151,6 +151,10 @@ "name": "AND_14829_WARNINGS_REFACTORING_ENABLED", "version": "undefined" }, + { + "name": "AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED", + "version": "6.0" + }, { "name": "TWI_1522_MARKETING_BANNERS_ENABLED", "version": "undefined" diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 822f6439eb..5914434127 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -105,6 +105,7 @@ Adressen Adresse auswählen + Alles löschen Kontakt Name der Kontaktperson Adresse kopieren @@ -134,6 +135,7 @@ In Wallet speichern Dieser Kontakt wird mit dem Adressbuch dieser Wallet verknüpft. Es wurden keine Ergebnisse gefunden.\nVersuchen Sie es mit einem anderen Namen + Alle auswählen Netzwerk auswählen Adressbuch Nicht gespeicherte Änderungen @@ -421,6 +423,7 @@ oder Hauptkarte Primärring + Sonstiges Passphrase Einfügen Datenschutzrichtlinie @@ -719,6 +722,7 @@ Eine Transaktion kann nicht gesendet werden Fehler in der Coinbeschreibung Portfolio prüfen und Verdienstmöglichkeiten erkunden + Portfolio-Überprüfung Für dich Jetzt aktualisieren Aktualisiere die Anwendung auf die neueste Version, um die ordnungsgemäße Funktionalität zu gewährleisten @@ -735,6 +739,7 @@ Es ist ein Fehler aufgetreten Es ist ein Fehler aufgetreten. Code: %s. Memo erforderlich + Erhalten %1$s Nur diese Mit Deiner Zustimmung erlaubst Du dem Smart Contract, Deine Token in zukünftigen Transaktionen zu verwenden. Betrag %s @@ -918,6 +923,7 @@ **Hinzufügen zu Ihrem Portfolio**, um mit dem Kauf, Tausch oder Erhalt dieses Vermögenswerts zu beginnen Zum Portfolio hinzufügen Dein Portfolio + **Token nicht unterstützt**. Dieser Token wird derzeit in der Wallet nicht unterstützt. Marktimpuls Schnelle Aktionen Alles löschen @@ -1280,6 +1286,8 @@ Erhältlich ab Verfügbar bis zu Du erhältst + Dieser Token wird nicht unterstützt. Bitte wählen Sie einen anderen Token zum Kauf. + %s wird nicht unterstützt Der Service wird von einem externen Anbieter bereitgestellt. \n Tangem übernimmt keine Verantwortung. Du kannst diesen Bildschirm schließen und den Transaktionsstatus auf dem Bildschirm mit den Token-Details überprüfen. Bis zu @@ -1292,6 +1300,19 @@ Nach Guthaben Token organisieren Gruppe löschen + Berechtigte Cashback-Zahlungen werden wie folgt ausgezahlt: + Sie sind bereits eingeschrieben in %1$s + Zulässige Token + Anmelden + Sie sind erfolgreich eingeschrieben in %1$s + Diese Aktion existiert nicht mehr oder ist abgelaufen. + Kampagne nicht aktiv + Cashback-Konto auswählen + Melden Sie sich an %1$s + Ich stimme zu, dass %1$s + Ich stimme zu, dass + %1$s Bedingungen + Erhalten Sie bis Ende Juli bei jedem Swap ab 10.000 $ Cashback.\n\nDie Sätze steigen mit dem Volumen: 0,10% ab 10.000 $, 0,20% ab 20.000 $, 0,50% ab 100.000 $.\n\nMaximale Auszahlung: 500 $ pro Swap und 10.000 $ pro Wallet und Swap-Richtung, solange die Aktion läuft. Swaps von Stablecoins in andere Stablecoins sind ausgeschlossen.\n\nDie Auszahlung erfolgt wöchentlich an eine USDT- oder USDC-Adresse Ihrer Wahl. %s Unterstützung Push-Benachrichtigungen sind aktiviert, funktionieren aber erst, nachdem du Benachrichtigungen in den Geräteeinstellungen zugelassen hast. Benachrichtigungen zulassen @@ -1423,6 +1444,7 @@ Ziel-Tag Adresse eingeben ENS-Name oder Adresse + Adresse, ENS oder Name Die Adresse stimmt mit der Adresse Ihrer Brieftasche überein Minimaler Betrag ist %s Minimaler Wechselgeld ist %s @@ -1622,9 +1644,9 @@ Um mit dem Staking zu beginnen, musst Du zuerst Dein TON-Konto aktivieren. Kontoaktivierung Um mit dem Staking in TON zu beginnen, sende zunächst eine kleine Transaktion an Deine eigene Adresse – dadurch wird Deine Wallet aktiviert. - Für den Abschluss der Transaktion können zusätzlich zur Netzwerkgebühr bis zu 0,2 TON erforderlich sein. Nicht genutzte Beträge werden zurückerstattet. - Für diesen Vorgang sind zusätzlich zur Netzwerkgebühr 0,2 TON erforderlich. Bitte lade Dein Guthaben auf. - TON-Reserve erforderlich + Für den Abschluss der Transaktion können zusätzlich zur Netzwerkgebühr bis zu 0,2 GRAM erforderlich sein. Nicht genutzte Beträge werden zurückerstattet. + Für diesen Vorgang sind zusätzlich zur Netzwerkgebühr 0,2 GRAM erforderlich. Bitte lade Dein Guthaben auf. + GRAM-Reserve erforderlich Durch diese Aktion werden andere Positionen geschlossen oder gemäß den Netzwerkregeln in den Auszahlungsstatus versetzt. Positionsstatus Entsperre dein Geld, um es aus dem Staking-Prozess abzuheben. Das Freischalten nimmt %s. @@ -1803,6 +1825,8 @@ MCC Eine Gebühr wird gemäß den Servicetarifen erhoben Die Transaktion wurde vom Händler teilweise oder vollständig storniert + Wir führen das System schrittweise ein und werden Sie informieren, sobald Tangem Pay hier verfügbar ist. + Tangem Pay ist in Ihrer Region nicht verfügbar. Nutze Dein Geld weiterhin. Du kannst es jederzeit einfrieren. Karte entsperren? Entsperren der Karte fehlgeschlagen. Versuchen Sie es später erneut. @@ -1812,6 +1836,16 @@ Ihr Konto wurde geschlossen Auf gerooteten Geräten nicht nutzbar. Verfügbares Guthaben + ACH + FedWire + Gebühr für die Auffahrt + Eingegangene USD werden im Verhältnis 1:1 in USDC umgewandelt. + Eine Banküberweisung kann 1-2 Werktage dauern. + Durch die Nutzung des Dienstes stimmen Sie den Bedingungen des Anbieters zu. %1$s Und %2$s + Details anzeigen + Dies kann etwas Zeit in Anspruch nehmen. + Vorbereitung Ihrer Bankdaten + Einzahlungen sind ausschließlich per ACH oder FedWire möglich. SWIFT-Überweisungen werden zurückgebucht. KYC vom Hauptbildschirm ausblenden Tangem Pay Karte 1 Guthaben hinzufügen @@ -1881,6 +1915,7 @@ PIN-Code ändern Kehren Sie zur App zurück, falls Sie ihn vergessen. Karte + Fehler beim Laden Tarif wechseln Kartenbezogen Planbezogen @@ -2011,11 +2046,14 @@ Nutzen Sie USDC für alltägliche Zahlungen Tangem Pay ist vorübergehend nicht erreichbar. Tangem Pay + Empfangen Sie Fiat-USD per ACH/FedWire + Banküberweisung Senden Sie USDC Polygon an die Adresse Ihres Kontos Von einer anderen Wallet oder Börse Laden Sie Ihr Konto mit einem beliebigen Token aus Ihrer Wallet auf Aus Ihrer Tangem Wallet USDC im Polygon + Visavorteile Gelder aus erstatteten Käufen werden nicht auf Ihr On-Chain-Guthaben zurückerstattet und stehen nicht für Abhebungen zur Verfügung, bleiben aber auf Ihrem Kartenguthaben für Einkäufe verfügbar Bitte beachten Sie Ihr PIN-Code @@ -2057,6 +2095,13 @@ %s kann nicht ausgeblendet werden N / A QR-Code anzeigen + Die Daten des Tokens konnten nicht geladen werden. + Gehe zum Tauschen + Letzte Aktualisierung: %1$s + Negativer Ausblick + Neutraler Ausblick + Positiver Ausblick + Token-Zusammenfassung Tausche diesen Token gegen einen anderen zu %1$s Servicegebühren von Februar %2$s-%3$s. Tausche mit Changelly, %s Gebühren Jetzt tauschen @@ -2137,6 +2182,12 @@ Wallet umbenennen Alle freischalten Alle mit %s freischalten + Kontonummer + Bankadresse + Name der Bank + Adresse des Begünstigten + Name des Begünstigten + Bankleitzahl Virtuelles Konto Noch keine Transaktionen. Beginnen Sie mit dem Einkaufen und sehen Sie sich hier den Verlauf an AML-geprüft @@ -2358,6 +2409,8 @@ Dieses Token muss mit deinem Hedera-Konto verknüpft sein, bevor du ihn erhalten kannst. Verknüpfe deinen Token Nicht genug %s. Lade dein Hedera-Konto auf, um dieses Token zuzuordnen + Es scheint, dass die Aktivierung der Karte oder des Rings nicht korrekt abgeschlossen wurde. Dies kann an einem Problem mit dem NFC-Modul deines Gerätes oder an einem falschen Tippen der Karte oder Ring auf dein Gerät liegen. Bitte wende dich an unser Support-Team, um Unterstützung zu erhalten. + Handeln ist gefragt. Benutzen Sie nicht Ihr Wallet! Bist Du sicher, dass Du die Transaktion stornieren willst? Du kannst die Transaktion nicht erneut starten. Deine Transaktion mit einem Betrag von %1$s %2$s wurde nicht abgeschlossen. Du kannst versuchen, diese erneut abzuschließen. Du hast eine nicht abgeschlossene Transaktion @@ -2647,6 +2700,8 @@ %1$s aus Aave zurücküberwiesen Yield-Modus initialisieren Yield-Modus reaktivieren + Zurückgegeben + Geliefert Lieferung an Aave %1$s geliefert an Aave Abheben von Aave diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index d3a635d982..60ae725cbf 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1288,6 +1288,7 @@ Tarjeta de crédito o cuenta bancaria Comparta su dirección o código QR Venda criptomonedas de forma segura + Enviar con intercambio a otro token Enviar a otra billetera Entre sus portafolios Otro @@ -1580,9 +1581,9 @@ Para empezar a hacer staking, primero debes activar tu cuenta de TON. Activación de cuenta Para empezar a hacer staking en TON, realice primero una transacción de salida de cualquier importe - esto activará su billetera. - Es posible que se requieran hasta 0.2 TON además de la tarifa de red para completar la transacción. Cualquier cantidad no utilizada será reembolsada. - Se requieren 0.2 TON para realizar esta operación, además de la tarifa de red. Por favor, recargue su saldo. - Se requiere reserva de TON + Es posible que se requieran hasta 0.2 GRAM además de la tarifa de red para completar la transacción. Cualquier cantidad no utilizada será reembolsada. + Se requieren 0.2 GRAM para realizar esta operación, además de la tarifa de red. Por favor, recargue su saldo. + Se requiere reserva de GRAM Esta acción cerrará otras posiciones o las cambiará al estado de retiro, de acuerdo con las reglas de la red. Estado de las posiciones Desbloquee su dinero para retirarlo del proceso de staking. El desbloqueo demora %s minuto. diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index a10ceb497d..bebe09f137 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -94,26 +94,44 @@ Ajouter une adresse Ajouter une adresse et sélectionner un réseau Ajouter le contact + Adresse copiée + Cette adresse est déjà enregistrée sous le nom %1$s adresse adresses + Choisir une adresse + Tout supprimer Contact Nom du contact Copier l\'adresse + Contact sauvegardé Nous n\'avons pas pu créer le contact. Veuillez réessayer plus tard. + Supprimer le contact Ce contact sera supprimé de tous vos carnets d\'adresses + \"%1$s\" n\'a qu\'une seule adresse. La supprimer va aussi supprimer le contact. Continuer? Nous n\'avons pas pu supprimer le contact. Veuillez réessayer plus tard. Gérer les contacts & adresses Oui, annuler Modifier l\'adresse Entrer l\'adresse + Adresse invalide Non, continuer + Vous ne pouvez pas créer plus de 20 adresses. Supprimez-en une pour en ajouter une nouvelle. + Impossible d\'ajouter une nouvelle adresse + Le nom du contact est requis + Le nom du contact contient des caractères invalides + Le nom du contact ne doit pas dépasser 50 caractères + Ce nom est déjà utilisé dans ce portefeuille Nouveau contact Aucun contact pour le moment Les contacts que vous ajouterez vont apparaître ici Supprimer l\'adresse + Enregistrer le contact + Enregistrer dans le portefeuille Ce contact va être lié au carnet d\'adresses de ce portefeuille. + Aucun résultat.\nEssayez un autre nom + Tout sélectionner Sélectionner un réseau Carnet d\'adresses Modifications non enregistrées @@ -1182,6 +1200,7 @@ Aucun jeton pris en charge n\'a été trouvé Ce code QR contient des paramètres non reconnus : %s. Si vous continuez, certaines informations de paiement risquent d\'être perdues. Paramètres inconnus + Envoyer en échangeant vers un autre token Recharge rapide Aucun mémo requis %1$s (%2$s) sur le réseau %3$s @@ -1355,7 +1374,7 @@ Le total dépasse le solde Êtes-vous sûr de vouloir modifier le token de réception ? Cela réinitialisera les données que vous avez saisies précédemment. Changement de token - Échanger et envoyer + Échanger et Envoyer Poursuivre l\'échange ? Cela effacera vos données précédentes. Confirmer la conversion L\'envoi de toute autre crypto entraînera sa perte irréversible. @@ -1459,9 +1478,9 @@ Pour commencer le staking, vous devez d’abord activer votre compte TON Activation du compte Pour commencer à staker TON, effectuez d\'abord une transaction sortante de n\'importe quel montant — cela activera votre portefeuille. - Jusqu\'à 0,2 TON peuvent être requis en plus des frais de réseau pour terminer la transaction. Tout montant non utilisé sera remboursé. - 0,2 TON requis pour effectuer cette opération, en plus des frais de réseau. Veuillez recharger votre solde. - Réserve de TON requise + Jusqu\'à 0,2 GRAM peuvent être requis en plus des frais de réseau pour terminer la transaction. Tout montant non utilisé sera remboursé. + 0,2 GRAM requis pour effectuer cette opération, en plus des frais de réseau. Veuillez recharger votre solde. + Réserve de GRAM requise Cette action fermera d\'autres positions ou les fera passer au statut de retrait, conformément aux règles du réseau. Statut des positions Débloquez votre argent pour le retirer du processus de staking. Le déverrouillage prend %s. @@ -2138,6 +2157,8 @@ Ce jeton doit être associé à votre compte Hedera avant que vous puissiez le recevoir Associez votre jeton %s insuffisant. Renflouez votre compte Hedera pour associer ce jeton + Nous avons constaté que le processus d\'activation de la carte n\'a pas été effectué correctement en raison de problèmes avec le module NFC de votre appareil ou d\'une méthode incorrecte pour maintenir les cartes sur votre téléphone. Veuillez contacter notre équipe d\'assistance pour plus de détails. + Action requise. N\'utilisez pas votre portefeuille! Êtes-vous sûr(e) de vouloir annuler la transaction ? Vous ne pourrez plus réessayer. Votre transaction d\'un montant de %1$s %2$s n\'a pas été finalisée. Vous pouvez réessayer plus tard pour la finaliser. Vous avez une transaction inachevée diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 0cb5e2e92d..2c2737a84a 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -98,14 +98,20 @@ アドレスを追加 アドレスを追加し、ネットワークを選択してください。 連絡先を追加 + アドレスをコピーしました + このアドレスはすでに%1$sとして保存されています %d件のアドレス + アドレスを選択 連絡先 連絡先名 アドレスをコピー + 連絡先を保存しました 連絡先を作成できませんでした。しばらくしてからもう一度お試しください。 + 連絡先を削除 この連絡先は、すべてのアドレス帳から削除されます。 + 「%1$s」には1つのアドレスしかありません。削除すると、連絡先も削除されます。続行しますか? 連絡先を削除できませんでした。しばらくしてからもう一度お試しください。 連絡先とアドレスを管理 破棄 @@ -113,11 +119,20 @@ アドレスを入力 無効なアドレス 編集を続ける + 20件を超えるアドレスは作成できません。新しく追加するには、既存のアドレスを1件削除してください。 + 新しいアドレスを追加できません + 連絡先名を入力してください + 連絡先名に使用できない文字が含まれています + 連絡先名は50文字以内で入力してください + その名前はこのウォレットですでに使用されています 新しい連絡先 連絡先はまだありません 追加した連絡先はここに表示されます。 アドレスを削除 + 連絡先を保存 + ウォレットに保存 この連絡先は、このウォレットのアドレス帳に紐付けられます。 + 結果が見つかりませんでした。\n別の名前を試してください。 ネットワークを選択 連絡先 保存されていない変更 @@ -1269,6 +1284,7 @@ クレジットカードまたは銀行口座 アドレスまたはQRコードを共有してください 暗号資産を安全に売却 + 別のトークンにスワップして送る 別のウォレットに送信 ポートフォリオ間で その他 @@ -1558,9 +1574,9 @@ ステーキングを開始するには、まずTONアカウントを有効化してください。 アカウントの有効化 TONのステーキングを開始するには、まず自分のアドレスに少額の取引を送信します。これによりウォレットが有効になります。 - 取引を完了するには、ネットワーク手数料に加えて最大0.2TONが必要になる場合があります。未使用分は返金されます。 - この操作を続行するには、ネットワーク手数料に加えて0.2 TONが必要です。残高を補充してください。 - TONの準備金が必要です + 取引を完了するには、ネットワーク手数料に加えて最大0.2GRAMが必要になる場合があります。未使用分は返金されます。 + この操作を続行するには、ネットワーク手数料に加えて0.2 GRAMが必要です。残高を補充してください。 + GRAMの準備金が必要です このアクションは、ネットワークのルールに従って、他のポジションをクローズするか、引き出しステータスに切り替えます。 ポジション状況 資金をステーキングから引き出すには、ロックを解除してください。ロック解除には%sかかります。 diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 0b5e395924..7e2292db62 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -105,6 +105,7 @@ %d endereços Escolha o endereço + Limpar tudo Contato Nome do contato Copiar endereço @@ -134,6 +135,7 @@ Salvar na carteira Este contato será vinculado à agenda de endereços desta carteira. Nenhum resultado encontrado.\nTente outro nome + Selecionar tudo Selecione a rede Agenda de endereços Alterações não salvas @@ -1174,13 +1176,13 @@ Biometria Leia mais sobre a frase-semente. - Escreva esta 1palavra na ordem indicada abaixo e guarde-a em um local seguro e secreto. - Escreva estas %dpalavras na ordem indicada abaixo e guarde-as em um local seguro e secreto. + Escreva esta %d palavra na ordem indicada abaixo e guarde-a em um local seguro e secreto. + Escreva estas %d palavras na ordem indicada abaixo e guarde-as em um local seguro e secreto. Sua frase-semente - %dpalavra - %dpalavras + %d palavra + %d palavras Para importar sua carteira, insira sua frase mnemônica no campo abaixo. Gerar frase-semente @@ -1284,6 +1286,8 @@ Disponível em Disponível até Você recebe + Este token não é compatível. Escolha outro token para comprar. + %s não é suportado O serviço é fornecido por um provedor externo. A Tangem não se responsabiliza por ele. Você pode fechar esta tela e verificar o status da transação na tela de detalhes do token. Até @@ -1296,6 +1300,19 @@ Por equilíbrio Organizar tokens Desagrupar + O cashback elegível será distribuído para: + Você já está matriculado(a) em %1$s + Tokens elegíveis + Inscreva-se + Você se inscreveu com sucesso em %1$s + Esta campanha não existe mais ou expirou. + Campanha inativa + Selecione a conta de cashback + Inscreva-se em %1$s + Concordo com %1$s + Concordo com + %1$s Termos + Ganhe cashback em todas as suas transações a partir de US$ 10.000 até o final de julho.\n\nAs taxas aumentam conforme o tamanho da transação: 0,10% a partir de US$ 10 mil, 0,20% a partir de US$ 20 mil, 0,50% A partir de US$ 100 mil.\n\nPagamento máximo: US$ 500 por troca e US$ 10.000 por carteira por direção de troca, enquanto durar a campanha. Trocas de stablecoin por stablecoin estão excluídas.\n\nO pagamento é feito semanalmente em USDT ou USDC, no endereço de sua escolha. %s suporte As notificações push estão ativadas, mas só funcionarão depois que você as permitir nas configurações do seu dispositivo. Permitir notificações @@ -1627,9 +1644,9 @@ Para começar a fazer staking, você precisa ativar sua conta TON primeiro. Ativação de conta Para começar a fazer staking de TON, primeiro envie uma pequena transação para o seu próprio endereço — isso ativará sua carteira. - Poderá ser necessário um valor adicional de até 0,2 TON, além da taxa de rede, para concluir a transação. Qualquer valor não utilizado será reembolsado. - Para prosseguir com esta operação, é necessário um saldo de 0,2 TON, além da taxa de rede. Por favor, recarregue seu saldo. - Reserva TON necessária + Poderá ser necessário um valor adicional de até 0,2 GRAM, além da taxa de rede, para concluir a transação. Qualquer valor não utilizado será reembolsado. + Para prosseguir com esta operação, é necessário um saldo de 0,2 GRAM, além da taxa de rede. Por favor, recarregue seu saldo. + Reserva GRAM necessária Essa ação encerrará outras posições ou as converterá para o status de saque, de acordo com as regras da rede. Status das posições Desbloqueie seu dinheiro para retirá-lo do processo de staking. O desbloqueio leva... %s. @@ -1819,6 +1836,16 @@ Sua conta foi encerrada Não é possível usar em dispositivos com root. Saldo disponível + ACH + FedWire + Taxa de acesso à rampa + Os USD recebidos serão convertidos para USDC na proporção de 1:1. + A transferência bancária pode levar de 1 a 2 dias úteis + Ao utilizar o serviço, você concorda com o provedor. %1$s e %2$s + Mostrar detalhes + Isso pode levar um pouco de tempo. + Preparando seus dados bancários + Depósito somente via ACH ou FedWire. Transferências SWIFT serão devolvidas. Ocultar KYC da tela principal Cartão Tangem Pay 1 Adicionar fundos @@ -1888,6 +1915,7 @@ Alterar código PIN Volte ao aplicativo se você se esquecer. Cartão + Erro ao carregar Alterar plano relacionado a cartões Plano relacionado @@ -2018,11 +2046,14 @@ Use USDC para pagamentos do dia a dia. O serviço Tangem Pay está temporariamente inacessível. Tangem Pay + Receba USD fiduciário via ACH/FedWire + Transferência bancária Envie USDC Polygon para o endereço da sua conta De outra carteira ou exchange Recarregue sua conta com qualquer token da carteira Da sua Tangem Wallet USDC na rede Polygon + Benefícios do visto Os fundos de compras reembolsadas não serão devolvidos ao seu saldo na blockchain nem estarão disponíveis para saque, mas permanecerão no saldo do seu cartão para compras futuras Observe Seu código PIN @@ -2064,6 +2095,13 @@ Não foi possível ocultar %s N/A Mostrar código QR + Não foi possível carregar os dados do token. + Ir para o swap + Última atualização: %1$s + Perspectiva negativa + Perspectiva neutra + Perspectiva positiva + Resumo do token Troque este token por outro em %1$s taxas de serviço a partir de fevereiro %2$s-%3$s. Trocar com Changelly, %s tarifas Troque agora @@ -2144,6 +2182,12 @@ Renomear carteira Desbloquear tudo Desbloqueie tudo com %s + Número de conta + Endereço do banco + Nome do banco + Endereço do beneficiário + Nome do beneficiário + Número de roteamento Conta virtual Ainda não há transações. Comece a gastar e veja o histórico aqui. Verificado AML @@ -2656,6 +2700,8 @@ %1$s retirado de Aave Modo de rendimento inicializado Modo de rendimento reativado + Devolvido + Fornecido Fornecimento para Aave %1$s fornecido à Aave Retirar do Aave diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index b1a59ec612..d111e36499 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -81,7 +81,7 @@ Выберите токен для получения Выберите токен для обмена Пополнить - Обмен + Обменять Перевести Добавить в портфель Добавить токены @@ -98,17 +98,24 @@ Добавить адрес Добавить адрес и выбрать сеть Добавить контакт + Адрес скопирован + Этот адрес уже сохранен как %1$s - адрес - адреса - адресов - адресов + %d адрес + %d адреса + %d адресов + %d адресов + Выберите адрес + Очистить все Контакт Имя контакта Копировать адрес + Контакт сохранен Не удалось создать контакт. Пожалуйста, попробуйте позже. + Удалить контакт Этот контакт будет удален из всех ваших адресных книг + У \"%1$s\" только один адрес. Его удаление также приведет к удалению контакта. Продолжить? Не удалось удалить контакт. Пожалуйста, попробуйте позже. Управление контактами и адресами Отменить @@ -116,11 +123,21 @@ Ввести адрес Неверный адрес Продолжить + Вы не можете создать более 20 адресов. Удалите один, чтобы добавить новый. + Невозможно добавить новый адрес + Имя контакта обязательно + Имя контакта содержит недопустимые символы + Имя контакта должно содержать не более 50 символов + Это имя уже используется в этом кошельке Новый контакт Нет добавленных контактов Здесь отобразятся добавленные вами контакты. Удалить адрес + Сохранить контакт + Сохранить в кошелек Этот контакт будет привязан к этому кошельку в адресной книге. + Ничего не найдено.\nПопробуйте другое имя + Выбрать все Выбрать сеть Адресная книга Несохраненные изменения @@ -1335,6 +1352,7 @@ Банковская карта или банковский счет Поделитесь своим адресом или QR-кодом Продавайте криптовалюту безопасно + Отправить с обменом на другой токен Отправить на другой кошелек Между вашими портфелями Другие @@ -1526,7 +1544,7 @@ Отправляемая сумма превышает остаток Вы уверены, что хотите изменить токен для получения? Это действие сбросит ранее введённые данные. Изменение токена - Обмен и отправка + Обменять и отправить Продолжить с обменом? Это действие удалит предыдущие данные Подтвердить конвертацию Отправка любой другой валюты приведёт к её безвозвратной потере. @@ -1631,9 +1649,9 @@ Чтобы начать стейкинг, сначала активируйте свой TON-аккаунт. Активация аккаунта Чтобы начать стейкинг в TON, сначала отправьте небольшую транзакцию на свой же адрес — это активирует ваш кошелёк. - До 0.2 TON может потребоваться сверх сетевой комиссии для завершения транзакции. Неиспользованная часть будет возвращена. - Для выполнения операции требуется дополнительно 0.2 TON, помимо сетевой комиссии. Пожалуйста, пополните баланс. - Требуется резерв TON + До 0.2 GRAM может потребоваться сверх сетевой комиссии для завершения транзакции. Неиспользованная часть будет возвращена. + Для выполнения операции требуется дополнительно 0.2 GRAM, помимо сетевой комиссии. Пожалуйста, пополните баланс. + Требуется резерв GRAM Это действие закроет другие позиции или переведёт их в статус вывода средств в соответствии с правилами сети. Статус позиций Разблокируйте свои средства, чтобы вывести их из стейкинга. Разблокировка займёт %s. @@ -2047,7 +2065,7 @@ Токен в сети %%image%% %1$s %s сеть %1$s в сети %2$s - %1$sв %%image%% %2$s + %1$s в %%image%% %2$s %1$s в %2$s %%image%% Токен %1$s (%2$s) является основной валютой в сети %3$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети Невозможно скрыть %s @@ -2292,6 +2310,8 @@ Этот токен должен быть ассоциирован с вашей учетной записью Hedera, прежде чем вы сможете его принять Ассоциируете свой токен Недостаточно %s. Пополните ваш аккаунт Hedera для ассоциации этого токена + Мы обнаружили, что процесс активации карты не завершен должным образом из-за проблем с NFC-модулем устройства или неправильного прикладывания карт к телефону. Пожалуйста, обратитесь в службу поддержки для уточнения деталей. + Требуется действие. Не используйте кошелек! Вы уверены что хотите отменить эту транзакцию? Вы не сможете отправить ее еще раз Ваша транзакция %1$s %2$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 9c7b73b538..87fbf2490b 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -81,7 +81,7 @@ Оберіть токен для отримання Оберіть токен для обміну Поповнити - Обмін + Обміняти Переказ Додати до портфеля Додати токени @@ -98,17 +98,24 @@ Додати адресу Додати адресу та вибрати мережу Додати контакт + Адресу скопійовано + Цю адресу вже збережено як %1$s - адреса - адреси - адрес - адрес + %d адреса + %d адреси + %d адрес + %d адрес + Оберіть адресу + Очистити все Контакт Ім\'я контакту Скопіювати адресу + Контакт збережено Не вдалося створити контакт. Будь ласка, спробуйте пізніше. + Видалити контакт Цей контакт буде видалено з усіх ваших адресних книг + У \"%1$s\" лише одна адреса. Її видалення також призведе до видалення контакту. Продовжити? Не вдалося видалити контакт. Будь ласка, спробуйте пізніше. Керування контактами та адресами Скасувати @@ -116,11 +123,21 @@ Ввести адресу Недійсна адреса Продовжити + Ви не можете створити більше 20 адрес. Видаліть одну, щоб додати нову. + Неможливо додати нову адресу + Ім\'я контакту обов\'язкове + Ім\'я контакту містить недопустимі символи + Ім\'я контакту повинно містити не більше 50 символів + Це ім\'я вже використовується в цьому гаманці Новий контакт Немає доданих контактів Тут відображатимуться додані вами контакти. Видалити адресу + Зберегти контакт + Зберегти в гаманець Цей контакт буде прив\'язано до цього гаманця в адресній книзі. + Нічого не знайдено.\nСпробуйте інше ім\'я + Вибрати все Вибрати мережу Адресна книга Незбережені зміни @@ -1335,6 +1352,7 @@ Банківська картка або банківський рахунок Поділіться своєю адресою або QR-кодом Безпечно продавайте криптовалюту + Надіслати з обміном на інший токен Надіслати на інший гаманець Між вашими портфелями Інші @@ -1526,7 +1544,7 @@ Сума, що відправляється, перевищує залишок Ви впевнені, що хочете змінити токен отримання? Це призведе до скидання раніше введених даних. Зміна токену - Обмін та надсилання + Обміняти та надіслати Продовжити обмін? Це очистить ваші попередні дані. Підтвердити конвертацію Надсилання будь-якої іншої валюти призведе до її незворотної втрати. @@ -1631,9 +1649,9 @@ Щоб розпочати стейкінг, спочатку активуйте свій TON-акаунт. Активація акаунту Щоб почати стейкінг в TON, спочатку здійсніть вихідну транзакцію на будь-яку суму — це активує ваш гаманець. - До 0.2 TON може знадобитися додатково мережевої комісії для завершення транзакції. Невикористана частина буде повернута. - Для виконання операції потребується додатково 0.2 TON, крім мережевої комісії. Будь ласка, поповніть баланс. - Потрібен резерв TON + До 0.2 GRAM може знадобитися додатково мережевої комісії для завершення транзакції. Невикористана частина буде повернута. + Для виконання операції потребується додатково 0.2 GRAM, крім мережевої комісії. Будь ласка, поповніть баланс. + Потрібен резерв GRAM Ця дія закриє інші позиції або переведе їх у статус виведення, відповідно до правил мережі. Статус позицій Розблокуйте свої кошти, щоб вивести їх зі стейкінгу. Розблокування займе %s. @@ -2307,6 +2325,8 @@ Цей токен повинен бути асоційований з вашим обліковим записом Hedera, перш ніж ви зможете його прийняти Асоціюйте свій токен Недостатньо %s. Поповніть ваш обліковий запис Hedera для асоціації цього токена + Ми виявили, що процес активації картки не завершено належним чином через проблеми з NFC-модулем пристрою або неправильне прикладання карток до телефону. Будь ласка, зверніться до служби підтримки для уточнення деталей. + Потрібна дія. Не використовуйте гаманець! Ви впевнені, що хочете скасувати цю транзакцію? Ви не зможете відправити її ще раз Ваша транзакція %1$s %2$s не була завершена. Ви можете спробувати відправити її ще раз. У вас є незавершена транзакція diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index 40abfde547..ef6f3f2e6c 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -98,14 +98,21 @@ 添加地址 添加地址并选择网络 添加联系人 + 地址已复制 + 此地址已保存为 %1$s %d地址\n%d地址 + 选择地址 + 全部清除 联系人 联系人姓名 复制地址 + 联系人已保存 无法创建联系人。请稍后再试。 + 删除联系人 该联系人将从您所有的通讯录中删除 + “%1$s“只有一个地址。删除此地址也会删除联系人。继续吗?” 无法删除联系人,请稍后再试。 管理联系人及地址 取消 @@ -113,11 +120,21 @@ 输入地址 无效地址 继续编辑 + 您最多只能创建 20 个地址。删除一个地址即可添加新地址。 + 无法添加新地址 + 必须填写联系人姓名 + 联系人姓名包含无效字符 + 联系人姓名不得超过 50 个字符 + 这个钱包的名字已经被注册了 新联系人 尚无联系人 添加的联系人将显示在此处 移除地址 + 保存联系人 + 保存到钱包 该联系人将与该钱包的通讯录关联。 + 未找到结果。\n请尝试其他名称 + 全选 选择网络 地址簿 未保存的更改 @@ -1263,6 +1280,7 @@ 信用卡或银行账户 分享您的地址或二维码 安全出售加密货币 + 发送并兑换成另一种代币 发送到另一个钱包 在您的投资组合之间 其他 @@ -1550,9 +1568,9 @@ 要开始质押,您需要先激活您的 TON 账户。 激活账户 要开始在 TON 上进行质押,首先向您自己的地址发送一笔小额交易——这将激活您的钱包。 - 除网络费用外,完成交易可能还需要额外支付最多 0.2 TON 的费用。任何未使用的金额将予以退还。 - 除网络费用外,本次操作还需要 0.2 TON。请充值。 - 需要TON储备 + 除网络费用外,完成交易可能还需要额外支付最多 0.2 GRAM 的费用。任何未使用的金额将予以退还。 + 除网络费用外,本次操作还需要 0.2 GRAM。请充值。 + 需要GRAM储备 根据网络规则,此操作将关闭其他仓位或将其切换为提现状态。 仓位状态 解锁您的资金即可从质押过程中提款。解锁需要一定时间。 %s。 @@ -2262,6 +2280,8 @@ 您必须先将此代币与您的 Hedera 账户关联才能收到它。 关联您的代币 %s不够。请为您的 Hedera 账户充值以关联此代币 + 我们发现,由于您的设备NFC模块存在问题,或者将卡片轻触手机的方式不正确,导致卡片激活流程未能正常完成。请联系我们的客服团队以获取更多详情。 + 必须采取行动。不要用你的钱包! 您确定要取消交易吗?取消后您将无法再次尝试交易。 您金额为 %1$s %2$s 的交易未完成。您可以再次尝试完成交易。 您有未完成的交易 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index b237dfe401..f2b53e3b74 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -289,10 +289,6 @@ Approve Approved Approving - - %d asset - %d assets - Attention Available networks Backup @@ -642,6 +638,8 @@ Long transaction time The transaction amount was refunded in %1$s to your wallet due to OKX or bridge rules. %2$s The amount was refunded in %1$s (%2$s network) + Your funds have been refunded in %1$s to your wallet on the %2$s network, in accordance with OKX exchange rules. + Refunded in %s Visit provider’s website for verification KYC verification required by provider Purchase completed @@ -1292,19 +1290,34 @@ Available up to You get This token is not supported. Please choose a different token to buy. + %s is not supported Service is provided by an external provider. \nTangem is not responsible. You can close this screen and check the transaction status on the token details screen. Up to Via via %s You will pay - %s is not supported Group Group by networks Sort by balance By balance Organize tokens Ungroup + Eligible cashback will be distributed to: + You\'re already enrolled in %1$s + Eligible tokens + Enroll + You\'re successfully enrolled in %1$s + This campaign no longer exists or has expired + Campaign not active + Earn 0.5% cashback on every swap over $500, on any pair except stable to stable. Max payout $50 per swap.\n\nComplete five qualifying swaps and unlock an extra $10 bonus.\n\nRewards are paid weekly in USDT or USDC on the address selected. + Select cashback account + Select token + Enroll in %1$s + I agree with %1$s + I agree with + %1$s Terms + Earn cashback on every swap from $10K until the end of July.\n\nRates step up with size: 0.10% from $10K, 0.20% from $20K, 0.50% from $100K.\n\nMax payout: $500 per swap, and $10,000 per wallet per swap direction until campaign lasts. Stable coin into stablecoin swaps are excluded.\n\nPayout arrives weekly in USDT or USDC address of your choice. %s support Push Notifications are enabled but won\'t work until you allow them Allow notifications @@ -1528,7 +1541,7 @@ Total amount exceeds balance Are you sure you want to change the receiving token? This will reset your previously entered data. Changing token - Swap and send + Swap & Send Proceed with swap? This will clear your previous data. Confirm Conversion Sending any other currency will result in its irreversible loss. @@ -1636,9 +1649,9 @@ To begin staking, you need to activate your TON account first. Account activation To start staking in TON, first send a small transaction to your own address — this will activate your wallet. - Up to 0.2 TON may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded. - 0.2 TON is required to proceed with this operation, in addition to the network fee. Please top up your balance. - TON reserve required + Up to 0.2 GRAM may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded. + 0.2 GRAM is required to proceed with this operation, in addition to the network fee. Please top up your balance. + GRAM reserve required This action will close other positions or switch them to withdrawal status, according to network rules. Positions status Unlock your money to withdraw it from staking process. Unlocking takes %s. @@ -2089,6 +2102,10 @@ Show QR code Can’t load data of the token Go to swap + Last update: %1$s + Negative outlook + Neutral outlook + Positive outlook Token summary Exchange this token for another at %1$s service fees from February %2$s-%3$s. Swap with Changelly, %s fees 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 e8cf99da89..54d5069530 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 @@ -29,6 +29,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.utils.extensions.indexOfFirstOrNull @@ -281,6 +283,7 @@ private fun Segment( modifier: Modifier = Modifier, minSegmentWidth: Dp = Dp.Unspecified, ) { + val hapticManager = LocalHapticManager.current Box( modifier = modifier .defaultMinSize(minWidth = minSegmentWidth) @@ -288,6 +291,9 @@ private fun Segment( indication = null, interactionSource = remember { MutableInteractionSource() }, ) { + if (selectedIndex.value != index) { + hapticManager.perform(TangemHapticEffect.View.SegmentTick) + } selectedIndex.value = index onClick() }, diff --git a/data/markets/build.gradle.kts b/data/markets/build.gradle.kts index b7cace4d18..868451243f 100644 --- a/data/markets/build.gradle.kts +++ b/data/markets/build.gradle.kts @@ -57,4 +57,8 @@ dependencies { api(projects.libs.blockchainSdk) implementation(projects.libs.crypto) // endregion + + // region Tests dependencies + testImplementation(projects.test.core) + // endregion } diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt index 86b29f8006..0ef9c79726 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt @@ -1,5 +1,6 @@ package com.tangem.data.markets.converters +import com.tangem.blockchainsdk.compatibility.applyL2Compatibility import com.tangem.datasource.api.markets.models.response.TokenMarketListResponse import com.tangem.domain.markets.TokenMarket import com.tangem.domain.markets.TokenMarketListWithMaxApy @@ -19,7 +20,8 @@ internal object TokenMarketListConverter : Converter + val tokens = value.tokens.map { rawToken -> + val token = rawToken.applyL2Compatibility() val stakingRate = token.stakingOpportunities ?.mapNotNull { it.apy } ?.max() diff --git a/data/markets/src/test/java/com/tangem/data/markets/converters/TokenMarketListConverterTest.kt b/data/markets/src/test/java/com/tangem/data/markets/converters/TokenMarketListConverterTest.kt new file mode 100644 index 0000000000..e48c695c8e --- /dev/null +++ b/data/markets/src/test/java/com/tangem/data/markets/converters/TokenMarketListConverterTest.kt @@ -0,0 +1,145 @@ +package com.tangem.data.markets.converters + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchainsdk.compatibility.l2BlockchainsList +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.datasource.api.markets.models.response.TokenMarketListResponse +import com.tangem.domain.markets.TokenMarket +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class TokenMarketListConverterTest { + + @Test + fun `GIVEN ethereum coin with networks WHEN convert THEN L2 networks are appended`() { + // Arrange + val response = createResponse( + createToken(id = "ethereum", networks = listOf(createNetwork(networkId = "ethereum"))), + ) + + // Act + val actual = TokenMarketListConverter.convert(response) + + // Assert + val networkIds = actual.tokens.single().networks?.map(TokenMarket.Network::networkId) + val expectedNetworkIds = listOf("ethereum") + l2BlockchainsList.map { it.toNetworkId() } + assertThat(networkIds).containsExactlyElementsIn(expectedNetworkIds) + assertThat(networkIds).containsAtLeast("arbitrum-one", "optimistic-ethereum", "base") + } + + @Test + fun `GIVEN ethereum coin with networks WHEN convert THEN appended L2 networks are native coin entries`() { + // Arrange + val response = createResponse( + createToken(id = "ethereum", networks = listOf(createNetwork(networkId = "ethereum"))), + ) + + // Act + val actual = TokenMarketListConverter.convert(response) + + // Assert + val l2Networks = actual.tokens.single().networks.orEmpty().filter { it.networkId != "ethereum" } + assertThat(l2Networks).isNotEmpty() + assertThat(l2Networks.mapNotNull(TokenMarket.Network::contractAddress)).isEmpty() + } + + @Test + fun `GIVEN ethereum coin with backend-provided L2 network WHEN convert THEN backend entry wins without duplicates`() { + // Arrange + val backendArbitrum = createNetwork(networkId = "arbitrum-one", decimalCount = 18) + val response = createResponse( + createToken(id = "ethereum", networks = listOf(createNetwork(networkId = "ethereum"), backendArbitrum)), + ) + + // Act + val actual = TokenMarketListConverter.convert(response) + + // Assert + val networks = actual.tokens.single().networks.orEmpty() + assertThat(networks.map(TokenMarket.Network::networkId)).containsNoDuplicates() + val arbitrum = networks.single { it.networkId == "arbitrum-one" } + assertThat(arbitrum.decimalCount).isEqualTo(18) + } + + @Test + fun `GIVEN non-ethereum token with networks WHEN convert THEN networks stay unchanged`() { + // Arrange + val tetherNetworks = listOf( + createNetwork( + networkId = "ethereum", + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + decimalCount = 6, + ), + createNetwork( + networkId = "tron", + contractAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + decimalCount = 6, + ), + ) + val response = createResponse(createToken(id = "tether", networks = tetherNetworks)) + + // Act + val actual = TokenMarketListConverter.convert(response) + + // Assert + val expected = tetherNetworks.map { network -> + TokenMarket.Network( + networkId = network.networkId, + contractAddress = network.contractAddress, + decimalCount = network.decimalCount, + ) + } + assertThat(actual.tokens.single().networks).isEqualTo(expected) + } + + @Test + fun `GIVEN ethereum coin without networks WHEN convert THEN networks stay null`() { + // Arrange + val response = createResponse(createToken(id = "ethereum", networks = null)) + + // Act + val actual = TokenMarketListConverter.convert(response) + + // Assert + assertThat(actual.tokens.single().networks).isNull() + } + + private fun createResponse(vararg tokens: TokenMarketListResponse.Token) = TokenMarketListResponse( + imageHost = "https://img.tangem.org/", + tokens = tokens.toList(), + total = tokens.size, + limit = 20, + offset = 0, + timestamp = 1L, + summary = null, + ) + + private fun createToken( + id: String, + networks: List?, + name: String = id, + symbol: String = id.take(n = 3).uppercase(), + ) = TokenMarketListResponse.Token( + id = id, + name = name, + symbol = symbol, + currentPrice = BigDecimal.ONE, + priceChangePercentage = null, + marketRating = null, + marketCap = null, + isUnderMarketCapLimit = null, + stakingOpportunities = null, + maxYieldApy = null, + networks = networks, + ) + + private fun createNetwork( + networkId: String, + contractAddress: String? = null, + decimalCount: Int? = null, + ) = TokenMarketListResponse.Token.Network( + networkId = networkId, + contractAddress = contractAddress, + decimalCount = decimalCount, + ) +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt index 2ac6d5e339..a2b194b0f8 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt @@ -14,6 +14,12 @@ internal class DefaultStakingFeatureToggles( return featureTogglesManager.isFeatureEnabled(toggle) } + override fun isSolanaUnstakeValidationEnabled(): Boolean { + return featureTogglesManager.isFeatureEnabled( + FeatureToggles.AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED, + ) + } + private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) { is StakingIntegrationID.P2PEthPool -> null is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle() diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt index 6ec391fa7b..c6f96ecc7b 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt @@ -3,6 +3,7 @@ package com.tangem.data.staking.toggles import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.domain.staking.model.StakingIntegrationID import com.google.common.truth.Truth.assertThat +import com.tangem.core.configtoggle.FeatureToggles import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk @@ -46,4 +47,30 @@ internal class DefaultStakingFeatureTogglesTest { verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) } } + + @Test + fun `isSolanaUnstakeValidationEnabled returns true when toggle enabled`() { + every { + featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED) + } returns true + + assertThat(toggles.isSolanaUnstakeValidationEnabled()).isTrue() + + verify(exactly = 1) { + featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED) + } + } + + @Test + fun `isSolanaUnstakeValidationEnabled returns false when toggle disabled`() { + every { + featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED) + } returns false + + assertThat(toggles.isSolanaUnstakeValidationEnabled()).isFalse() + + verify(exactly = 1) { + featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED) + } + } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index 35cca5b3c2..a99062d362 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -92,10 +92,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( .fold( ifLeft = { error -> logger.e("Failed check wallet ${params.userWalletId}: ${error.javaClass.simpleName}") - when (error) { - is VisaApiError.NotPaeraCustomer -> constructNotCreatedOrEmptyStatus(params.userWalletId) - else -> PaymentAccountStatusValue.Error.Unavailable - } + error.toStatusValueWhenTangemPayStatusUnknown(params.userWalletId) }, ifRight = { hasTangemPay -> proceedHasTangemPayResult(account = account, hasTangemPay = hasTangemPay) @@ -168,14 +165,11 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( if (cache != null && cache.value.hasAccountData()) { cache.value.copySealed( source = StatusSource.ONLY_CACHE, - error = when (error) { - is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced - else -> PaymentAccountStatusValue.Error.Unavailable - }, + error = error.toErrorValue(), ) } else { logger.e("proceedWithoutOrder ${account.userWalletId} error: $error") - error.mapToPaymentAccountStatus(account.userWalletId) + error.toStatusValueWhenHasTangemPay(account.userWalletId) } }, ifRight = { customerInfo -> @@ -196,7 +190,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( val customerInfo = onboardingRepository.getCustomerInfo(account.userWalletId).fold( ifLeft = { error -> logger.e("proceedWithOrderId KYC check ${account.userWalletId} error: $error") - return error.mapToPaymentAccountStatus(account.userWalletId) + return error.toStatusValueWhenHasTangemPay(account.userWalletId) }, ifRight = { it }, ) @@ -215,7 +209,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( return customerOrderRepository.getOrderData(userWalletId = account.userWalletId, orderId = orderId).fold( ifLeft = { error -> logger.e("proceedWithOrderId ${account.userWalletId} orderId: $orderId error: $error") - error.mapToPaymentAccountStatus(account.userWalletId) + error.toStatusValueWhenHasTangemPay(account.userWalletId) }, ifRight = { orderData -> logger.i("proceedWithOrderId ${account.userWalletId}: $orderId status: ${orderData.status}") @@ -282,7 +276,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( onboardingRepository.clearOrderId(account.userWalletId) return onboardingRepository.getCustomerInfo(userWalletId = account.userWalletId) .fold( - ifLeft = { it.mapToPaymentAccountStatus(account.userWalletId) }, + ifLeft = { it.toStatusValueWhenHasTangemPay(account.userWalletId) }, ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus(account.userWalletId) }, ) } @@ -518,14 +512,39 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( state = TangemPayCardState.Issuing, ) - private suspend fun VisaApiError.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue { + private suspend fun VisaApiError.toStatusValueWhenHasTangemPay( + userWalletId: UserWalletId, + ): PaymentAccountStatusValue { return when (this) { - is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced is VisaApiError.NotPaeraCustomer -> constructNotCreatedOrEmptyStatus(userWalletId) - else -> PaymentAccountStatusValue.Error.Unavailable + else -> toErrorValue() } } + private suspend fun VisaApiError.toStatusValueWhenTangemPayStatusUnknown( + userWalletId: UserWalletId, + ): PaymentAccountStatusValue { + return when (this) { + is VisaApiError.NotPaeraCustomer -> constructNotCreatedOrEmptyStatus(userWalletId) + else -> { + val previousValue = paymentAccountStatusesStore.getSyncOrNull(userWalletId)?.value + if (previousValue != null && previousValue.hasAccountData()) { + previousValue.copySealed( + source = StatusSource.ONLY_CACHE, + error = toErrorValue(), + ) + } else { + constructNotCreatedOrEmptyStatus(userWalletId) + } + } + } + } + + private fun VisaApiError.toErrorValue(): PaymentAccountStatusValue.Error = when (this) { + is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced + else -> PaymentAccountStatusValue.Error.Unavailable + } + private suspend fun constructNotCreatedOrEmptyStatus(userWalletId: UserWalletId): PaymentAccountStatusValue { val entryPoint = TangemPayEntryPoint.BANNER val shouldShowBanner = !eligibilityManager.isPaeraCustomerForAnyWallet(entryPoint) && diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt index 5010202433..1711ccdcbd 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt @@ -1,6 +1,7 @@ package com.tangem.domain.onramp.analytics import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_DESCRIPTION import com.tangem.core.analytics.models.AnalyticsParam.Key.PAYMENT_METHOD @@ -208,4 +209,17 @@ sealed class OnrampAnalyticsEvent( event = "Button - All Offers", params = emptyMap(), ) + + data class NoticeBuyNotSupported( + private val source: OnrampSource, + private val tokenSymbol: String, + private val blockchain: String, + ) : OnrampAnalyticsEvent( + event = "Notice - Buy Not Supported", + params = mapOf( + SOURCE to source.analyticsName, + TOKEN_PARAM to tokenSymbol, + BLOCKCHAIN to blockchain, + ), + ) } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt b/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt index 80761562fc..8da36f24fb 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt @@ -5,4 +5,6 @@ import com.tangem.domain.staking.model.StakingIntegrationID interface StakingFeatureToggles { fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean + + fun isSolanaUnstakeValidationEnabled(): Boolean } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt index ba7056df00..cc59832b8d 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 @@ -136,6 +136,30 @@ sealed class TokenScreenAnalyticsEvent( status = status, blockchain = blockchain, ) + + class ButtonAddFunds( + token: String, + blockchain: String, + derivationIndex: Int? = null, + ) : ButtonWithParams( + event = "Button - Add Funds", + token = token, + status = null, + blockchain = blockchain, + derivationIndex = derivationIndex, + ) + + class ButtonTransfer( + token: String, + blockchain: String, + derivationIndex: Int? = null, + ) : ButtonWithParams( + event = "Button - Transfer", + token = token, + status = null, + blockchain = blockchain, + derivationIndex = derivationIndex, + ) } class ActionButtonDisabled( 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 75b406ee1b..4cc9fc0ed5 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 @@ -54,37 +54,6 @@ internal open class BaseActionsFactory( } } - /** - * Determines the unavailability reason for the BUY action - * - * @param userWallet the user's cold wallet - * @param currency the cryptocurrency to check - * @param requirementsDeferred a deferred object containing the asset requirements condition - */ - protected suspend fun getOnrampUnavailabilityReason( - userWallet: UserWallet, - currency: CryptoCurrency, - requirementsDeferred: Deferred?, - ): ScenarioUnavailabilityReason { - // Start2Coin (S2C) are legacy single-currency cards that do not support buying crypto in-app - // (historically only Receive/Send were offered for them). - if (userWallet is UserWallet.Cold && userWallet.cardTypesResolver.isStart2Coin()) { - return ScenarioUnavailabilityReason.BuyUnavailable(currency.name) - } - - val onrampUnavailabilityReason = rampStateManager.availableForBuy( - userWallet = userWallet, - cryptoCurrency = currency, - ) - val shouldCheckAssetRequirements = - onrampUnavailabilityReason == ScenarioUnavailabilityReason.None && requirementsDeferred != null - return if (shouldCheckAssetRequirements) { - getReceiveScenario(requirementsDeferred.await()) - } else { - onrampUnavailabilityReason - } - } - /** * Determines the unavailability reason for the SEND action * @@ -104,17 +73,20 @@ internal open class BaseActionsFactory( /** * Determines the unavailability reason for the SELL action * - * @param userWalletId the ID of the user's wallet + * @param userWallet the user's wallet * @param status the status of the cryptocurrency * @param sendUnavailabilityReason the reason for unavailability of the send action */ protected suspend fun getSellUnavailabilityReason( - userWalletId: UserWalletId, + userWallet: UserWallet, status: CryptoCurrencyStatus, sendUnavailabilityReason: ScenarioUnavailabilityReason, ): ScenarioUnavailabilityReason { + if (userWallet is UserWallet.Cold && userWallet.cardTypesResolver.isStart2Coin()) { + return ScenarioUnavailabilityReason.NotSupportedBySellService(status.currency.name) + } return rampStateManager.availableForSell( - userWalletId = userWalletId, + userWalletId = userWallet.walletId, status = status, sendUnavailabilityReason = sendUnavailabilityReason, ).fold( 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 4d9837958f..f244724fd4 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 @@ -50,14 +50,6 @@ internal class CommonActionsFactory( null } - val onrampUnavailabilityReasonDeferred = async { - getOnrampUnavailabilityReason( - userWallet = userWallet, - currency = cryptoCurrencyStatus.currency, - requirementsDeferred = requirementsDeferred, - ) - } - val sendUnavailabilityReasonDeferred = async { getSendUnavailabilityReason(userWalletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus) } @@ -99,12 +91,12 @@ internal class CommonActionsFactory( // endregion // region Buy - addBuyAction(reason = onrampUnavailabilityReasonDeferred.await()) + addBuyAction(reason = ScenarioUnavailabilityReason.None) // endregion // region Sell val sellUnavailabilityReason = getSellUnavailabilityReason( - userWalletId = userWallet.walletId, + userWallet = userWallet, status = cryptoCurrencyStatus, sendUnavailabilityReason = sendUnavailabilityReason, ) 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 49084a3a21..111e4685a5 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 @@ -52,14 +52,6 @@ internal class OutdatedDataActionsFactory( null } - val onrampUnavailabilityReasonDeferred = async { - getOnrampUnavailabilityReason( - userWallet = userWallet, - currency = cryptoCurrencyStatus.currency, - requirementsDeferred = requirementsDeferred, - ) - } - val sendUnavailabilityReasonDeferred = if (sources.networkSource == StatusSource.ACTUAL) { async { getSendUnavailabilityReason( @@ -87,7 +79,7 @@ internal class OutdatedDataActionsFactory( // endregion // region Buy - addBuyAction(reason = onrampUnavailabilityReasonDeferred.await()) + addBuyAction(reason = ScenarioUnavailabilityReason.None) // endregion // region Stake @@ -120,7 +112,7 @@ internal class OutdatedDataActionsFactory( // region Sell if (sendUnavailabilityReason == ScenarioUnavailabilityReason.None) { val sellUnavailabilityReason = getSellUnavailabilityReason( - userWalletId = userWallet.walletId, + userWallet = userWallet, status = cryptoCurrencyStatus, sendUnavailabilityReason = sendUnavailabilityReason, ) 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 11a8155d1d..23e188e3dd 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 @@ -35,14 +35,6 @@ internal class UnreachableActionsFactory( null } - val onrampUnavailabilityReasonDeferred = async { - getOnrampUnavailabilityReason( - userWallet = userWallet, - currency = cryptoCurrencyStatus.currency, - requirementsDeferred = requirementsDeferred, - ) - } - val hideTokenUnavailabilityReason = getTokenHideUnavailabilityReason(userWallet) // endregion @@ -52,7 +44,7 @@ internal class UnreachableActionsFactory( // endregion // region Buy - addBuyAction(reason = onrampUnavailabilityReasonDeferred.await()) + addBuyAction(reason = ScenarioUnavailabilityReason.None) // endregion // region Receive diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt index 3f2288d1f4..e1d4f00182 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt @@ -18,6 +18,7 @@ import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTi import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.commonfeatures.impl.R @@ -69,7 +70,7 @@ internal fun AddToPortfolioBottomSheet( AddToPortfolioRoutes.AddToken, AddToPortfolioRoutes.Empty, is AddToPortfolioRoutes.NetworkSelector, - AddToPortfolioRoutes.TokenActions, + is AddToPortfolioRoutes.TokenActions, -> true } if (isScrollableContent) { @@ -92,11 +93,12 @@ private fun AddToPortfolioBottomSheetTitle( onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - val title: TextReference = when (stack.active.configuration) { + val title: TextReference = when (val config = stack.active.configuration) { AddToPortfolioRoutes.AddToken -> resourceReference(R.string.common_add_token) AddToPortfolioRoutes.Empty -> TextReference.EMPTY is AddToPortfolioRoutes.NetworkSelector -> resourceReference(R.string.common_choose_network) - AddToPortfolioRoutes.TokenActions -> resourceReference(R.string.common_get_token) + is AddToPortfolioRoutes.TokenActions -> + resourceReference(R.string.get_token_title, wrappedList(config.currencyName)) AddToPortfolioRoutes.UserPortfolio -> resourceReference(R.string.markets_portfolio_block_title) AddToPortfolioRoutes.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent) .title.collectAsStateWithLifecycle().value diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt index 6c9970ee38..d3652b2d02 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt @@ -104,7 +104,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( ): ComposableContentComponent = when (config) { AddToPortfolioRoutes.AddToken -> addTokenComponent AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent - AddToPortfolioRoutes.TokenActions -> tokenActionsComponent + is AddToPortfolioRoutes.TokenActions -> tokenActionsComponent AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY AddToPortfolioRoutes.UserPortfolio -> createUserPortfolioComponent(componentContext) is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt index 1cc4c726fb..5369f159bc 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt @@ -305,7 +305,9 @@ internal class AddToPortfolioModel @Inject constructor( setupTokenActionsFlow(selectedPortfolioSnapshot, addedToken) .onEach { cryptoCurrencyData -> tokenActionsData.emit(cryptoCurrencyData) - navigation.replaceAll(AddToPortfolioRoutes.TokenActions) + navigation.replaceAll( + AddToPortfolioRoutes.TokenActions(cryptoCurrencyData.status.currency.name), + ) } .onEmpty { finishSuccessFlow(result) } .launchIn(this) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRouteUiSpec.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRouteUiSpec.kt index 68d861ed3a..4a2311cea0 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRouteUiSpec.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRouteUiSpec.kt @@ -2,6 +2,7 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.model import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.features.commonfeatures.impl.R internal data class AddToPortfolioRouteUiSpec( @@ -42,8 +43,8 @@ internal fun AddToPortfolioRoutes.uiSpec(): AddToPortfolioRouteUiSpec = when (th shouldApplyHorizontalPadding = false, footer = AddToPortfolioFooterKind.UserPortfolioAdd, ) - AddToPortfolioRoutes.TokenActions -> AddToPortfolioRouteUiSpec( - title = resourceReference(R.string.common_get_token), + is AddToPortfolioRoutes.TokenActions -> AddToPortfolioRouteUiSpec( + title = resourceReference(R.string.get_token_title, wrappedList(currencyName)), isScrollable = false, shouldApplyHorizontalPadding = true, footer = AddToPortfolioFooterKind.None, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt index 8c01391c47..80e3cf7632 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt @@ -27,5 +27,5 @@ internal sealed interface AddToPortfolioRoutes : Route { data object UserPortfolio : AddToPortfolioRoutes @Serializable - data object TokenActions : AddToPortfolioRoutes + data class TokenActions(val currencyName: String) : AddToPortfolioRoutes } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt index 7b7facbc0e..871aa665ed 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt @@ -17,18 +17,23 @@ import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio import com.tangem.features.commonfeatures.impl.R import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenUiBuilder.Companion.toggleProgress +import com.tangem.features.wallet.utils.UserWalletImageFetcher import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.operations.attestation.ArtworkSize import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @ModelScoped @Suppress("LongParameterList") +@OptIn(ExperimentalCoroutinesApi::class) internal class AddTokenModel @Inject constructor( paramsContainer: ParamsContainer, + private val walletImageFetcher: UserWalletImageFetcher, private val uiBuilder: AddTokenUiBuilder, private val messageSender: UiMessageSender, private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, @@ -46,15 +51,22 @@ internal class AddTokenModel @Inject constructor( field = MutableStateFlow(value = null) init { + val walletImageFlow = params.selectedPortfolio + .map { it.userWallet } + .distinctUntilChanged() + .flatMapLatest { walletImageFetcher.walletImage(it, ArtworkSize.SMALL) } + combine( flow = params.selectedNetwork.distinctUntilChanged(), flow2 = params.selectedPortfolio.distinctUntilChanged(), - transform = { selectedNetwork, selectedPortfolio -> + flow3 = walletImageFlow, + transform = { selectedNetwork, selectedPortfolio, walletImage -> addTokenJob.join() val isTangemIconVisible = needColdWalletInteraction(selectedNetwork, selectedPortfolio) uiBuilder.updateContent( selectedPortfolio = selectedPortfolio, selectedNetwork = selectedNetwork, + walletImage = walletImage, isTangemIconVisible = isTangemIconVisible, onConfirmClick = { onAddClick(selectedNetwork, selectedPortfolio).saveIn(addTokenJob) }, ) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt index fc6d0e2afd..662e9f6754 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt @@ -5,6 +5,7 @@ import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.PortfolioSelectUM import com.tangem.common.ui.account.toUM import com.tangem.common.ui.addtoken.AddTokenUM +import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.ParamsContainer import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter @@ -35,13 +36,18 @@ internal class AddTokenUiBuilder @Inject constructor( ) } - private fun createPortfolio(selectedPortfolio: SelectedPortfolio): PortfolioSelectUM { + private fun createPortfolio( + selectedPortfolio: SelectedPortfolio, + walletImage: UserWalletItemUM.ImageState, + ): PortfolioSelectUM { val accountIcon: AccountIconUM? val portfolioName: TextReference + val imageState: UserWalletItemUM.ImageState? when (selectedPortfolio.isAccountMode) { false -> { accountIcon = null portfolioName = stringReference(selectedPortfolio.userWallet.name) + imageState = walletImage } true -> { val accountStatus = selectedPortfolio.account.account @@ -50,6 +56,7 @@ internal class AddTokenUiBuilder @Inject constructor( is CryptoPortfolio -> CryptoPortfolioIconConverter.convert(accountStatus.account.icon) is Payment -> AccountIconUM.Payment } + imageState = null } } return PortfolioSelectUM( @@ -58,12 +65,14 @@ internal class AddTokenUiBuilder @Inject constructor( isAccountMode = selectedPortfolio.isAccountMode, isMultiChoice = selectedPortfolio.isAvailableMorePortfolio, onClick = { params.callbacks.onChangePortfolioClick() }, + imageState = imageState, ) } fun updateContent( selectedPortfolio: SelectedPortfolio, selectedNetwork: SelectedNetwork, + walletImage: UserWalletItemUM.ImageState, isTangemIconVisible: Boolean, onConfirmClick: () -> Unit, ): AddTokenUM { @@ -78,7 +87,7 @@ internal class AddTokenUiBuilder @Inject constructor( onConfirmClick = onConfirmClick, ) val networkUM = createNetwork(selectedNetwork) - val portfolioUM = createPortfolio(selectedPortfolio) + val portfolioUM = createPortfolio(selectedPortfolio, walletImage) val currency = selectedNetwork.cryptoCurrency val tokenToAdd = TokenItemState.Content( id = currency.id.value, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt index 3ca744cb50..84238b8387 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt @@ -8,7 +8,6 @@ import com.tangem.common.ui.tokens.TokenItemGrouping.toGroupedItems import com.tangem.common.ui.tokens.TokenItemGrouping.toUngroupedItems import com.tangem.common.ui.tokens.TokenItemStateConverter import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.icons.IconTint import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM @@ -55,12 +54,11 @@ internal class ChooseTokenListItemConverter( } private val fiatAmountStateProvider: ((TotalFiatBalance, isExpanded: Boolean) -> FiatAmountState?) = - { totalBalance, isExpanded -> - when { - isSearchingState -> FiatAmountState.Empty - !isExpanded -> FiatAmountState.Icon(R.drawable.ic_chewron_down_20, IconTint.Informative) - else -> AccountCryptoPortfolioItemStateConverter - .createFiatAmountState(totalBalance, appCurrency) + { totalBalance, _ -> + if (isSearchingState) { + FiatAmountState.Empty + } else { + AccountCryptoPortfolioItemStateConverter.createFiatAmountState(totalBalance, appCurrency) } } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt index 5e480142ac..926aeecb47 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt @@ -16,13 +16,9 @@ internal enum class SwapMarketCategory( val title: TextReference, val order: TokenMarketListConfig.Order, ) { - Trending( - title = resourceReference(R.string.markets_sort_by_trending_title), - order = TokenMarketListConfig.Order.Trending, - ), - ExperiencedBuyers( - title = resourceReference(R.string.markets_sort_by_experienced_buyers_title), - order = TokenMarketListConfig.Order.Buyers, + MarketCap( + title = resourceReference(R.string.markets_sort_by_rating_title), + order = TokenMarketListConfig.Order.ByRating, ), TopGainers( title = resourceReference(R.string.markets_sort_by_top_gainers_title), diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt index 15fffa4ab4..b5f33f004b 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt @@ -51,7 +51,7 @@ internal class MarketBlockDelegate @AssistedInject constructor( private val visibleMarketItemIds = MutableStateFlow>(emptyList()) private val visibleDefaultMarketItemIds = MutableStateFlow>(emptyList()) - private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.Trending) + private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.MarketCap) val addToPortfolioSlot: SlotNavigation = SlotNavigation() val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt index a9af5504aa..65166caad5 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt @@ -1,5 +1,16 @@ package com.tangem.features.commonfeatures.impl.choosetoken.ui +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +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.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -7,6 +18,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -15,16 +27,21 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.lerp import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import com.tangem.common.ui.tokens.portfolioTokensList +import com.tangem.common.ui.tokens.NonContentItemContent +import com.tangem.common.ui.tokens.SlideInItemVisibility import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.account.toBoxSize import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.fields.SearchBar @@ -34,6 +51,8 @@ import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.token.AccountItemPreviewData import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.tokenlist.NON_CONTENT_TOKENS_LIST_KEY +import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem import com.tangem.core.ui.components.tokenlist.TokenListItem import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM @@ -41,26 +60,42 @@ import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.ds.image.TangemDeviceIcon +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.internal.TangemRowTailUM +import com.tangem.core.ui.ds.row.token.TangemTokenRow +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.ds.row.token.internal.TokenRowTitle import com.tangem.core.ui.extensions.* 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.BuyTokenScreenTestTags +import com.tangem.core.ui.utils.ProvideSharedTransitionScope import com.tangem.core.ui.utils.TangemSharedTransitionLayout import com.tangem.core.ui.utils.lazyListItemPosition import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection +import com.tangem.core.ui.utils.sharedBoundsSafely import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM import com.tangem.features.commonfeatures.impl.R import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState +import com.tangem.utils.StringsSigns.DOT import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlin.random.Random private const val LOAD_MORE_BUFFER = 25 +private const val ACCOUNT_COLLAPSE_STEP_MS = 50 +private const val ACCOUNT_COLLAPSE_MAX_DELAY_MS = 250 +private const val ACCOUNT_COLLAPSE_BASE_DELAY_MS = 150 +private const val ACCOUNT_CONTENT_ANIM_MS = 350 +private const val ACCOUNT_CONTENT_ANIM_DELAY_MS = 90 +private const val ACCOUNT_BOUNDS_ANIM_MS = 250 private val ChooseTokenFullUM.isNotFoundState: Boolean get() { @@ -298,11 +333,10 @@ private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBal when (tokensListData) { is TokenListUMData.AccountList -> { tokensListData.tokensList.forEachIndexed { index, item -> - portfolioTokensList( + accountWithTokens( portfolio = item, portfolioIndex = index, isBalanceHidden = isBalanceHidden, - testTag = BuyTokenScreenTestTags.LAZY_LIST_ITEM, ) } } @@ -338,6 +372,298 @@ private fun LazyListScope.tokensList(items: ImmutableList, isB ) } +@Suppress("LongMethod") +private fun LazyListScope.accountWithTokens( + portfolio: TokensListItemUM.Portfolio, + portfolioIndex: Int, + isBalanceHidden: Boolean, +) { + val tokens = portfolio.tokens + val isExpanded = portfolio.isExpanded + val lastIndex = maxOf(tokens.lastIndex.inc(), 1) + + item(key = "account-${portfolio.id}", contentType = "choose-token-account") { + val effectiveLastIndex by animateIntAsState( + targetValue = if (isExpanded) lastIndex else 0, + animationSpec = if (isExpanded) { + snap() + } else { + snap( + delayMillis = minOf( + ACCOUNT_COLLAPSE_STEP_MS * maxOf(tokens.lastIndex, 0), + ACCOUNT_COLLAPSE_MAX_DELAY_MS, + ) + ACCOUNT_COLLAPSE_BASE_DELAY_MS, + ) + }, + label = "accountLastIndex", + ) + AccountRow( + portfolio = portfolio, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) + .semantics { lazyListItemPosition = portfolioIndex } + .roundedShapeItemDecoration( + currentIndex = 0, + radius = TangemTheme.dimens.radius14, + lastIndex = effectiveLastIndex, + backgroundColor = TangemTheme.colors.background.primary, + ), + ) + } + + if (portfolio.content is PortfolioItemContentUM.Empty) { + item(key = "$NON_CONTENT_TOKENS_LIST_KEY account-${portfolio.id}") { + SlideInItemVisibility( + visible = isExpanded, + currentIndex = 1, + lastIndex = lastIndex, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = 1, + radius = TangemTheme.dimens.radius14, + lastIndex = 1, + backgroundColor = TangemTheme.colors.background.primary, + ), + ) { + NonContentItemContent(modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing16)) + } + } + return + } + + itemsIndexed( + items = tokens, + key = { _, token -> "${token.id}-choose-account-${portfolio.id}" }, + contentType = { _, token -> token::class.java }, + ) { tokenIndex, token -> + val indexWithHeader = tokenIndex.inc() + val lastTokenBottomPadding = TangemTheme.dimens.spacing8 + SlideInItemVisibility( + visible = isExpanded, + currentIndex = tokenIndex, + lastIndex = lastIndex, + modifier = Modifier + .testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) + .roundedShapeItemDecoration( + currentIndex = indexWithHeader, + radius = TangemTheme.dimens.radius14, + lastIndex = lastIndex, + backgroundColor = TangemTheme.colors.background.primary, + ), + ) { + PortfolioTokensListItem( + state = token, + isBalanceHidden = isBalanceHidden, + modifier = Modifier.conditional(indexWithHeader == lastIndex) { + padding(bottom = lastTokenBottomPadding) + }, + ) + } + } +} + +@Suppress("LongMethod") +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun AccountRow( + portfolio: TokensListItemUM.Portfolio, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + val tokenRowUM = accountTokenRowUM(portfolio) + val subtitle = (tokenRowUM.subtitleUM as? TangemTokenRowUM.SubtitleUM.Content)?.text + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + ProvideSharedTransitionScope(Modifier.weight(1f)) { + val iconSharedContentState = rememberSharedContentState(key = "account-icon-${portfolio.id}") + val titleSharedContentState = rememberSharedContentState(key = "account-title-${portfolio.id}") + val boundsTransform = BoundsTransform { _, _ -> tween(ACCOUNT_BOUNDS_ANIM_MS) } + + AnimatedContent( + targetState = portfolio.isExpanded, + transitionSpec = { + fadeIn(animationSpec = tween(ACCOUNT_CONTENT_ANIM_MS, delayMillis = ACCOUNT_CONTENT_ANIM_DELAY_MS)) + .togetherWith(fadeOut(animationSpec = tween(ACCOUNT_CONTENT_ANIM_MS))) + }, + label = "accountExpand", + ) { isExpandedState -> + val animatedContentScope = this + val composables = remember(tokenRowUM) { + AccountRowComposables( + icon = { iconModifier -> + val iconSize = if (isExpandedState) { + AccountIconSize.RedesignExtraSmall + } else { + AccountIconSize.RedesignedDefault + } + val sizedIcon = when (val icon = tokenRowUM.headIconUM) { + is TangemIconUM.Currency -> icon.copy( + currencyIconState = when (val iconState = icon.currencyIconState) { + is CurrencyIconState.CryptoPortfolio.Icon -> iconState.copy(size = iconSize) + is CurrencyIconState.CryptoPortfolio.Letter -> iconState.copy(size = iconSize) + else -> iconState + }, + ) + else -> icon + } + TangemIcon( + tangemIconUM = sizedIcon, + modifier = iconModifier + .size(iconSize.toBoxSize()) + .sharedBoundsSafely( + sharedContentState = iconSharedContentState, + animatedVisibilityScope = animatedContentScope, + boundsTransform = boundsTransform, + ), + ) + }, + title = { titleModifier -> + val targetFraction = if (isExpandedState) 0f else 1f + val animationFraction = animateFloatAsState( + targetValue = targetFraction, + animationSpec = tween(durationMillis = ACCOUNT_CONTENT_ANIM_MS), + label = "accountTitle", + ) + val startStyle = TangemTheme.typography2.captionSemibold12 + val stopStyle = TangemTheme.typography2.bodySemibold16 + val textStyle by remember(animationFraction.value) { + derivedStateOf { lerp(startStyle, stopStyle, animationFraction.value) } + } + val resizedTitle = when (val titleUM = tokenRowUM.titleUM) { + is TangemTokenRowUM.TitleUM.Content -> titleUM.copy( + text = styledStringReference( + titleUM.text.resolveReference(), + { textStyle.toSpanStyle() }, + ), + ) + else -> titleUM + } + TokenRowTitle( + titleUM = resizedTitle, + modifier = titleModifier.sharedBoundsSafely( + sharedContentState = titleSharedContentState, + animatedVisibilityScope = animatedContentScope, + boundsTransform = boundsTransform, + resizeMode = scaleToBounds(ContentScale.Fit, Alignment.CenterStart), + ), + ) + }, + ) + } + + if (isExpandedState) { + TangemHeaderRow( + subtitle = subtitle, + isBalanceHidden = isBalanceHidden, + titleContent = composables.title, + headContent = composables.icon, + tailUM = TangemRowTailUM.Empty, + onItemClick = tokenRowUM.onItemClick, + ) + } else { + TangemTokenRow( + tokenRowUM = tokenRowUM, + isBalanceHidden = isBalanceHidden, + headComponent = composables.icon, + titleComponent = composables.title, + ) + } + } + } + AccountTail( + isExpanded = portfolio.isExpanded, + onClick = { tokenRowUM.onItemClick?.invoke() }, + ) + } +} + +@Composable +private fun AccountTail(isExpanded: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .padding(start = TangemTheme.dimens2.x2, end = TangemTheme.dimens2.x4) + .size(TangemTheme.dimens2.x9) + .clip(CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + AnimatedContent( + targetState = isExpanded, + contentAlignment = Alignment.Center, + label = "accountTail", + ) { expanded -> + if (expanded) { + Icon( + modifier = Modifier + .offset(x = TangemTheme.dimens.spacing2) + .size(TangemTheme.dimens2.x4), + painter = painterResource(id = R.drawable.ic_minimize_24), + tint = TangemTheme.colors2.graphic.neutral.tertiaryConstant, + contentDescription = null, + ) + } else { + AccountExpandButton() + } + } + } +} + +@Composable +private fun AccountExpandButton(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(TangemTheme.dimens2.x9) + .clip(CircleShape) + .background(TangemTheme.colors2.button.backgroundSecondary), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x5), + painter = painterResource(id = R.drawable.ic_chewron_down_20), + tint = TangemTheme.colors2.button.iconPrimary, + contentDescription = null, + ) + } +} + +@Stable +private class AccountRowComposables( + val title: @Composable (Modifier) -> Unit, + val icon: @Composable (Modifier) -> Unit, +) + +private fun accountTokenRowUM(portfolio: TokensListItemUM.Portfolio): TangemTokenRowUM.Content { + val account = portfolio.tokenItemUM + val name = (account.titleState as? TokenItemState.TitleState.Content)?.text ?: TextReference.EMPTY + val tokensCount = (account.subtitleState as? TokenItemState.SubtitleState.TextContent)?.value + val balance = (account.fiatAmountState as? TokenItemState.FiatAmountState.Content)?.text + return TangemTokenRowUM.Content( + id = portfolio.id, + headIconUM = TangemIconUM.Currency(currencyIconState = account.iconState), + titleUM = TangemTokenRowUM.TitleUM.Content(text = name), + subtitleUM = buildAccountSubtitle(tokensCount, balance) + ?.let { TangemTokenRowUM.SubtitleUM.Content(text = it) } + ?: TangemTokenRowUM.SubtitleUM.Empty, + topEndContentUM = TangemTokenRowUM.EndContentUM.Empty, + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Empty, + tailUM = TangemRowTailUM.Empty, + onItemClick = { account.onItemClick?.invoke(account) }, + onItemLongClick = null, + ) +} + +private fun buildAccountSubtitle(tokensCount: TextReference?, balance: String?): TextReference? { + return when { + tokensCount != null && balance != null -> + combinedReference(tokensCount, stringReference(" $DOT "), stringReference(balance)) + tokensCount != null -> tokensCount + balance != null -> stringReference(balance) + else -> null + } +} + private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) { item("EmptyTokensList") { Box( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt index 8354a085f3..b2ae5d9dda 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt @@ -30,7 +30,6 @@ import com.tangem.common.ui.markets.action.TokenActionsContext import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent import com.tangem.features.commonfeatures.impl.R -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -42,15 +41,12 @@ internal class DefaultManageFundsComponent @AssistedInject constructor( chooseTokenComponentFactory: ChooseTokenComponent.Factory, tokenActionsComponentFactory: TokenActionsComponent.Factory, userPortfolioComponentFactory: UserPortfolioComponent.Factory, - walletFeatureToggles: WalletFeatureToggles, ) : AppComponentContext by appComponentContext, ManageFundsComponent { private val model: ManageFundsModel = getOrCreateModel(params) private val isCompactTokenActions: Boolean = params.launchMode is ManageFundsComponent.LaunchMode.TokenActionsOnly - private val isAddFundsStage1Enabled: Boolean = walletFeatureToggles.isAddFundsStage1Enabled - private val tokenActionsComponent: TokenActionsComponent by lazy { tokenActionsComponentFactory.create( context = child(key = "manageFundsTokenActions"), @@ -107,7 +103,7 @@ internal class DefaultManageFundsComponent @AssistedInject constructor( ) } - WithOptionalRedesignTheme(isEnabled = isAddFundsStage1Enabled) { + TangemThemeRedesign { TangemBottomSheet( onBack = if (canGoBack) model::onBack else ::dismiss, config = TangemBottomSheetConfig( @@ -179,7 +175,7 @@ internal class DefaultManageFundsComponent @AssistedInject constructor( ) { userPortfolioComponent.Content(modifier) } - ManageFundsModel.UiRoute.TokenActions -> tokenActionsComponent.Content(modifier) + is ManageFundsModel.UiRoute.TokenActions -> tokenActionsComponent.Content(modifier) } } @@ -191,8 +187,13 @@ internal class DefaultManageFundsComponent @AssistedInject constructor( onCloseClick: () -> Unit, ) { val spec = route.uiSpec(model.flowType) + val title = if (route is ManageFundsModel.UiRoute.TokenActions) { + route.title + } else { + spec.title + } TangemTopBar( - title = spec.title, + title = title, subtitle = spec.subtitle, type = TangemTopBarType.BottomSheet, startContent = if (canGoBack) { @@ -219,15 +220,6 @@ internal class DefaultManageFundsComponent @AssistedInject constructor( ) } - @Composable - private fun WithOptionalRedesignTheme(isEnabled: Boolean, content: @Composable () -> Unit) { - if (isEnabled) { - TangemThemeRedesign(content = content) - } else { - content() - } - } - @AssistedFactory interface Factory : ManageFundsComponent.Factory { override fun create( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/analytics/ManageFundsAnalyticsEvent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/analytics/ManageFundsAnalyticsEvent.kt index 19c26683b6..e59d62bbe1 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/analytics/ManageFundsAnalyticsEvent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/analytics/ManageFundsAnalyticsEvent.kt @@ -8,9 +8,9 @@ internal sealed class ManageFundsAnalyticsEvent( params: Map = emptyMap(), ) : AnalyticsEvent(category = CATEGORY, event = event, params = params) { - class MethodScreenOpened(source: String) : ManageFundsAnalyticsEvent( + class MethodScreenOpened(source: AnalyticsParam.ScreensSources) : ManageFundsAnalyticsEvent( event = "Method Screen Opened", - params = mapOf(AnalyticsParam.SOURCE to source), + params = mapOf(AnalyticsParam.SOURCE to source.value), ) class ButtonBuy : ManageFundsAnalyticsEvent(event = "Button - Buy") @@ -21,6 +21,5 @@ internal sealed class ManageFundsAnalyticsEvent( companion object { private const val CATEGORY = "Add Funds" - const val SOURCE_MAIN_SCREEN = "Main Screen" } } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsModel.kt index 71f307963d..94b88e2469 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsModel.kt @@ -7,9 +7,14 @@ import com.tangem.common.routing.AppRouter import com.tangem.common.ui.markets.action.CryptoCurrencyData import com.tangem.common.ui.markets.action.TokenActionsBSContentUM import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.event.TransferAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase @@ -26,6 +31,7 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPa import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.commonfeatures.api.tokenactions.BottomAction +import com.tangem.features.commonfeatures.impl.R import com.tangem.features.commonfeatures.impl.managefunds.analytics.ManageFundsAnalyticsEvent import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent import com.tangem.features.commonfeatures.impl.userportfolio.state.UserPortfolioStateController @@ -146,11 +152,20 @@ internal class ManageFundsModel @Inject constructor( } override fun onQuickActionClick(action: TokenActionsBSContentUM.Action, shouldDismiss: Boolean) { - val event = when (action) { - TokenActionsBSContentUM.Action.Buy -> ManageFundsAnalyticsEvent.ButtonBuy() - TokenActionsBSContentUM.Action.Exchange -> ManageFundsAnalyticsEvent.ButtonSwap() - TokenActionsBSContentUM.Action.Receive -> ManageFundsAnalyticsEvent.ButtonReceive() - else -> null + val event = when (flowType) { + ManageFundsComponent.FlowType.AddFunds -> when (action) { + TokenActionsBSContentUM.Action.Buy -> ManageFundsAnalyticsEvent.ButtonBuy() + TokenActionsBSContentUM.Action.Exchange -> ManageFundsAnalyticsEvent.ButtonSwap() + TokenActionsBSContentUM.Action.Receive -> ManageFundsAnalyticsEvent.ButtonReceive() + else -> null + } + ManageFundsComponent.FlowType.Transfer -> when (action) { + TokenActionsBSContentUM.Action.Send -> TransferAnalyticsEvent.ButtonSend() + TokenActionsBSContentUM.Action.Exchange -> TransferAnalyticsEvent.ButtonSwap() + TokenActionsBSContentUM.Action.SendWithSwap -> TransferAnalyticsEvent.ButtonSwapAndSend() + TokenActionsBSContentUM.Action.Sell -> TransferAnalyticsEvent.ButtonSell() + else -> null + } } event?.let { analyticsEventHandler.send(it) } if (shouldDismiss) { @@ -174,9 +189,7 @@ internal class ManageFundsModel @Inject constructor( private fun initChooseToken(mode: ManageFundsComponent.LaunchMode.ChooseToken) { chooseTokenBridge.selectWalletTab(mode.userWalletId) - analyticsEventHandler.send( - ManageFundsAnalyticsEvent.MethodScreenOpened(source = ManageFundsAnalyticsEvent.SOURCE_MAIN_SCREEN), - ) + sendMethodScreenOpenedEvent() replaceRoot(UiRoute.ChooseToken) modelScope.launch { chooseTokenBridge.onCurrencyChosen.receiveAsFlow().collect(::openTokenActionsFromBridge) @@ -205,20 +218,24 @@ internal class ManageFundsModel @Inject constructor( params.onDismiss() return@launch } + sendMethodScreenOpenedEvent() tokenActionsTrigger.value = TokenActionsRequest(wallet, match.first, match.second) - replaceRoot(UiRoute.TokenActions) + replaceRoot(tokenActionsRoute(match.second)) } } private fun initFilteredByRawId(mode: ManageFundsComponent.LaunchMode.FilteredByRawId) { modelScope.launch { val entries = collectFilteredEntries(mode.rawCurrencyId) + if (entries.isNotEmpty()) { + sendMethodScreenOpenedEvent() + } when (entries.size) { 0 -> params.onDismiss() 1 -> { val entry = entries.first() tokenActionsTrigger.value = TokenActionsRequest(entry.userWallet, entry.account, entry.status) - replaceRoot(UiRoute.TokenActions) + replaceRoot(tokenActionsRoute(entry.status)) } else -> { filteredEntries.value = entries @@ -246,6 +263,19 @@ internal class ManageFundsModel @Inject constructor( } } + private fun sendMethodScreenOpenedEvent() { + val source = when (launchMode) { + is ManageFundsComponent.LaunchMode.ChooseToken -> AnalyticsParam.ScreensSources.Main + is ManageFundsComponent.LaunchMode.TokenActionsOnly -> AnalyticsParam.ScreensSources.Token + is ManageFundsComponent.LaunchMode.FilteredByRawId -> AnalyticsParam.ScreensSources.Market + } + val event = when (flowType) { + ManageFundsComponent.FlowType.AddFunds -> ManageFundsAnalyticsEvent.MethodScreenOpened(source = source) + ManageFundsComponent.FlowType.Transfer -> TransferAnalyticsEvent.MethodScreenOpened(source = source) + } + analyticsEventHandler.send(event) + } + private fun openTokenActionsFromBridge(result: ChooseTokenResult) { val account = result.account as? AccountStatus.CryptoPortfolio ?: return openTokenActions( @@ -261,7 +291,12 @@ internal class ManageFundsModel @Inject constructor( private fun openTokenActions(request: TokenActionsRequest, bottomAction: BottomAction) { tokenActionsTrigger.value = request currentBottomAction.value = bottomAction - pushRoute(UiRoute.TokenActions) + pushRoute(tokenActionsRoute(request.status)) + } + + private fun tokenActionsRoute(status: CryptoCurrencyStatus): UiRoute.TokenActions { + val title = resourceReference(R.string.get_token_title, wrappedList(status.currency.name)) + return UiRoute.TokenActions(title = title) } private fun replaceRoot(route: UiRoute) { @@ -277,7 +312,7 @@ internal class ManageFundsModel @Inject constructor( data object Loading : UiRoute data object ChooseToken : UiRoute data object UserPortfolio : UiRoute - data object TokenActions : UiRoute + data class TokenActions(val title: TextReference) : UiRoute } private data class TokenActionsRequest( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsRouteUiSpec.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsRouteUiSpec.kt index 7fd98aecd9..a9911c0b98 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsRouteUiSpec.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsRouteUiSpec.kt @@ -33,7 +33,7 @@ internal fun ManageFundsModel.UiRoute.uiSpec(flowType: ManageFundsComponent.Flow shouldApplyHorizontalPadding = false, shouldFillHeight = false, ) - ManageFundsModel.UiRoute.TokenActions -> ManageFundsRouteUiSpec( + is ManageFundsModel.UiRoute.TokenActions -> ManageFundsRouteUiSpec( title = resourceReference(if (isTransfer) R.string.common_transfer else R.string.common_get_token), subtitle = null, shouldApplyHorizontalPadding = true, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/tokenactions/model/TokenActionsUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/tokenactions/model/TokenActionsUiBuilder.kt index cc1ec83458..e6ad94e41a 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/tokenactions/model/TokenActionsUiBuilder.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/tokenactions/model/TokenActionsUiBuilder.kt @@ -31,6 +31,7 @@ import com.tangem.features.commonfeatures.impl.R import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.wallets.usecase.GetWalletIconUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.PortfolioBadgeUM import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM @@ -42,6 +43,7 @@ internal class TokenActionsUiBuilder @Inject constructor( paramsContainer: ParamsContainer, private val designFeatureToggles: DesignFeatureToggles, private val getWalletIconUseCase: GetWalletIconUseCase, + private val getWalletsUseCase: GetWalletsUseCase, private val walletIconUMConverter: WalletIconUMConverter, ) { private val params = paramsContainer.require() @@ -145,38 +147,47 @@ internal class TokenActionsUiBuilder @Inject constructor( } private fun createPortfolioBadge(cryptoCurrencyData: CryptoCurrencyData): PortfolioBadgeUM { - return if (cryptoCurrencyData.isAccountMode) { - val icon = CryptoPortfolioIconConverter.convert(cryptoCurrencyData.account.account.icon) - val name = cryptoCurrencyData - .account - .account - .accountName - .toUM() - .value - PortfolioBadgeUM.Account( - badge = TangemBadgeUM( - text = name, - tangemIconUM = TangemIconUM.Icon( - iconRes = icon.value.getResId(), - tintReference = { icon.color.getUiColor() }, + return when { + cryptoCurrencyData.isAccountMode -> { + val icon = CryptoPortfolioIconConverter.convert(cryptoCurrencyData.account.account.icon) + val name = cryptoCurrencyData + .account + .account + .accountName + .toUM() + .value + PortfolioBadgeUM.Account( + badge = TangemBadgeUM( + text = name, + tangemIconUM = TangemIconUM.Icon( + iconRes = icon.value.getResId(), + tintReference = { icon.color.getUiColor() }, + ), + size = TangemBadgeSize.X6, + shape = TangemBadgeShape.Rounded, + iconPosition = TangemBadgeIconPosition.Start, + shouldRespectIconTint = true, ), - size = TangemBadgeSize.X6, - shape = TangemBadgeShape.Rounded, - iconPosition = TangemBadgeIconPosition.Start, - shouldRespectIconTint = true, - ), - ) - } else { - val userWallet = cryptoCurrencyData.userWallet - PortfolioBadgeUM.Wallet( - name = stringReference(userWallet.name), - deviceIcon = walletIconUMConverter.convert( - getWalletIconUseCase(cryptoCurrencyData.userWallet), - ), - ) + ) + } + isSingleWallet() -> PortfolioBadgeUM.None + else -> { + val userWallet = cryptoCurrencyData.userWallet + PortfolioBadgeUM.Wallet( + name = stringReference(userWallet.name), + deviceIcon = walletIconUMConverter.convert( + getWalletIconUseCase(cryptoCurrencyData.userWallet), + ), + ) + } } } + private fun isSingleWallet(): Boolean { + val count = runCatching { getWalletsUseCase.invokeSync().size }.getOrNull() ?: return false + return count <= 1 + } + private fun createSubtitle2State(status: CryptoCurrencyStatus): TokenItemState.Subtitle2State? { return when (status.value) { is CryptoCurrencyStatus.Loaded, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/tokenactions/ui/TokenActionsContentV2.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/tokenactions/ui/TokenActionsContentV2.kt index 20c555716f..bf4f21300b 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/tokenactions/ui/TokenActionsContentV2.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/tokenactions/ui/TokenActionsContentV2.kt @@ -127,14 +127,16 @@ private fun QuickActionsList(state: TokenActionsUM, modifier: Modifier = Modifie Modifier.testTag(TokenActionsTestTags.BUY_ACTION) else -> Modifier } + val isEnabled = actionUM !in state.quickActions.disabledActions TokenActionRow( modifier = actionModifier, iconRes = actionUM.icon, title = actionUM.title, description = actionUM.description, - onClick = { state.quickActions.onQuickActionClick(actionUM) }, + isEnabled = isEnabled, + onClick = { state.quickActions.onQuickActionClick(actionUM) }.takeIf { isEnabled }, onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) } - .takeIf { actionUM.isLongClickAvailable }, + .takeIf { actionUM.isLongClickAvailable && isEnabled }, ) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt index a185cc8ed4..efbc99093b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt @@ -76,7 +76,7 @@ internal class DefaultMarketsTokenDetailsComponent( } private val portfolioBlockComponent: PortfolioBlockComponent? = - if (designFeatureToggles.isRedesignEnabled) { + if (updatedParams.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled) { portfolioBlockComponentFactory.create( context = child("portfolio_block"), params = PortfolioBlockComponent.Params(token = updatedParams.token), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt index 22a447786c..77df065ac6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt @@ -35,6 +35,8 @@ import com.tangem.core.ui.ds.row.TangemRowContainer import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.ds2.fade.TangemFade import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign @@ -96,6 +98,7 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi @Composable private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = Modifier) { + val hapticManager = LocalHapticManager.current FloatingCard(modifier = modifier) { TangemRowContainer(modifier = Modifier.clickableSingle(onClick = state.onRowClick)) { Text( @@ -128,7 +131,10 @@ private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = M iconPosition = TangemButtonIconPosition.Start, shape = TangemButtonShape.Rounded, size = TangemButtonSize.X9, - onClick = state.onAddFundsClick, + onClick = { + hapticManager.perform(TangemHapticEffect.View.ContextClick) + state.onAddFundsClick() + }, ), ) @@ -144,7 +150,10 @@ private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = M ), shape = TangemButtonShape.Rounded, size = TangemButtonSize.X9, - onClick = state.onRowClick, + onClick = { + hapticManager.perform(TangemHapticEffect.View.ContextClick) + state.onRowClick() + }, ), ) } @@ -153,6 +162,7 @@ private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = M @Composable private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier = Modifier) { + val hapticManager = LocalHapticManager.current FloatingCard(modifier = modifier) { Row( modifier = Modifier @@ -189,7 +199,10 @@ private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier = text = resourceReference(R.string.common_add), shape = TangemButtonShape.Rounded, size = TangemButtonSize.X9, - onClick = state.onAddClick, + onClick = { + hapticManager.perform(TangemHapticEffect.View.ContextClick) + state.onAddClick() + }, ), ) } 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 a87365998c..e79b45966a 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 @@ -393,6 +393,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( } fun openAddFunds(rawCurrencyId: com.tangem.domain.models.currency.CryptoCurrency.RawID) { + analyticsEventHandler.send(analyticsEventBuilder.addFundsClicked()) addFundsSheetNavigation.activate(AddFundsSlotRoute(rawCurrencyId = rawCurrencyId)) } @@ -401,6 +402,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( AppRoute.CurrencyDetails( userWalletId = result.wallet.walletId, currency = result.addedCurrency.currency, + shouldShowMarketBlock = false, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/analytics/MarketDetailsAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/analytics/MarketDetailsAnalyticsEvent.kt index 1ae3f86fab..907705a88b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/analytics/MarketDetailsAnalyticsEvent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/analytics/MarketDetailsAnalyticsEvent.kt @@ -70,6 +70,11 @@ internal class MarketDetailsAnalyticsEvent( event = "Button - Share", params = mapOf("Token" to token.symbol), ) + + fun addFundsClicked() = MarketDetailsAnalyticsEvent( + event = "Button - Add Funds", + params = mapOf("Token" to token.symbol), + ) } enum class IntervalType(val source: String) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/Options.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/Options.kt index 104d4fef3f..a69b65bd57 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/Options.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/Options.kt @@ -19,6 +19,8 @@ import com.tangem.core.ui.ds.image.TangemIconUM 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.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.features.feed.impl.R @@ -117,6 +119,7 @@ private fun OptionsV2( modifier: Modifier = Modifier, ) { var isShowDropdownMenu by rememberSaveable { mutableStateOf(false) } + val hapticManager = LocalHapticManager.current val segmentItems = remember { persistentListOf( @@ -147,7 +150,10 @@ private fun OptionsV2( horizontalArrangement = Arrangement.SpaceBetween, ) { PrimaryInverseTangemButton( - onClick = { isShowDropdownMenu = true }, + onClick = { + hapticManager.perform(TangemHapticEffect.View.ContextClick) + isShowDropdownMenu = true + }, iconPosition = RedesignTangemButtonIconPosition.End, tangemIconUM = TangemIconUM.Icon( iconRes = R.drawable.ic_chewron_down_20, diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt index 562dfeb547..e72a7e2d26 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt @@ -126,7 +126,7 @@ internal class ForYouTokenListConverter( headIconUM = TangemIconUM.Currency(CurrencyIconState.Empty()), titleUM = TangemTokenRowUM.TitleUM.Content(text = resourceReference(R.string.common_other)), subtitleUM = TangemTokenRowUM.SubtitleUM.Content( - text = pluralReference(R.plurals.common_assets, otherAssets.count()), + text = pluralReference(R.plurals.market_chart_assets_android, otherAssets.count()), ), topEndContentUM = TangemTokenRowUM.EndContentUM.Content( text = stringReference( diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt index a36e7bb702..4152f5b37b 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt @@ -149,7 +149,7 @@ internal class ForYouTokenListConverterTest { val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content assertThat(otherRow.id).isEqualTo("for_you_other_assets") val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_assets, count = 1)) + assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.market_chart_assets_android, count = 1)) } @Test @@ -172,7 +172,7 @@ internal class ForYouTokenListConverterTest { // Assert val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_assets, count = 3)) + assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.market_chart_assets_android, count = 3)) } @Test diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 69de92479e..60ccf215e6 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -85,4 +85,9 @@ dependencies { implementation(deps.arrow.core) implementation(deps.jodatime) implementation(deps.decompose.ext.compose) + implementation(deps.kotlin.immutable.collections) + + /** Tests */ + testImplementation(projects.test.core) + testImplementation(projects.common.test) } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampMainComponentUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampMainComponentUM.kt index b9e86bee09..07967d59c2 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampMainComponentUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampMainComponentUM.kt @@ -3,6 +3,7 @@ package com.tangem.features.onramp.main.entity import androidx.compose.runtime.Immutable import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.ds.message.TangemMessageUM import com.tangem.core.ui.extensions.TextReference @Immutable @@ -11,9 +12,12 @@ internal sealed interface OnrampMainComponentUM { val topBarConfig: OnrampMainTopBarUM val errorNotification: NotificationUM? + val buyNotSupportedMessage: TangemMessageUM? + data class InitialLoading( override val topBarConfig: OnrampMainTopBarUM, override val errorNotification: NotificationUM?, + override val buyNotSupportedMessage: TangemMessageUM? = null, ) : OnrampMainComponentUM data class Content( @@ -22,6 +26,7 @@ internal sealed interface OnrampMainComponentUM { val amountBlockState: OnrampAmountBlockUM, val offersBlockState: OnrampOffersBlockUM, val onrampAmountButtonUMState: OnrampAmountButtonUMState, + override val buyNotSupportedMessage: TangemMessageUM? = null, ) : OnrampMainComponentUM } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt index 46e69fe061..132bb8aa5b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt @@ -7,10 +7,12 @@ import androidx.compose.ui.text.input.KeyboardType import com.tangem.common.ui.amountScreen.models.AmountFieldModel import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.combinedReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.ds.message.TangemMessageIconPosition +import com.tangem.core.ui.ds.message.TangemMessageUM +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.onramp.model.OnrampCurrency import com.tangem.domain.onramp.model.error.OnrampError @@ -21,6 +23,7 @@ import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.main.entity.* import com.tangem.utils.Provider import java.math.BigDecimal +import com.tangem.core.ui.R as CoreUiR internal class OnrampStateFactory( private val currentStateProvider: Provider, @@ -120,6 +123,42 @@ internal class OnrampStateFactory( } } + fun getBuyNotSupportedState(state: OnrampMainComponentUM = currentStateProvider()): OnrampMainComponentUM { + val message = buildBuyNotSupportedMessage() + + return when (state) { + is OnrampMainComponentUM.Content -> state.copy( + buyNotSupportedMessage = message, + errorNotification = null, + offersBlockState = OnrampOffersBlockUM.Empty, + onrampAmountButtonUMState = OnrampAmountButtonUMState.None, + amountBlockState = state.amountBlockState.copy( + amountFieldModel = state.amountBlockState.amountFieldModel.copy(isError = true), + secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty, + ), + ) + is OnrampMainComponentUM.InitialLoading -> state.copy( + buyNotSupportedMessage = message, + errorNotification = null, + ) + } + } + + private fun buildBuyNotSupportedMessage(): TangemMessageUM = TangemMessageUM( + id = "buy_not_supported", + title = resourceReference( + id = R.string.onramp_token_is_not_supported_banner_title, + formatArgs = wrappedList(cryptoCurrency.name), + ), + subtitle = resourceReference(R.string.onramp_token_is_not_supported_banner_subtitle), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon( + iconRes = CoreUiR.drawable.ic_attention_default_24, + tintReference = { TangemTheme.colors2.graphic.status.attention }, + ), + iconPosition = TangemMessageIconPosition.Leading, + ) + private fun getNoPairsErrorState(): OnrampMainComponentUM { val state = currentStateProvider() val contentState = state as? OnrampMainComponentUM.Content ?: return state diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index 86098da81b..ba9858ba48 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.onramp.main.model import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -9,12 +10,15 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.components.fields.InputManager import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.onramp.* import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent import com.tangem.domain.onramp.model.OnrampAvailability +import com.tangem.domain.onramp.model.OnrampCountry import com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.OnrampQuote import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.onramp.main.OnrampMainComponent import com.tangem.features.onramp.main.entity.* @@ -23,6 +27,7 @@ import com.tangem.features.onramp.main.entity.factory.OnrampAmountStateFactory import com.tangem.features.onramp.main.entity.factory.OnrampOffersStateFactory import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory import com.tangem.features.onramp.utils.sendOnrampErrorEvent +import com.tangem.features.onramp.utils.sendProviderCalculatedEvent import com.tangem.features.onramp.utils.showDemoModeWarningIfNeeded import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -30,9 +35,9 @@ import com.tangem.utils.coroutines.PeriodicTask import com.tangem.utils.coroutines.SingleTaskScheduler import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.isNullOrZero +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") @@ -46,6 +51,7 @@ internal class OnrampMainComponentModel @Inject constructor( private val fetchQuotesUseCase: OnrampFetchQuotesUseCase, private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase, private val fetchPairsUseCase: OnrampFetchPairsUseCase, + private val rampStateManager: RampStateManager, private val amountInputManager: InputManager, private val getOnrampOffersUseCase: GetOnrampOffersUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, @@ -222,6 +228,8 @@ internal class OnrampMainComponentModel @Inject constructor( } private fun handleOnrampAvailability(availability: OnrampAvailability) { + // "Buy not supported" notification has priority over the residency flow. + if (state.value.buyNotSupportedMessage != null) return when (availability) { is OnrampAvailability.Available -> Unit is OnrampAvailability.ConfirmResidency, @@ -274,23 +282,30 @@ internal class OnrampMainComponentModel @Inject constructor( ifLeft = ::handleOnrampError, ifRight = { country -> if (country == null) return@onEach - state.update { prevState -> - when (prevState) { - is OnrampMainComponentUM.Content -> { - amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency) - } - is OnrampMainComponentUM.InitialLoading -> { - stateFactory.getReadyState(country.defaultCurrency, params.initialFiatAmount) - } - } + // Resolve token-level buy support BEFORE emitting any Content state, so an + // unsupported token never briefly shows an enabled amount field — otherwise it + // would grab focus and flash the keyboard before being disabled. + if (isTokenNotSupportedForBuy()) { + showBuyNotSupported(country) + } else { + state.update { prevState -> getCountryUpdatedState(prevState, country) } + updatePairsAndQuotes() } - updatePairsAndQuotes() }, ) } .launchIn(modelScope) } + private fun getCountryUpdatedState( + prevState: OnrampMainComponentUM, + country: OnrampCountry, + ): OnrampMainComponentUM = when (prevState) { + is OnrampMainComponentUM.Content -> amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency) + is OnrampMainComponentUM.InitialLoading -> + stateFactory.getReadyState(country.defaultCurrency, params.initialFiatAmount) + } + private fun subscribeToQuotesUpdate() { getOnrampQuotesUseCase.invoke() .conflate() @@ -316,6 +331,10 @@ internal class OnrampMainComponentModel @Inject constructor( state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) } } else -> { + analyticsEventHandler.sendProviderCalculatedEvent( + quotes = quotes, + tokenSymbol = params.cryptoCurrency.symbol, + ) state.update { prevState -> val resetState = amountStateFactory.getAmountSecondaryFieldResetState() if (prevState is OnrampMainComponentUM.Content && @@ -356,11 +375,43 @@ internal class OnrampMainComponentModel @Inject constructor( ) } + private suspend fun isTokenNotSupportedForBuy(): Boolean { + // Token-level "cannot be bought", independent of country: the asset is either flagged as + // not onrampable (BuyUnavailable) or absent from the express asset list (AssetNotFound). + // Transient express states (loading/unreachable) are NOT treated as "not supported". + val reason = rampStateManager.availableForBuy( + userWallet = userWallet, + cryptoCurrency = params.cryptoCurrency, + ) + return reason is ScenarioUnavailabilityReason.BuyUnavailable || + reason is ScenarioUnavailabilityReason.AssetNotFound + } + private fun handleOnrampError(onrampError: OnrampError) { TangemLogger.e(onrampError.toString()) state.update { stateFactory.getOnrampErrorState(onrampError) } } + private fun showBuyNotSupported(country: OnrampCountry) { + if (state.value.buyNotSupportedMessage != null) return + + analyticsEventHandler.send( + OnrampAnalyticsEvent.NoticeBuyNotSupported( + source = params.source, + tokenSymbol = params.cryptoCurrency.symbol, + blockchain = params.cryptoCurrency.network.name, + ), + ) + quotesTaskScheduler.cancelTask() + // "Not supported" has priority: hide the residency bottom sheet if it was already shown. + bottomSheetNavigation.dismiss() + // Emit the not-supported state in a single update built from the ready state, so the amount + // field never appears enabled first (no focus/keyboard flash). + state.update { prevState -> + stateFactory.getBuyNotSupportedState(getCountryUpdatedState(prevState, country)) + } + } + private fun sendOnrampQuotesErrorAnalytic(quotes: List) { quotes.forEach { errorState -> when (errorState) { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt index e0cedd00a0..5afa3dab84 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt @@ -125,7 +125,9 @@ private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: Strin ) LaunchedEffect(key1 = Unit) { - requester.requestFocus() + if (!amountField.isError) { + requester.requestFocus() + } } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampMainComponentContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampMainComponentContent.kt index 2b24ad2fb9..2c61db8413 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampMainComponentContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampMainComponentContent.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.ds.message.TangemMessage import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.WindowInsetsZero @@ -75,7 +76,7 @@ private fun InitialLoading(state: OnrampMainComponentUM.InitialLoading, modifier verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { OnrampAmountContentLoading() - if (state.errorNotification != null) Notification(config = state.errorNotification.config) + OnrampNotifications(state = state) } } @@ -134,6 +135,16 @@ private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = M OnrampOffersContent(state = state.offersBlockState) - if (state.errorNotification != null) Notification(config = state.errorNotification.config) + OnrampNotifications(state = state) + } +} + +@Composable +private fun OnrampNotifications(state: OnrampMainComponentUM, modifier: Modifier = Modifier) { + val buyNotSupportedMessage = state.buyNotSupportedMessage + val errorNotification = state.errorNotification + when { + buyNotSupportedMessage != null -> TangemMessage(messageUM = buyNotSupportedMessage, modifier = modifier) + errorNotification != null -> Notification(config = errorNotification.config, modifier = modifier) } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt index 500c36d02c..85a3666027 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt @@ -23,7 +23,6 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.tokens.GetAssetRequirementsUseCase -import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.tokenlist.entity.AccountAvailabilityUM @@ -270,9 +269,7 @@ internal class OnrampTokenListModel @Inject constructor( val isNotUnreachable = status.value !is CryptoCurrencyStatus.Unreachable val isAvailable = when (params.filterOperation) { - OnrampOperation.BUY -> { - isAvailableForBuy - } // unreachable state is available for Buy operation + OnrampOperation.BUY -> true OnrampOperation.SELL -> isNotUnreachable OnrampOperation.SWAP -> { isNotUnreachable && isAvailableForBuy @@ -295,12 +292,7 @@ internal class OnrampTokenListModel @Inject constructor( private suspend fun checkAvailabilityByOperation(status: CryptoCurrencyStatus): Boolean { return when (params.filterOperation) { - OnrampOperation.BUY -> { - rampStateManager.availableForBuy( - userWallet = userWallet, - cryptoCurrency = status.currency, - ).isAvailable() - } + OnrampOperation.BUY -> true OnrampOperation.SELL -> { rampStateManager.availableForSell( userWalletId = userWallet.walletId, @@ -318,8 +310,4 @@ internal class OnrampTokenListModel @Inject constructor( } } } - - private fun ScenarioUnavailabilityReason.isAvailable(): Boolean { - return this == ScenarioUnavailabilityReason.None - } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/utils/OnrampProviderCalculatedAnalyticsSender.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/utils/OnrampProviderCalculatedAnalyticsSender.kt new file mode 100644 index 0000000000..cd0f9f8480 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/utils/OnrampProviderCalculatedAnalyticsSender.kt @@ -0,0 +1,21 @@ +package com.tangem.features.onramp.utils + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent +import com.tangem.domain.onramp.model.OnrampQuote + +internal fun AnalyticsEventHandler.sendProviderCalculatedEvent(quotes: List, tokenSymbol: String) { + val quote = quotes.findBestRateQuote() ?: return + + send( + OnrampAnalyticsEvent.ProviderCalculated( + providerName = quote.provider.info.name, + tokenSymbol = tokenSymbol, + paymentMethod = quote.paymentMethod.name, + ), + ) +} + +private fun List.findBestRateQuote(): OnrampQuote.Data? { + return filterIsInstance().maxByOrNull { it.toAmount.value } +} \ No newline at end of file diff --git a/features/onramp/impl/src/test/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactoryTest.kt b/features/onramp/impl/src/test/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactoryTest.kt new file mode 100644 index 0000000000..e5beb49d45 --- /dev/null +++ b/features/onramp/impl/src/test/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactoryTest.kt @@ -0,0 +1,95 @@ +package com.tangem.features.onramp.main.entity.factory + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.onramp.model.OnrampCurrency +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.main.entity.OnrampAmountButtonUMState +import com.tangem.features.onramp.main.entity.OnrampMainComponentUM +import com.tangem.features.onramp.main.entity.OnrampOffersBlockUM +import com.tangem.features.onramp.main.entity.OnrampSecondaryFieldErrorUM +import com.tangem.utils.Provider +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +internal class OnrampStateFactoryTest { + + private lateinit var currentState: OnrampMainComponentUM + + private val cryptoCurrency = MockCryptoCurrencyFactory().ethereum + + private val factory = OnrampStateFactory( + currentStateProvider = Provider { currentState }, + onrampAmountButtonUMStateFactory = OnrampAmountButtonUMStateFactory(), + cryptoCurrency = cryptoCurrency, + onrampIntents = mockk(relaxed = true), + ) + + @Test + fun `GIVEN initial loading with error WHEN getBuyNotSupportedState THEN shows None message and clears error`() { + // Arrange + currentState = factory.getInitialState(currency = "BTC", onClose = {}, openSettings = {}) + .copy(errorNotification = mockk()) + + // Act + val result = factory.getBuyNotSupportedState() + + // Assert + val message = result.buyNotSupportedMessage + assertThat(message).isNotNull() + assertThat(message!!.messageEffect).isEqualTo(TangemMessageEffect.None) + assertThat(message.title).isEqualTo( + resourceReference( + id = R.string.onramp_token_is_not_supported_banner_title, + formatArgs = wrappedList(cryptoCurrency.name), + ), + ) + assertThat(message.subtitle).isEqualTo( + resourceReference(R.string.onramp_token_is_not_supported_banner_subtitle), + ) + assertThat(result.errorNotification).isNull() + } + + @Test + fun `GIVEN content with errors WHEN getBuyNotSupportedState THEN message has priority and other errors hidden`() { + // Arrange + currentState = factory.getInitialState(currency = "BTC", onClose = {}, openSettings = {}) + val content = factory.getReadyState(currency = USD_CURRENCY) as OnrampMainComponentUM.Content + currentState = content.copy( + errorNotification = mockk(), + offersBlockState = OnrampOffersBlockUM.Loading, + onrampAmountButtonUMState = OnrampAmountButtonUMState.Loaded(persistentListOf()), + amountBlockState = content.amountBlockState.copy( + amountFieldModel = content.amountBlockState.amountFieldModel.copy(isError = true), + secondaryFieldModel = OnrampSecondaryFieldErrorUM.Error(stringReference("error")), + ), + ) + + // Act + val result = factory.getBuyNotSupportedState() as OnrampMainComponentUM.Content + + // Assert + assertThat(result.buyNotSupportedMessage).isNotNull() + assertThat(result.errorNotification).isNull() + assertThat(result.offersBlockState).isEqualTo(OnrampOffersBlockUM.Empty) + assertThat(result.onrampAmountButtonUMState).isEqualTo(OnrampAmountButtonUMState.None) + assertThat(result.amountBlockState.secondaryFieldModel).isEqualTo(OnrampSecondaryFieldErrorUM.Empty) + // Amount input is locked (disabled via isError) — like the unsupported-country case. + assertThat(result.amountBlockState.amountFieldModel.isError).isTrue() + } + + private companion object { + val USD_CURRENCY = OnrampCurrency( + name = "US Dollar", + code = "USD", + image = null, + precision = 2, + unit = "$", + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/test/kotlin/com/tangem/features/onramp/utils/OnrampProviderCalculatedAnalyticsSenderTest.kt b/features/onramp/impl/src/test/kotlin/com/tangem/features/onramp/utils/OnrampProviderCalculatedAnalyticsSenderTest.kt new file mode 100644 index 0000000000..1e1cc15ee8 --- /dev/null +++ b/features/onramp/impl/src/test/kotlin/com/tangem/features/onramp/utils/OnrampProviderCalculatedAnalyticsSenderTest.kt @@ -0,0 +1,123 @@ +package com.tangem.features.onramp.utils + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent +import com.tangem.domain.onramp.model.OnrampQuote +import com.tangem.test.core.ProvideTestModels +import io.mockk.Called +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class OnrampProviderCalculatedAnalyticsSenderTest { + + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true) + + @BeforeEach + fun resetMocks() { + clearMocks(analyticsEventHandler) + } + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN quotes WHEN send THEN provider calculated sent for best-rate provider`(model: SelectionModel) { + // Act + analyticsEventHandler.sendProviderCalculatedEvent(quotes = model.quotes, tokenSymbol = TOKEN_SYMBOL) + + // Assert + verify(exactly = 1) { + analyticsEventHandler.send( + OnrampAnalyticsEvent.ProviderCalculated( + providerName = model.expectedProviderName, + tokenSymbol = TOKEN_SYMBOL, + paymentMethod = PAYMENT_METHOD, + ), + ) + } + } + + @Test + fun `GIVEN no quotes WHEN send THEN no event sent`() { + // Act + analyticsEventHandler.sendProviderCalculatedEvent(quotes = emptyList(), tokenSymbol = TOKEN_SYMBOL) + + // Assert + verify { analyticsEventHandler wasNot Called } + } + + @Test + fun `GIVEN only non-loaded quotes WHEN send THEN no event sent`() { + // Arrange + val quotes = listOf(mockk(), mockk()) + + // Act + analyticsEventHandler.sendProviderCalculatedEvent(quotes = quotes, tokenSymbol = TOKEN_SYMBOL) + + // Assert + verify { analyticsEventHandler wasNot Called } + } + + private fun provideTestModels() = listOf( + SelectionModel( + name = "highest-rate quote among several is selected", + quotes = listOf( + createQuote(providerName = "Low", rate = BigDecimal("100")), + createQuote(providerName = "High", rate = BigDecimal("120")), + createQuote(providerName = "Mid", rate = BigDecimal("90")), + ), + expectedProviderName = "High", + ), + SelectionModel( + name = "SEPA quote with lower rate is NOT prioritized, higher-rate quote wins", + quotes = listOf( + createQuote(providerName = "SepaLowerRate", rate = BigDecimal("100")), + createQuote(providerName = "CardHigherRate", rate = BigDecimal("105")), + ), + expectedProviderName = "CardHigherRate", + ), + SelectionModel( + name = "single loaded quote is selected", + quotes = listOf( + createQuote(providerName = "Single", rate = BigDecimal("100")), + ), + expectedProviderName = "Single", + ), + SelectionModel( + name = "best-rate loaded quote is selected even when error quotes are present", + quotes = listOf( + mockk(), + createQuote(providerName = "Loaded", rate = BigDecimal("100")), + mockk(), + ), + expectedProviderName = "Loaded", + ), + ) + + private fun createQuote(providerName: String, rate: BigDecimal): OnrampQuote.Data { + return mockk { + every { provider.info.name } returns providerName + every { paymentMethod.name } returns PAYMENT_METHOD + every { toAmount.value } returns rate + } + } + + internal data class SelectionModel( + val name: String, + val quotes: List, + val expectedProviderName: String, + ) { + override fun toString(): String = name + } + + private companion object { + const val TOKEN_SYMBOL = "BTC" + const val PAYMENT_METHOD = "Card" + } +} \ No newline at end of file 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 6d5caf7d6b..7074a6dfb0 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 @@ -57,9 +57,13 @@ 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.staking.toggles.StakingFeatureToggles import com.tangem.domain.tokens.* import com.tangem.domain.transaction.error.GetFeeError -import com.tangem.domain.transaction.usecase.* +import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase +import com.tangem.domain.transaction.usecase.GetAllowanceUseCase +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.approval.api.GiveApprovalComponent @@ -72,14 +76,11 @@ import com.tangem.features.staking.impl.presentation.state.* import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType import com.tangem.features.staking.impl.presentation.state.events.StakingAlertUM import com.tangem.features.staking.impl.presentation.state.events.StakingEventFactory -import com.tangem.features.staking.impl.presentation.state.helpers.GetEffectiveStakingFee -import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater -import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeLoader -import com.tangem.features.staking.impl.presentation.state.helpers.StakingOperationsFactory -import com.tangem.features.staking.impl.presentation.state.helpers.StakingTransactionSender +import com.tangem.features.staking.impl.presentation.state.helpers.* import com.tangem.features.staking.impl.presentation.state.transformers.* import com.tangem.features.staking.impl.presentation.state.transformers.amount.* -import com.tangem.features.staking.impl.presentation.state.transformers.approval.* +import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalInProgressTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetConfirmationStateAssentApprovalTransformer import com.tangem.features.staking.impl.presentation.state.transformers.confirmation.SetUpdatedAllowanceTransformer import com.tangem.features.staking.impl.presentation.state.transformers.notifications.AddStakingNotificationsTransformer import com.tangem.features.staking.impl.presentation.state.transformers.notifications.DismissStakingNotificationsStateTransformer @@ -151,6 +152,7 @@ internal class StakingModel @Inject constructor( private val coroutineScope: AppCoroutineScope, private val innerRouter: InnerStakingRouter, private val messageSender: UiMessageSender, + private val stakingFeatureToggles: StakingFeatureToggles, appRouter: AppRouter, ) : Model(), StakingClickIntents { @@ -374,6 +376,8 @@ internal class StakingModel @Inject constructor( minimumTransactionAmount = minimumTransactionAmount, actionType = uiState.value.actionType, integration = integration, + isSolanaUnstakeValidationEnabled = stakingFeatureToggles + .isSolanaUnstakeValidationEnabled(), ) addAll( @@ -636,6 +640,7 @@ internal class StakingModel @Inject constructor( minimumTransactionAmount = minimumTransactionAmount, value = value, integration = integration, + isSolanaUnstakeValidationEnabled = stakingFeatureToggles.isSolanaUnstakeValidationEnabled(), ), ) checkSumLimitExceeded() @@ -677,6 +682,7 @@ internal class StakingModel @Inject constructor( minimumTransactionAmount = minimumTransactionAmount, actionType = uiState.value.actionType, integration = integration, + isSolanaUnstakeValidationEnabled = stakingFeatureToggles.isSolanaUnstakeValidationEnabled(), ), ) checkSumLimitExceeded() @@ -1359,6 +1365,7 @@ internal class StakingModel @Inject constructor( value = amountValue, minimumTransactionAmount = minimumTransactionAmount, integration = integration, + isSolanaUnstakeValidationEnabled = stakingFeatureToggles.isSolanaUnstakeValidationEnabled(), ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt index 9c9c0d1595..7dd219e1b3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt @@ -14,6 +14,7 @@ internal class AmountChangeStateTransformer( private val minimumTransactionAmount: EnterAmountBoundary?, private val value: String, private val integration: StakingIntegration, + private val isSolanaUnstakeValidationEnabled: Boolean, ) : Transformer { private val maxEnterAmountConverter = MaxEnterAmountConverter() @@ -43,6 +44,7 @@ internal class AmountChangeStateTransformer( maxAmount = maxEnterAmount, integration = integration, actionType = prevState.actionType, + isSolanaUnstakeValidationEnabled = isSolanaUnstakeValidationEnabled, ).transform(updatedAmountState), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt index 94476c6be9..0e250e8b3c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt @@ -14,6 +14,7 @@ internal class AmountMaxValueStateTransformer( private val minimumTransactionAmount: EnterAmountBoundary?, private val actionType: StakingActionCommonType, private val integration: StakingIntegration, + private val isSolanaUnstakeValidationEnabled: Boolean, ) : Transformer { private val maxEnterAmountConverter = MaxEnterAmountConverter() @@ -40,6 +41,7 @@ internal class AmountMaxValueStateTransformer( cryptoCurrencyStatus = cryptoCurrencyStatus, integration = integration, actionType = prevState.actionType, + isSolanaUnstakeValidationEnabled = isSolanaUnstakeValidationEnabled, ).transform(updatedAmountState), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt index 05a795e7cf..8874243fb5 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt @@ -16,6 +16,7 @@ import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.common.StakingAmountRequirement import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.R +import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.lib.crypto.BlockchainUtils.isTron import com.tangem.utils.extensions.isPositive import com.tangem.utils.isNullOrZero @@ -28,6 +29,7 @@ internal class AmountRequirementStateTransformer( private val maxAmount: EnterAmountBoundary, private val integration: StakingIntegration, private val actionType: StakingActionCommonType, + private val isSolanaUnstakeValidationEnabled: Boolean = false, ) : Transformer { override fun transform(prevState: AmountState): AmountState { return if (prevState is AmountState.Data) { @@ -103,11 +105,17 @@ internal class AmountRequirementStateTransformer( ) } is StakingActionCommonType.Exit -> { - integration.exitArgs?.amountRequirement?.getError( - amount = amountDecimal, - minErrorRes = R.string.staking_unstake_amount_requirement_error, - maxErrorRes = R.string.staking_max_amount_requirement_error, - ) + if (isSolanaUnstakeValidationEnabled && + isSolana(cryptoCurrencyStatus.currency.network.rawId) + ) { + getSolanaUnstakeError(amount = amountDecimal, staked = maxAmount.amount) + } else { + integration.exitArgs?.amountRequirement?.getError( + amount = amountDecimal, + minErrorRes = R.string.staking_unstake_amount_requirement_error, + maxErrorRes = R.string.staking_max_amount_requirement_error, + ) + } } else -> null } @@ -124,6 +132,29 @@ internal class AmountRequirementStateTransformer( return isEnterOrExit && isTron && !isIntegerOnly } + private fun getSolanaUnstakeError(amount: BigDecimal, staked: BigDecimal?): TextReference? { + val minimum = integration.exitMinimumAmount?.takeIf { it.isPositive() } + ?: integration.enterMinimumAmount + if (minimum == null || staked == null) return null + + // Full unstake is always allowed regardless of minimum delegation. + if (amount.compareTo(staked) == 0) return null + + if (amount < minimum) { + val formatted = minimum.format { crypto(cryptoCurrencyStatus.currency) } + return resourceReference( + R.string.staking_unstake_amount_requirement_error, + wrappedList(formatted), + ) + } + + if (staked - amount < minimum) { + return resourceReference(R.string.staking_notification_low_staked_balance_text) + } + + return null + } + private fun StakingAmountRequirement.getError( amount: BigDecimal, @StringRes minErrorRes: Int, diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt index cc4c746a1f..189a86477d 100644 --- a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt @@ -29,6 +29,7 @@ import com.tangem.domain.staking.* import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.tokens.* import com.tangem.domain.transaction.usecase.* import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -58,11 +59,12 @@ internal abstract class StakingModelTestBase { protected val testUserWalletId = UserWalletId("1234567890ABCDEF") protected val testCryptoCurrency: CryptoCurrency = mockk(relaxed = true) protected open val testIntegrationId: StakingIntegrationID = StakingIntegrationID.StakeKit.Coin.Solana - private val testParams get() = StakingComponent.Params( - userWalletId = testUserWalletId, - cryptoCurrency = testCryptoCurrency, - integrationId = testIntegrationId, - ) + private val testParams + get() = StakingComponent.Params( + userWalletId = testUserWalletId, + cryptoCurrency = testCryptoCurrency, + integrationId = testIntegrationId, + ) protected val testYield: Yield = mockk(relaxed = true) protected val testUserWallet: UserWallet = mockk(relaxed = true) protected val initialUiState: StakingUiState = mockk(relaxed = true) { @@ -112,6 +114,9 @@ internal abstract class StakingModelTestBase { private val coroutineScope: AppCoroutineScope = mockk() protected val innerRouter: InnerStakingRouter = mockk() protected val messageSender: UiMessageSender = mockk() + protected val stakingFeatureToggles: StakingFeatureToggles = mockk { + every { isSolanaUnstakeValidationEnabled() } returns false + } @BeforeEach fun setUp() { @@ -203,6 +208,7 @@ internal abstract class StakingModelTestBase { coroutineScope = coroutineScope, innerRouter = innerRouter, messageSender = messageSender, + stakingFeatureToggles = stakingFeatureToggles, appRouter = appRouter, ) } diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt index b470fd8de2..10b2ec7971 100644 --- a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt @@ -70,6 +70,30 @@ internal class AmountRequirementStateTransformerTest { ) } + private fun solanaCryptoStatus(): CryptoCurrencyStatus = mockk(relaxed = true) { + every { currency.network.rawId } returns "solana" + } + + private fun solanaExitIntegration(exitMin: BigDecimal?, enterMin: BigDecimal? = null): StakingIntegration = + mockk { + every { exitMinimumAmount } returns exitMin + every { enterMinimumAmount } returns enterMin + every { exitArgs } returns null + } + + private fun solanaTransformer( + staked: BigDecimal, + exitMin: BigDecimal?, + enterMin: BigDecimal? = null, + enabled: Boolean = true, + ) = AmountRequirementStateTransformer( + cryptoCurrencyStatus = solanaCryptoStatus(), + maxAmount = EnterAmountBoundary(amount = staked, fiatAmount = null, fiatRate = null), + integration = solanaExitIntegration(exitMin = exitMin, enterMin = enterMin), + actionType = StakingActionCommonType.Exit(partiallyUnstakeDisabled = false), + isSolanaUnstakeValidationEnabled = enabled, + ) + @Test fun `WHEN amount exceeds positive maximum THEN max amount error string is used`() { val transformer = AmountRequirementStateTransformer( @@ -149,4 +173,143 @@ internal class AmountRequirementStateTransformerTest { assertThat((result.amountTextField.error as TextReference.Res).id) .isEqualTo(R.string.staking_max_amount_requirement_error) } + + @Test + fun `WHEN Solana full unstake THEN no error`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `WHEN Solana full unstake of small stake below minimum THEN no error`() { + val small = BigDecimal("0.098090754") + val transformer = solanaTransformer(staked = small, exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(small)) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `WHEN Solana partial unstake below minimum THEN unstake min error and button disabled`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat(result.isPrimaryButtonEnabled).isFalse() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_unstake_amount_requirement_error) + } + + @Test + fun `WHEN Solana partial unstake leaving remainder below minimum THEN low staked balance error and button disabled`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("4.5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat(result.isPrimaryButtonEnabled).isFalse() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_notification_low_staked_balance_text) + } + + @Test + fun `WHEN Solana partial unstake violating both minimums THEN unstake min error takes priority`() { + val transformer = solanaTransformer(staked = BigDecimal("1.5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("0.7"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_unstake_amount_requirement_error) + } + + @Test + fun `WHEN Solana partial unstake with both parts above minimum THEN no error`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("1.5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `WHEN Solana amount exactly at minimum THEN no error`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("1"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `WHEN Solana remainder exactly at minimum THEN no error`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("4"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + } + + @Test + fun `WHEN Solana exit minimum is zero THEN falls back to enter minimum`() { + val transformer = solanaTransformer( + staked = BigDecimal("5"), + exitMin = BigDecimal.ZERO, + enterMin = BigDecimal("1"), + ) + + val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_unstake_amount_requirement_error) + } + + @Test + fun `WHEN Solana both minimums null THEN partial unstake allowed`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = null, enterMin = null) + + val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `WHEN Solana validation disabled THEN partial unstake below minimum allowed`() { + val transformer = solanaTransformer( + staked = BigDecimal("5"), + exitMin = BigDecimal("1"), + enabled = false, + ) + + val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + } + + @Test + fun `WHEN validation enabled but currency is not Solana THEN Solana rule does not apply`() { + val transformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, // relaxed mock: network.rawId is not "solana" + maxAmount = EnterAmountBoundary(amount = BigDecimal("5"), fiatAmount = null, fiatRate = null), + integration = exitIntegrationWith(minimum = null, maximum = null), + actionType = StakingActionCommonType.Exit(partiallyUnstakeDisabled = false), + isSolanaUnstakeValidationEnabled = true, + ) + + val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data + + // Non-Solana: falls through to legacy exitArgs path (minimum null → no error), NOT the Solana remainder rule. + assertThat(result.amountTextField.isError).isFalse() + } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 9e3e2227b4..fa252262e5 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 @@ -297,7 +297,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, expressOperationType = ExpressOperationType.SWAP, ) } else { @@ -306,7 +305,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, expressOperationType = ExpressOperationType.SWAP, ) } @@ -317,7 +315,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, ) } } @@ -332,7 +329,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, amount: SwapAmount, - reduceBalanceBy: BigDecimal, expressOperationType: ExpressOperationType, ): Pair? { if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true && @@ -364,7 +360,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, ) } @@ -460,7 +455,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, amount: SwapAmount, - reduceBalanceBy: BigDecimal, expressOperationType: ExpressOperationType, ): Pair { val maybeQuotes = repository.findBestQuote( @@ -482,7 +476,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, ) } @@ -520,43 +513,27 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, amount: SwapAmount, - reduceBalanceBy: BigDecimal, ): Pair { val fromToken = fromSwapCurrencyStatus.currency val toToken = toSwapCurrencyStatus.currency - val includeFeeInAmount = getIncludeFeeInAmountInternal( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - amount = amount, - reduceBalanceBy = reduceBalanceBy, - feeValue = BigDecimal.ZERO, - ) - - val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmountInternal.Included) { - includeFeeInAmount.amountSubtractFee - } else { - amount - } - + // Always request the user-entered amount. The real balance/fee decision is deferred to the fee + // selector (`computeBalanceStatus` / `applySwapFee`), which correctly handles gasless (token) fee + // payment even when the native coin balance is zero. Do NOT derive the quote amount from the native + // balance here — that discards the entered amount ([REDACTED_TASK_KEY] regression: CEX always sent max). val quotes = repository.findBestQuote( userWallet = fromSwapCurrencyStatus.userWallet, fromContractAddress = fromToken.getContractAddress(), fromNetwork = fromToken.network.rawId, toContractAddress = toToken.getContractAddress(), toNetwork = toToken.network.rawId, - fromAmount = amountToRequest.toStringWithRightOffset(), + fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, toDecimals = toToken.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, ) - val quoteBalanceStatus = if (includeFeeInAmount == IncludeFeeInAmountInternal.BalanceNotEnough) { - SwapBalanceStatus.InsufficientAmount - } else { - SwapBalanceStatus.Pending // fee not resolved yet - } - return provider to getQuotesState( provider = provider, quoteDataModel = quotes, @@ -564,7 +541,7 @@ internal class SwapInteractorImpl @Inject constructor( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = true, - quoteBalanceStatus = quoteBalanceStatus, + quoteBalanceStatus = SwapBalanceStatus.Pending, ) } @@ -1809,8 +1786,8 @@ internal class SwapInteractorImpl @Inject constructor( * same-currency-token path: balance check on the from-token's own balance. * - Otherwise → native-fee branch via [getIncludeFeeInAmountForNative]. * - * Used both by [loadCexQuoteData] (with `feeValue = ZERO` at quote stage) and by - * [computeBalanceStatus] (with the actual fee once the selector resolves). + * Used by [computeBalanceStatus] with the actual fee once the fee selector resolves. The quote stage + * ([manageCex]) no longer consults this — it always requests the user-entered amount. */ private suspend fun getIncludeFeeInAmountInternal( fromSwapCurrencyStatus: SwapCurrencyStatus, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt index b2ca111cd2..11aa80d5f6 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt @@ -383,11 +383,6 @@ class DexSwapFeeCalculator( derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, ) - // if native balance is zero - we can't calculate fee - if (nativeBalance.signum() == 0) { - raise(GetFeeError.UnknownError) - } - val txAmountValue = transaction.txValue ?: error("unable to get txValue") val amountToSend = if (permissionState is PermissionDataState.PermissionSettings) { transaction.fromAmount.value.convertToSdkAmount(fromSwapCurrencyStatus.status) diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt index dd3da80960..9e0f36436b 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -20,6 +20,7 @@ import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel +import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus import com.tangem.feature.swap.domain.models.domain.SwapDataModel import com.tangem.feature.swap.domain.models.ui.PermissionDataState import com.tangem.feature.swap.domain.models.ui.SwapState @@ -28,6 +29,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.slot import kotlinx.coroutines.test.runTest import org.junit.Ignore import org.junit.jupiter.api.BeforeEach @@ -720,6 +722,337 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase( } } + /** + * [REDACTED_TASK_KEY]: the CEX quote stage must request the **user-entered** amount as `fromAmount`, regardless of + * the native-coin balance or `reduceBalanceBy`. A prior fix derived the quote amount from the native + * balance (`nativeBalance - reduceBalanceBy`), which discarded the entered amount and made CEX always + * quote the max balance (and, for tokens, sent the native balance under the token's decimals). The real + * balance/fee decision is deferred to the fee selector, so the quote status is always `Pending`. + */ + @Nested + inner class CexQuoteAmount { + + @Test + fun `should request the entered amount for a coin with non-zero native balance`() = runTest { + // Given — coin balance 10, native balance 10 (base stub); user enters 0.014 (the reported case) + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + decimals = 18, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "0.014", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — fromAmount is the entered 0.014 (0.014 * 1e18), NOT the full balance + assertThat(fromAmountSlot.isCaptured).isTrue() + assertThat(fromAmountSlot.captured).isEqualTo("14000000000000000") + assertThat(result[cexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending) + } + + @Test + fun `should ignore reduceBalanceBy when building the CEX quote fromAmount`() = runTest { + // Given — reduceBalanceBy must NOT affect the CEX quote amount anymore + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + decimals = 18, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal("2"), + ) + + // Then — still the entered 1.0 * 1e18, unaffected by reduceBalanceBy + assertThat(fromAmountSlot.captured).isEqualTo("1000000000000000000") + } + + @Test + fun `should request the entered amount for a coin with zero native balance`() = runTest { + // Given — native balance ZERO must not block or override the entered amount + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + decimals = 18, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — 1.0 * 1e18, status Pending + assertThat(fromAmountSlot.captured).isEqualTo("1000000000000000000") + val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending) + } + + @Test + fun `should request the entered token amount with token decimals for a token with non-zero native balance`() = + runTest { + // Given — token (6 decimals) balance 100, native ETH balance 10 (base stub); user enters 5. + // The quote must send 5 in token units, NOT the native balance under token decimals. + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xToken", + isCoin = false, + amount = BigDecimal("100"), + decimals = 6, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "5", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — 5 * 1e6 (token decimals), NOT 10 (native balance) + assertThat(fromAmountSlot.captured).isEqualTo("5000000") + val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending) + } + + @Test + fun `should not block a token swap with zero native balance (gasless)`() = runTest { + // Given — the original [REDACTED_TASK_KEY] case: token with zero native (ETH) balance, gasless supported. + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xToken", + isCoin = false, + amount = BigDecimal("100"), + decimals = 6, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "5", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — entered token amount is quoted and status is Pending (not InsufficientAmount) + assertThat(fromAmountSlot.captured).isEqualTo("5000000") + val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending) + } + + @Test + fun `should request the full entered balance for a coin when max is tapped`() = runTest { + // Given — "Max" sets the entered amount to the full coin balance (10). The native balance stub is + // deliberately different (3) so a regression to the old `nativeBalance - reduceBalanceBy` logic + // would flip the asserted value (3e18) instead of the entered 10e18. + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + decimals = 18, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("3") + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When — user taps Max: entered amount == full coin balance + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "10", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — full entered balance 10 * 1e18, NOT the native balance (3); no quote-stage fee subtraction + assertThat(fromAmountSlot.captured).isEqualTo("10000000000000000000") + val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending) + } + + @Test + fun `should request the full entered token balance when max is tapped`() = runTest { + // Given — token (6 decimals) balance 100, native ETH balance 10 (base stub). "Max" enters 100. + // native (10) naturally differs from the token balance (100), so a regression to the native-balance + // logic would send 10 (as "10000000") instead of the entered 100 (as "100000000"). + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xToken", + isCoin = false, + amount = BigDecimal("100"), + decimals = 6, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When — user taps Max: entered amount == full token balance + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "100", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — full entered token balance 100 * 1e6, NOT the native balance (10) + assertThat(fromAmountSlot.captured).isEqualTo("100000000") + val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending) + } + } + @Nested inner class MixedProviderDispatch { diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt index 8918f73d47..691e7ef207 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt @@ -124,29 +124,88 @@ internal class DexSwapFeeCalculatorTest { } // ------------------------------------------------------------------------- - // EVM zero-balance short-circuit + // EVM zero-balance no longer short-circuits (guard removed) + // + // Previously a zero native balance raised UnknownError *before* any fee call. That guard was + // removed, so a zero-balance quote must still surface a fee: when the tx amount fits the (zero) + // balance the normal getFeeUseCase path runs; when it does not, the balance check throws and the + // calculator falls back to getEthSpecificFeeUseCase via the IllegalStateException branch. // ------------------------------------------------------------------------- @Test - fun `EVM DEX swap with native balance ZERO returns Left UnknownError`() = runTest { - val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) - val transaction = buildDex(txValue = "0") - coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO + fun `EVM DEX swap with native balance ZERO no longer short-circuits and computes fee via getFeeUseCase`() = + runTest { + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + // txValue "0" → amountToSend 0, so `nativeBalance(0) < 0` is false and the main path runs. + val transaction = buildDex(txValue = "0") + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO + coEvery { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } returns TransactionFee.Single(normal = ethLegacyFee()).right() - val result = sut.calculate(fromStatus, transaction) + val result = sut.calculate(fromStatus, transaction) - assertThat(result.isLeft()).isTrue() - result.onLeft { assertThat(it).isEqualTo(GetFeeError.UnknownError) } - // getFeeUseCase should not have been called because balance check short-circuits first. - // Use a more permissive verify to avoid clashing with the other overload signatures. - coVerify(exactly = 0) { - getFeeUseCase.invoke( - userWallet = any(), - network = any(), - transactionData = any(), - ) + // The removed guard means the fee is now computed instead of raising UnknownError. + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { + getFeeUseCase.invoke( + userWallet = any(), + network = any(), + transactionData = any(), + ) + } + coVerify(exactly = 0) { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = any(), + gasPrice = any(), + ) + } + } + + @Test + fun `EVM DEX swap with native balance ZERO falls back to getEthSpecificFeeUseCase when txValue exceeds balance`() = + runTest { + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val gas = BigInteger.valueOf(120_000L) + // txValue 0.001 ETH > zero balance → `nativeBalance < amountToSend` throws → gas fallback. + val transaction = buildDex(txValue = "1000000000000000", gas = gas) + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO + coEvery { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = any(), + gasPrice = any(), + ) + } returns TransactionFee.Choosable( + minimum = ethLegacyFee(), + normal = ethLegacyFee(), + priority = ethLegacyFee(), + ).right() + + val result = sut.calculate(fromStatus, transaction) + + // Zero balance now falls back instead of raising UnknownError up-front. + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = gas, + gasPrice = any(), + ) + } + // The balance check throws before the main fee call, so getFeeUseCase is never reached. + coVerify(exactly = 0) { + getFeeUseCase.invoke( + userWallet = any(), + network = any(), + transactionData = any(), + ) + } } - } // ------------------------------------------------------------------------- // EVM IllegalStateException → fallback to GetEthSpecificFeeUseCase 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 58d13cc7f1..b50b960811 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 @@ -2083,6 +2083,7 @@ internal class SwapModel @Inject constructor( if (provider != null && swapState != null && isNotNullCurrency) { modelScope.launch(dispatchers.default) { feeSelectorRepository.state.value = FeeSelectorUM.Loading + updateFeePaidCryptoCurrencyFor(fromSwapCurrencyStatus) feeSelectorReloadTrigger.triggerUpdate() } analyticsEventHandler.send(SwapEvents.ProviderChosen(provider)) diff --git a/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/TokenDetailsComponent.kt b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/TokenDetailsComponent.kt index 8192a29732..05acec665c 100644 --- a/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/TokenDetailsComponent.kt +++ b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/TokenDetailsComponent.kt @@ -12,6 +12,7 @@ interface TokenDetailsComponent : ComposableContentComponent { val userWalletId: UserWalletId, val currency: CryptoCurrency, val navigationAction: NavigationAction? = null, + val shouldShowMarketBlock: Boolean = true, ) interface Factory : ComponentFactory diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index 047dd80f26..ddc96c2d4b 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -23,12 +23,12 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.route.TokenDeta import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreen import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreenLegacy import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent -import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.TransferBottomSheetComponent -import com.tangem.features.rating.RatingComponent +import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import com.tangem.features.rating.RatingComponent import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokenreceive.TokenReceiveComponent @@ -113,12 +113,14 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( }, ) - private val tokenMarketBlockComponent = params.currency.toTokenMarketParam()?.let { tokenMarketParams -> - tokenMarketBlockComponentFactory.create( - appComponentContext = child("tokenMarketBlockComponent"), - params = tokenMarketParams, - ) - } + private val tokenMarketBlockComponent = params.currency.toTokenMarketParam() + ?.takeIf { params.shouldShowMarketBlock } + ?.let { tokenMarketParams -> + tokenMarketBlockComponentFactory.create( + appComponentContext = child("tokenMarketBlockComponent"), + params = tokenMarketParams, + ) + } private val yieldSupplyComponent = yieldSupplyComponentFactory.create( context = child("tokenYieldSupplyComponent"), 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 60821d7231..f27d73d86f 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 @@ -23,6 +23,7 @@ import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent +import com.tangem.core.analytics.models.event.TransferAnalyticsEvent import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -368,6 +369,7 @@ internal class TokenDetailsModel @Inject constructor( actions = state.states, networkSource = networkSource, clickIntents = this@TokenDetailsModel, + analyticsEventHandler = analyticsEventsHandler, onActionDispatched = bottomSheetNavigation::dismiss, ), ) @@ -547,6 +549,13 @@ internal class TokenDetailsModel @Inject constructor( } override fun onAddFundsClick() { + analyticsEventsHandler.send( + TokenScreenAnalyticsEvent.ButtonWithParams.ButtonAddFunds( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + derivationIndex = getAccountIndexOrNull(), + ), + ) bottomSheetNavigation.activate( TokenDetailsBottomSheetConfig.AddFunds( userWalletId = userWalletId, @@ -556,15 +565,25 @@ internal class TokenDetailsModel @Inject constructor( } override fun onTransferClick() { + analyticsEventsHandler.send( + TokenScreenAnalyticsEvent.ButtonWithParams.ButtonTransfer( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + derivationIndex = getAccountIndexOrNull(), + ), + ) val amount = cryptoCurrencyStatus?.value?.amount if (amount == null || amount.signum() <= 0) { - uiMessageSender.send( - message = SnackbarMessage( - message = resourceReference(R.string.token_button_unavailability_reason_empty_balance_send), + handleUnavailabilityReason( + unavailabilityReason = ScenarioUnavailabilityReason.EmptyBalance( + withdrawalScenario = ScenarioUnavailabilityReason.WithdrawalScenario.SEND, ), ) return } + analyticsEventsHandler.send( + TransferAnalyticsEvent.MethodScreenOpened(source = AnalyticsParam.ScreensSources.Token), + ) bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.Transfer) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt index c1e5c1fafb..a75eb5c7de 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt @@ -13,12 +13,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.features.tokendetails.impl.R import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.* import javax.inject.Inject @ModelScoped @@ -51,14 +46,14 @@ internal class TokenDetailsStateController @Inject constructor() { ), balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( addFundsButton = TangemButtonUM( - text = resourceReference(R.string.tangempay_card_details_add_funds), + text = resourceReference(R.string.actionbutton_addfunds_title), tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_down_24), onClick = { }, isEnabled = true, type = TangemButtonType.Secondary, ), swapButton = TangemButtonUM( - text = resourceReference(R.string.common_swap), + text = resourceReference(R.string.actionbutton_swap_title), tangemIconUM = TangemIconUM.Icon( iconRes = R.drawable.ic_exchange_default_24, tintReference = { TangemTheme.colors2.graphic.neutral.quaternary }, @@ -68,7 +63,7 @@ internal class TokenDetailsStateController @Inject constructor() { type = TangemButtonType.Secondary, ), transferButton = TangemButtonUM( - text = resourceReference(R.string.common_transfer), + text = resourceReference(R.string.actionbutton_transfer_title), tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_up_24), onClick = { }, isEnabled = true, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformer.kt index 46f2b89ec5..c33785b3a3 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformer.kt @@ -1,5 +1,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.event.TransferAnalyticsEvent import com.tangem.domain.models.StatusSource import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenActionsState @@ -13,6 +15,7 @@ internal class UpdateTransferTransformer( private val actions: List, private val networkSource: StatusSource, private val clickIntents: TokenDetailsClickIntents, + private val analyticsEventHandler: AnalyticsEventHandler, private val onActionDispatched: () -> Unit, ) : Transformer { @@ -28,6 +31,7 @@ internal class UpdateTransferTransformer( isLoading = action.unavailabilityReason.isOutdatedLoading(), isEnabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None, onClick = { + analyticsEventHandler.send(TransferAnalyticsEvent.ButtonSend()) onActionDispatched() clickIntents.onSendClick(action.unavailabilityReason) }, @@ -38,6 +42,7 @@ internal class UpdateTransferTransformer( isLoading = action.unavailabilityReason.isLoading, isEnabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None, onClick = { + analyticsEventHandler.send(TransferAnalyticsEvent.ButtonSwap()) onActionDispatched() clickIntents.onSwapFromClick(action.unavailabilityReason) }, @@ -53,6 +58,7 @@ internal class UpdateTransferTransformer( isLoading = false, isEnabled = true, onClick = { + analyticsEventHandler.send(TransferAnalyticsEvent.ButtonSwapAndSend()) onActionDispatched() clickIntents.onSwapAndSendClick(it.unavailabilityReason) }, @@ -63,6 +69,7 @@ internal class UpdateTransferTransformer( isLoading = action.unavailabilityReason.isOutdatedLoading(), isEnabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None, onClick = { + analyticsEventHandler.send(TransferAnalyticsEvent.ButtonSell()) onActionDispatched() clickIntents.onSellClick(action.unavailabilityReason) }, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt index a74d169214..427c7c7b30 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt @@ -14,11 +14,11 @@ 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.platform.testTag +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.platform.testTag -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 @@ -35,11 +35,9 @@ import com.tangem.core.ui.ds.button.TangemButtonType import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.button.action.ActionButtons import com.tangem.core.ui.ds.image.TangemIconUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.orMaskWithStars -import com.tangem.core.ui.extensions.resolveAnnotatedReference -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.test.TokenDetailsScreenTestTags @@ -47,6 +45,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBala import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM import com.tangem.features.tokendetails.impl.R import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList private val CurrencyIconSize: Dp = 70.dp private val NetworkBadgeSize: Dp = 24.dp @@ -90,16 +89,25 @@ internal fun TokenDetailsBalanceBlock( } if (!balanceBlockUM.isBalanceZeroContent()) { SpacerH(TangemTheme.dimens2.x10) + val hapticManager = LocalHapticManager.current val buttons = remember( balanceBlockUM.addFundsButton, balanceBlockUM.swapButton, balanceBlockUM.transferButton, + hapticManager, ) { persistentListOf( balanceBlockUM.addFundsButton, balanceBlockUM.swapButton, balanceBlockUM.transferButton, - ) + ).map { button -> + button.copy( + onClick = { + hapticManager.perform(TangemHapticEffect.View.ContextClick) + button.onClick() + }, + ) + }.toPersistentList() } ActionButtons(buttons = buttons) } diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformerTest.kt index e50e0b4c1a..3fcf8b3ee0 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformerTest.kt @@ -1,6 +1,8 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.event.TransferAnalyticsEvent import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.extensions.stringReference @@ -24,6 +26,7 @@ import org.junit.jupiter.api.Test class UpdateTransferTransformerTest { private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) private val onActionDispatched: () -> Unit = mockk(relaxed = true) @Test @@ -112,6 +115,7 @@ class UpdateTransferTransformerTest { // THEN verifyOrder { + analyticsEventHandler.send(ofType()) onActionDispatched.invoke() clickIntents.onSendClick(ScenarioUnavailabilityReason.None) } @@ -130,6 +134,7 @@ class UpdateTransferTransformerTest { // THEN verifyOrder { + analyticsEventHandler.send(ofType()) onActionDispatched.invoke() clickIntents.onSellClick(ScenarioUnavailabilityReason.None) } @@ -225,6 +230,7 @@ class UpdateTransferTransformerTest { // THEN verifyOrder { + analyticsEventHandler.send(ofType()) onActionDispatched.invoke() clickIntents.onSwapFromClick(ScenarioUnavailabilityReason.None) } @@ -347,6 +353,7 @@ class UpdateTransferTransformerTest { // Assert verifyOrder { + analyticsEventHandler.send(ofType()) onActionDispatched.invoke() clickIntents.onSwapAndSendClick(ScenarioUnavailabilityReason.None) } @@ -405,6 +412,7 @@ class UpdateTransferTransformerTest { // THEN verify(exactly = 0) { onActionDispatched.invoke() } verify(exactly = 0) { clickIntents.onSendClick(any()) } + verify(exactly = 0) { analyticsEventHandler.send(any()) } } private fun createTransformer( @@ -414,6 +422,7 @@ class UpdateTransferTransformerTest { actions = actions, networkSource = networkSource, clickIntents = clickIntents, + analyticsEventHandler = analyticsEventHandler, onActionDispatched = onActionDispatched, ) 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 d983fe385c..0ed6aa49de 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 @@ -125,6 +125,7 @@ internal class WalletClickIntents @Inject constructor( } fun onTransferClick(userWalletId: UserWalletId) { + analyticsEventHandler.send(MainScreenAnalyticsEvent.ButtonTransfer()) router.openTransfer(userWalletId) } 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 31daa69aca..10ed719757 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 @@ -55,7 +55,7 @@ internal sealed class WalletActionButtons( override val onClick: () -> Unit, override val isEnabled: Boolean, ) : WalletActionButtons( - text = resourceReference(R.string.common_add_funds), + text = resourceReference(R.string.actionbutton_addfunds_title), iconRes = R.drawable.ic_arrow_down_24, ) @@ -63,7 +63,7 @@ internal sealed class WalletActionButtons( override val onClick: () -> Unit, override val isEnabled: Boolean, ) : WalletActionButtons( - text = resourceReference(R.string.common_swap), + text = resourceReference(R.string.actionbutton_swap_title), iconRes = R.drawable.ic_exchange_default_24, ) @@ -79,7 +79,7 @@ internal sealed class WalletActionButtons( override val onClick: () -> Unit, override val isEnabled: Boolean, ) : WalletActionButtons( - text = resourceReference(R.string.common_transfer), + text = resourceReference(R.string.actionbutton_transfer_title), iconRes = R.drawable.ic_arrow_up_24, ) } \ No newline at end of file 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 05924d40c5..31d053f08a 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 @@ -5,6 +5,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import com.tangem.core.ui.ds.button.TangemButton import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.test.MainScreenTestTags import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM @@ -31,13 +33,19 @@ internal fun LazyListScope.organizeTokens2(state: WalletUM, itemModifier: Modifi key = "OrganizeTokensButton", contentType = "OrganizeTokensButton", ) { + val hapticManager = LocalHapticManager.current val testTag = if (organizeButton.text == resourceReference(R.string.main_add_and_manage_tokens)) { MainScreenTestTags.ADD_AND_MANAGE_BUTTON } else { MainScreenTestTags.ORGANIZE_TOKENS_BUTTON } TangemButton( - buttonUM = organizeButton, + buttonUM = organizeButton.copy( + onClick = { + hapticManager.perform(TangemHapticEffect.View.ContextClick) + organizeButton.onClick() + }, + ), modifier = itemModifier.testTag(testTag), ) } 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 562d21f546..ceb9e0f51b 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.CircularProgressIndicator 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.alpha @@ -37,6 +38,8 @@ 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 import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.test.MainScreenTestTags @@ -47,6 +50,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditiona import com.tangem.feature.wallet.presentation.wallet.state.model.WalletBalanceUM import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList private const val MIN_SCALE = 0.75f private const val MAX_SCALE = 1f @@ -64,6 +68,17 @@ internal fun WalletBalance( val alpha = 1f - collapsedFraction val scale = alpha.coerceIn(MIN_SCALE, MAX_SCALE) val density = LocalDensity.current + val hapticManager = LocalHapticManager.current + val hapticButtons = remember(buttons, hapticManager) { + buttons.map { button -> + button.copy( + onClick = { + hapticManager.perform(TangemHapticEffect.View.ContextClick) + button.onClick() + }, + ) + }.toImmutableList() + } Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -99,7 +114,7 @@ internal fun WalletBalance( } } SpacerH(TangemTheme.dimens2.x2) - ActionButtons(buttons, modifier = Modifier.fillMaxWidth()) + ActionButtons(buttons = hapticButtons, modifier = Modifier.fillMaxWidth()) SpacerH(TangemTheme.dimens2.x6) } } 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 b03fc85b81..da656845e9 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 @@ -28,10 +28,8 @@ import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedS import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.orMaskWithStars 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 +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.* import com.tangem.core.ui.test.MainScreenTestTags import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.common.WalletPreviewDataLegacy @@ -58,6 +56,7 @@ internal fun WalletTopBar( isBalanceHidden: Boolean, behavior: TangemCollapsingAppBarBehavior, ) { + val hapticManager = LocalHapticManager.current Surface( color = Color.Unspecified, contentColor = Color.Unspecified, @@ -95,7 +94,14 @@ internal fun WalletTopBar( ) { topBarConfig.endActions.forEach { action -> TangemTopBarActionContent( - action, + action.copy( + onClick = action.onClick?.let { onClick -> + { + hapticManager.perform(TangemHapticEffect.View.ContextClick) + onClick() + } + }, + ), modifier = Modifier.testTag(MainScreenTestTags.MORE_BUTTON), ) } diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/compatibility/L2Networks.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/compatibility/L2Networks.kt index 782e3e88ff..f60e5218ee 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/compatibility/L2Networks.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/compatibility/L2Networks.kt @@ -4,6 +4,7 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toCoinId import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.datasource.api.markets.models.response.TokenMarketInfoResponse +import com.tangem.datasource.api.markets.models.response.TokenMarketListResponse import com.tangem.datasource.api.tangemTech.models.CoinsResponse import com.tangem.datasource.api.tangemTech.models.UserTokensResponse @@ -36,18 +37,48 @@ fun List.applyL2Compatibility(coinId: String): List< fun TokenMarketInfoResponse.applyL2Compatibility(coinId: String): TokenMarketInfoResponse { val networks = this.networks ?: return this - return if (coinId == ETHEREUM_COIN_ID) { - val l2Networks = l2BlockchainsList.map { blockchain -> + if (coinId != ETHEREUM_COIN_ID) return this + + val networksWithL2 = networks.appendMissingL2Networks( + networkId = { it.networkId }, + createNetwork = { networkId -> TokenMarketInfoResponse.Network( - networkId = blockchain.toNetworkId(), + networkId = networkId, contractAddress = null, decimalCount = null, ) - } - this.copy(networks = networks + l2Networks) - } else { - this - } + }, + ) + return this.copy(networks = networksWithL2) +} + +fun TokenMarketListResponse.Token.applyL2Compatibility(): TokenMarketListResponse.Token { + val networks = this.networks ?: return this + if (id != ETHEREUM_COIN_ID) return this + + val networksWithL2 = networks.appendMissingL2Networks( + networkId = { it.networkId }, + createNetwork = { networkId -> + TokenMarketListResponse.Token.Network( + networkId = networkId, + contractAddress = null, + decimalCount = null, + ) + }, + ) + return this.copy(networks = networksWithL2) +} + +private inline fun List.appendMissingL2Networks( + networkId: (T) -> String, + createNetwork: (networkId: String) -> T, +): List { + val existingNetworkIds = mapTo(hashSetOf(), networkId) + val missingL2Networks = l2BlockchainsList + .map { it.toNetworkId() } + .filterNot { it in existingNetworkIds } + .map(createNetwork) + return this + missingL2Networks } fun getTokenIdIfL2Network(tokenId: String): String { diff --git a/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/compatibility/L2NetworksTest.kt b/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/compatibility/L2NetworksTest.kt new file mode 100644 index 0000000000..1dc7fda86c --- /dev/null +++ b/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/compatibility/L2NetworksTest.kt @@ -0,0 +1,92 @@ +package com.tangem.blockchainsdk.compatibility + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.datasource.api.markets.models.response.TokenMarketInfoResponse +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class L2NetworksTest { + + @Test + fun `GIVEN ethereum info with networks WHEN applyL2Compatibility THEN missing L2 networks are appended`() { + // Arrange + val response = createInfoResponse(id = "ethereum", networks = listOf(createNetwork(networkId = "ethereum"))) + + // Act + val actual = response.applyL2Compatibility(coinId = "ethereum") + + // Assert + val networkIds = actual.networks?.map(TokenMarketInfoResponse.Network::networkId) + val expectedNetworkIds = listOf("ethereum") + l2BlockchainsList.map { it.toNetworkId() } + assertThat(networkIds).containsExactlyElementsIn(expectedNetworkIds) + } + + @Test + fun `GIVEN ethereum info with backend-provided L2 network WHEN applyL2Compatibility THEN backend entry wins without duplicates`() { + // Arrange + val backendArbitrum = createNetwork(networkId = "arbitrum-one", decimalCount = 18) + val response = createInfoResponse( + id = "ethereum", + networks = listOf(createNetwork(networkId = "ethereum"), backendArbitrum), + ) + + // Act + val actual = response.applyL2Compatibility(coinId = "ethereum") + + // Assert + val networks = actual.networks.orEmpty() + assertThat(networks.map(TokenMarketInfoResponse.Network::networkId)).containsNoDuplicates() + assertThat(networks.single { it.networkId == "arbitrum-one" }).isEqualTo(backendArbitrum) + } + + @Test + fun `GIVEN non-ethereum info WHEN applyL2Compatibility THEN networks stay unchanged`() { + // Arrange + val tetherNetworks = listOf( + createNetwork( + networkId = "ethereum", + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + decimalCount = 6, + ), + ) + val response = createInfoResponse(id = "tether", networks = tetherNetworks) + + // Act + val actual = response.applyL2Compatibility(coinId = "tether") + + // Assert + assertThat(actual.networks).isEqualTo(tetherNetworks) + } + + private fun createInfoResponse( + id: String, + networks: List?, + ) = TokenMarketInfoResponse( + id = id, + name = id, + symbol = id.take(n = 3).uppercase(), + currentPrice = BigDecimal.ONE, + priceChangePercentage = null, + networks = networks, + shortDescription = null, + fullDescription = null, + insights = null, + metrics = null, + securityData = null, + links = null, + pricePerformance = null, + exchangesAmount = null, + ) + + private fun createNetwork( + networkId: String, + contractAddress: String? = null, + decimalCount: Int? = null, + ) = TokenMarketInfoResponse.Network( + networkId = networkId, + exchangeable = false, + contractAddress = contractAddress, + decimalCount = decimalCount, + ) +} \ No newline at end of file