Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-09 16:18:34 +04:00
commit 997194b3fb
92 changed files with 2454 additions and 391 deletions

View file

@ -301,6 +301,7 @@ internal class ChildFactory @Inject constructor(
userWalletId = route.userWalletId,
currency = route.currency,
navigationAction = route.navigationAction,
shouldShowMarketBlock = route.shouldShowMarketBlock,
),
componentFactory = tokenDetailsComponentFactory,
)

View file

@ -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

View file

@ -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),
)

View file

@ -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<QuickActionUM>,
val onQuickActionClick: (QuickActionUM) -> Unit,
val onQuickActionLongClick: (QuickActionUM) -> Unit,
val disabledActions: ImmutableSet<QuickActionUM> = persistentSetOf(),
)

View file

@ -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<TokenActionsState.ActionState>,
isRedesignEnabled: Boolean,
context: TokenActionsContext = TokenActionsContext.Markets,
): ImmutableList<QuickActionUM> {
val available = actions.filter { it.unavailabilityReason == ScenarioUnavailabilityReason.None }
val allowed = context.allowedActionsInOrder
?: return available.mapNotNull { it.toQuickActionUM(isRedesignEnabled) }.toImmutableList()
): ImmutableList<QuickActionUM> =
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<TokenActionsState.ActionState>,
isRedesignEnabled: Boolean,
context: TokenActionsContext,
): List<QuickActionState> {
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

View file

@ -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)
}
}

View file

@ -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)

View file

@ -42,6 +42,10 @@ sealed class MainScreenAnalyticsEvent(
event = "Button - Add Funds",
)
class ButtonTransfer : MainScreenAnalyticsEvent(
event = "Button - Transfer",
)
class LimitsClicked : MainScreenAnalyticsEvent(
event = "Limits Clicked",
)

View file

@ -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<String, String> = 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")
}

View file

@ -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"

View file

@ -105,6 +105,7 @@
<item quantity="other">Adressen</item>
</plurals>
<string name="address_book_choose_address">Adresse auswählen</string>
<string name="address_book_clear_all_networks">Alles löschen</string>
<string name="address_book_contact">Kontakt</string>
<string name="address_book_contact_name">Name der Kontaktperson</string>
<string name="address_book_copy_address">Adresse kopieren</string>
@ -134,6 +135,7 @@
<string name="address_book_save_to_wallet_title">In Wallet speichern</string>
<string name="address_book_save_wallet_to_description">Dieser Kontakt wird mit dem Adressbuch dieser Wallet verknüpft.</string>
<string name="address_book_search_no_results">Es wurden keine Ergebnisse gefunden.\nVersuchen Sie es mit einem anderen Namen</string>
<string name="address_book_select_all_networks">Alle auswählen</string>
<string name="address_book_select_network">Netzwerk auswählen</string>
<string name="address_book_title">Adressbuch</string>
<string name="address_book_unsaved_changes">Nicht gespeicherte Änderungen</string>
@ -421,6 +423,7 @@
<string name="common_or">oder</string>
<string name="common_origin_card">Hauptkarte</string>
<string name="common_origin_ring">Primärring</string>
<string name="common_other">Sonstiges</string>
<string name="common_passphrase">Passphrase</string>
<string name="common_paste">Einfügen</string>
<string name="common_privacy_policy">Datenschutzrichtlinie</string>
@ -719,6 +722,7 @@
<string name="feedback_subject_tx_failed">Eine Transaktion kann nicht gesendet werden</string>
<string name="feedback_token_description_error">Fehler in der Coinbeschreibung</string>
<string name="for_you_description">Portfolio prüfen und Verdienstmöglichkeiten erkunden</string>
<string name="for_you_portfolio_review_title">Portfolio-Überprüfung</string>
<string name="for_you_title">Für dich</string>
<string name="force_update_action">Jetzt aktualisieren</string>
<string name="force_update_banner_message">Aktualisiere die Anwendung auf die neueste Version, um die ordnungsgemäße Funktionalität zu gewährleisten</string>
@ -735,6 +739,7 @@
<string name="generic_error">Es ist ein Fehler aufgetreten</string>
<string name="generic_error_code">Es ist ein Fehler aufgetreten. Code: %s.</string>
<string name="generic_requires_memo_error">Memo erforderlich</string>
<string name="get_token_title">Erhalten %1$s</string>
<string name="give_permission_current_transaction">Nur diese</string>
<string name="give_permission_policy_type_footer">Mit Deiner Zustimmung erlaubst Du dem Smart Contract, Deine Token in zukünftigen Transaktionen zu verwenden.</string>
<string name="give_permission_rows_amount">Betrag %s</string>
@ -918,6 +923,7 @@
<string name="markets_portfolio_block_add_token_title">**Hinzufügen zu Ihrem Portfolio**, um mit dem Kauf, Tausch oder Erhalt dieses Vermögenswerts zu beginnen</string>
<string name="markets_portfolio_block_subtitle">Zum Portfolio hinzufügen</string>
<string name="markets_portfolio_block_title">Dein Portfolio</string>
<string name="markets_portfolio_block_token_unsupported">**Token nicht unterstützt**. Dieser Token wird derzeit in der Wallet nicht unterstützt.</string>
<string name="markets_pulse_common_title">Marktimpuls</string>
<string name="markets_quick_actions">Schnelle Aktionen</string>
<string name="markets_search_clear_all_hints">Alles löschen</string>
@ -1280,6 +1286,8 @@
<string name="onramp_title_available_from">Erhältlich ab</string>
<string name="onramp_title_available_up_to">Verfügbar bis zu</string>
<string name="onramp_title_you_get">Du erhältst</string>
<string name="onramp_token_is_not_supported_banner_subtitle">Dieser Token wird nicht unterstützt. Bitte wählen Sie einen anderen Token zum Kauf.</string>
<string name="onramp_token_is_not_supported_banner_title">%s wird nicht unterstützt</string>
<string name="onramp_tos_external_providers">Der Service wird von einem externen Anbieter bereitgestellt. \n Tangem übernimmt keine Verantwortung.</string>
<string name="onramp_transaction_status_footer_text">Du kannst diesen Bildschirm schließen und den Transaktionsstatus auf dem Bildschirm mit den Token-Details überprüfen.</string>
<string name="onramp_up_to_rate">Bis zu</string>
@ -1292,6 +1300,19 @@
<string name="organize_tokens_sort_by_balance">Nach Guthaben</string>
<string name="organize_tokens_title">Token organisieren</string>
<string name="organize_tokens_ungroup">Gruppe löschen</string>
<string name="promo_campaign_already_activated_subtitle">Berechtigte Cashback-Zahlungen werden wie folgt ausgezahlt:</string>
<string name="promo_campaign_already_activated_title">Sie sind bereits eingeschrieben in %1$s</string>
<string name="promo_campaign_eligible_tokens">Zulässige Token</string>
<string name="promo_campaign_enroll">Anmelden</string>
<string name="promo_campaign_enroll_success_title">Sie sind erfolgreich eingeschrieben in %1$s</string>
<string name="promo_campaign_not_active_subtitle">Diese Aktion existiert nicht mehr oder ist abgelaufen.</string>
<string name="promo_campaign_not_active_title">Kampagne nicht aktiv</string>
<string name="promo_campaign_select_cashback_account">Cashback-Konto auswählen</string>
<string name="promo_campaign_summary_title">Melden Sie sich an %1$s</string>
<string name="promo_campaign_terms_agreement">Ich stimme zu, dass %1$s</string>
<string name="promo_campaign_terms_agreement_android">Ich stimme zu, dass</string>
<string name="promo_campaign_terms_link">%1$s Bedingungen</string>
<string name="promo_campaign_whale_swap_summary_description">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.</string>
<string name="provider_name_support">%s Unterstützung</string>
<string name="push_notification_settings_banner_description">Push-Benachrichtigungen sind aktiviert, funktionieren aber erst, nachdem du Benachrichtigungen in den Geräteeinstellungen zugelassen hast.</string>
<string name="push_notification_settings_banner_title">Benachrichtigungen zulassen</string>
@ -1423,6 +1444,7 @@
<string name="send_destination_tag_field">Ziel-Tag</string>
<string name="send_enter_address_field">Adresse eingeben</string>
<string name="send_enter_address_field_ens">ENS-Name oder Adresse</string>
<string name="send_enter_address_field_ens_name">Adresse, ENS oder Name</string>
<string name="send_error_address_same_as_wallet">Die Adresse stimmt mit der Adresse Ihrer Brieftasche überein</string>
<string name="send_error_dust_amount_format">Minimaler Betrag ist %s</string>
<string name="send_error_dust_change_format">Minimaler Wechselgeld ist %s</string>
@ -1622,9 +1644,9 @@
<string name="staking_notification_ton_account_initialization_message">Um mit dem Staking zu beginnen, musst Du zuerst Dein TON-Konto aktivieren.</string>
<string name="staking_notification_ton_account_initialization_title">Kontoaktivierung</string>
<string name="staking_notification_ton_activate_account">Um mit dem Staking in TON zu beginnen, sende zunächst eine kleine Transaktion an Deine eigene Adresse dadurch wird Deine Wallet aktiviert.</string>
<string name="staking_notification_ton_extra_reserve_info">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.</string>
<string name="staking_notification_ton_extra_reserve_is_required">Für diesen Vorgang sind zusätzlich zur Netzwerkgebühr 0,2 TON erforderlich. Bitte lade Dein Guthaben auf.</string>
<string name="staking_notification_ton_extra_reserve_title">TON-Reserve erforderlich</string>
<string name="staking_notification_ton_extra_reserve_info">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.</string>
<string name="staking_notification_ton_extra_reserve_is_required">Für diesen Vorgang sind zusätzlich zur Netzwerkgebühr 0,2 GRAM erforderlich. Bitte lade Dein Guthaben auf.</string>
<string name="staking_notification_ton_extra_reserve_title">GRAM-Reserve erforderlich</string>
<string name="staking_notification_ton_have_to_unstake_all_text">Durch diese Aktion werden andere Positionen geschlossen oder gemäß den Netzwerkregeln in den Auszahlungsstatus versetzt.</string>
<string name="staking_notification_ton_have_to_unstake_all_title">Positionsstatus</string>
<string name="staking_notification_unlock_text">Entsperre dein Geld, um es aus dem Staking-Prozess abzuheben. Das Freischalten nimmt %s.</string>
@ -1803,6 +1825,8 @@
<string name="tangem_pay_transaction_details_mcc">MCC</string>
<string name="tangem_pay_transaction_fee_notification_text">Eine Gebühr wird gemäß den Servicetarifen erhoben</string>
<string name="tangem_pay_transaction_reversed_notification_text">Die Transaktion wurde vom Händler teilweise oder vollständig storniert</string>
<string name="tangem_pay_unavailable_region_description">Wir führen das System schrittweise ein und werden Sie informieren, sobald Tangem Pay hier verfügbar ist.</string>
<string name="tangem_pay_unavailable_region_title">Tangem Pay ist in Ihrer Region nicht verfügbar.</string>
<string name="tangem_pay_unfreeze_card_alert_body">Nutze Dein Geld weiterhin. Du kannst es jederzeit einfrieren.</string>
<string name="tangem_pay_unfreeze_card_alert_title">Karte entsperren?</string>
<string name="tangem_pay_unfreeze_card_failed">Entsperren der Karte fehlgeschlagen. Versuchen Sie es später erneut.</string>
@ -1812,6 +1836,16 @@
<string name="tangempay_account_deactivated_message_title">Ihr Konto wurde geschlossen</string>
<string name="tangempay_account_unable_to_use_rooted">Auf gerooteten Geräten nicht nutzbar.</string>
<string name="tangempay_available_balance">Verfügbares Guthaben</string>
<string name="tangempay_bank_transfer_fee_ach">ACH</string>
<string name="tangempay_bank_transfer_fee_fedwire">FedWire</string>
<string name="tangempay_bank_transfer_fee_header">Gebühr für die Auffahrt</string>
<string name="tangempay_bank_transfer_intro_subtitle">Eingegangene USD werden im Verhältnis 1:1 in USDC umgewandelt.</string>
<string name="tangempay_bank_transfer_intro_title">Eine Banküberweisung kann 1-2 Werktage dauern.</string>
<string name="tangempay_bank_transfer_legal">Durch die Nutzung des Dienstes stimmen Sie den Bedingungen des Anbieters zu. %1$s Und %2$s</string>
<string name="tangempay_bank_transfer_show_details">Details anzeigen</string>
<string name="tangempay_bank_transfer_success_subtitle">Dies kann etwas Zeit in Anspruch nehmen.</string>
<string name="tangempay_bank_transfer_success_title">Vorbereitung Ihrer Bankdaten</string>
<string name="tangempay_bank_transfer_swift_warning">Einzahlungen sind ausschließlich per ACH oder FedWire möglich. SWIFT-Überweisungen werden zurückgebucht.</string>
<string name="tangempay_cancel_kyc">KYC vom Hauptbildschirm ausblenden</string>
<string name="tangempay_card_default_name">Tangem Pay Karte 1</string>
<string name="tangempay_card_details_add_funds">Guthaben hinzufügen</string>
@ -1881,6 +1915,7 @@
<string name="tangempay_change_pin_code">PIN-Code ändern</string>
<string name="tangempay_come_back_if_forget_pin">Kehren Sie zur App zurück, falls Sie ihn vergessen.</string>
<string name="tangempay_common_card">Karte</string>
<string name="tangempay_common_error_loading">Fehler beim Laden</string>
<string name="tangempay_current_plan_change">Tarif wechseln</string>
<string name="tangempay_current_plan_section_card">Kartenbezogen</string>
<string name="tangempay_current_plan_section_plan">Planbezogen</string>
@ -2011,11 +2046,14 @@
<string name="tangempay_tangem_visa_card">Nutzen Sie USDC für alltägliche Zahlungen</string>
<string name="tangempay_temporarily_unavailable">Tangem Pay ist vorübergehend nicht erreichbar.</string>
<string name="tangempay_title">Tangem Pay</string>
<string name="tangempay_topup_bank_transfer_body">Empfangen Sie Fiat-USD per ACH/FedWire</string>
<string name="tangempay_topup_bank_transfer_title">Banküberweisung</string>
<string name="tangempay_topup_receive_body">Senden Sie USDC Polygon an die Adresse Ihres Kontos</string>
<string name="tangempay_topup_receive_title">Von einer anderen Wallet oder Börse</string>
<string name="tangempay_topup_swap_body">Laden Sie Ihr Konto mit einem beliebigen Token aus Ihrer Wallet auf</string>
<string name="tangempay_topup_swap_title">Aus Ihrer Tangem Wallet</string>
<string name="tangempay_usdc_on_polygon_network">USDC im Polygon</string>
<string name="tangempay_visa_benefits">Visavorteile</string>
<string name="tangempay_withdrawal_note_description">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</string>
<string name="tangempay_withdrawal_note_title">Bitte beachten Sie</string>
<string name="tangempay_your_pin_code">Ihr PIN-Code</string>
@ -2057,6 +2095,13 @@
<string name="token_details_unable_hide_alert_title">%s kann nicht ausgeblendet werden</string>
<string name="token_market_metrics_no_data">N / A</string>
<string name="token_receive_show_qr_code_title">QR-Code anzeigen</string>
<string name="token_summary_can_not_load_token">Die Daten des Tokens konnten nicht geladen werden.</string>
<string name="token_summary_go_to_swap_button">Gehe zum Tauschen</string>
<string name="token_summary_last_update_subtitle">Letzte Aktualisierung: %1$s</string>
<string name="token_summary_negative_outlook_title">Negativer Ausblick</string>
<string name="token_summary_neutral_outlook_title">Neutraler Ausblick</string>
<string name="token_summary_positive_outlook_title">Positiver Ausblick</string>
<string name="token_summary_title">Token-Zusammenfassung</string>
<string name="token_swap_changelly_promotion_message">Tausche diesen Token gegen einen anderen zu %1$s Servicegebühren von Februar %2$s-%3$s.</string>
<string name="token_swap_changelly_promotion_title">Tausche mit Changelly, %s Gebühren</string>
<string name="token_swap_promotion_button">Jetzt tauschen</string>
@ -2137,6 +2182,12 @@
<string name="user_wallet_list_rename_popup_title">Wallet umbenennen</string>
<string name="user_wallet_list_unlock_all">Alle freischalten</string>
<string name="user_wallet_list_unlock_all_with">Alle mit %s freischalten</string>
<string name="virtual_account_requisites_account_number">Kontonummer</string>
<string name="virtual_account_requisites_bank_address">Bankadresse</string>
<string name="virtual_account_requisites_bank_name">Name der Bank</string>
<string name="virtual_account_requisites_beneficiary_address">Adresse des Begünstigten</string>
<string name="virtual_account_requisites_beneficiary_name">Name des Begünstigten</string>
<string name="virtual_account_requisites_routing_number">Bankleitzahl</string>
<string name="virtual_account_title">Virtuelles Konto</string>
<string name="virtual_account_transactions_empty">Noch keine Transaktionen. Beginnen Sie mit dem Einkaufen und sehen Sie sich hier den Verlauf an</string>
<string name="visa_balance_limits_details_aml_verified">AML-geprüft</string>
@ -2358,6 +2409,8 @@
<string name="warning_hedera_missing_token_association_message_brief">Dieses Token muss mit deinem Hedera-Konto verknüpft sein, bevor du ihn erhalten kannst.</string>
<string name="warning_hedera_missing_token_association_title">Verknüpfe deinen Token</string>
<string name="warning_hedera_token_association_not_enough_hbar_message">Nicht genug %s. Lade dein Hedera-Konto auf, um dieses Token zuzuordnen</string>
<string name="warning_incomplete_backup_notification_message">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.</string>
<string name="warning_incomplete_backup_notification_title">Handeln ist gefragt. Benutzen Sie nicht Ihr Wallet!</string>
<string name="warning_kaspa_unfinished_token_transaction_discard_message">Bist Du sicher, dass Du die Transaktion stornieren willst? Du kannst die Transaktion nicht erneut starten.</string>
<string name="warning_kaspa_unfinished_token_transaction_message">Deine Transaktion mit einem Betrag von %1$s %2$s wurde nicht abgeschlossen. Du kannst versuchen, diese erneut abzuschließen.</string>
<string name="warning_kaspa_unfinished_token_transaction_title">Du hast eine nicht abgeschlossene Transaktion</string>
@ -2647,6 +2700,8 @@
<string name="yield_module_transaction_exit_subtitle">%1$s aus Aave zurücküberwiesen</string>
<string name="yield_module_transaction_initialize">Yield-Modus initialisieren</string>
<string name="yield_module_transaction_reactivate">Yield-Modus reaktivieren</string>
<string name="yield_module_transaction_returned">Zurückgegeben</string>
<string name="yield_module_transaction_supplied">Geliefert</string>
<string name="yield_module_transaction_topup">Lieferung an Aave</string>
<string name="yield_module_transaction_topup_subtitle">%1$s geliefert an Aave</string>
<string name="yield_module_transaction_withdraw">Abheben von Aave</string>

View file

@ -1288,6 +1288,7 @@
<string name="quick_action_buy_description">Tarjeta de crédito o cuenta bancaria</string>
<string name="quick_action_receive_description">Comparta su dirección o código QR</string>
<string name="quick_action_sell_description">Venda criptomonedas de forma segura</string>
<string name="quick_action_send_and_swap_description">Enviar con intercambio a otro token</string>
<string name="quick_action_send_description">Enviar a otra billetera</string>
<string name="quick_action_swap_description">Entre sus portafolios</string>
<string name="quick_top_up_chip_other">Otro</string>
@ -1580,9 +1581,9 @@
<string name="staking_notification_ton_account_initialization_message">Para empezar a hacer staking, primero debes activar tu cuenta de TON.</string>
<string name="staking_notification_ton_account_initialization_title">Activación de cuenta</string>
<string name="staking_notification_ton_activate_account">Para empezar a hacer staking en TON, realice primero una transacción de salida de cualquier importe - esto activará su billetera.</string>
<string name="staking_notification_ton_extra_reserve_info">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.</string>
<string name="staking_notification_ton_extra_reserve_is_required">Se requieren 0.2 TON para realizar esta operación, además de la tarifa de red. Por favor, recargue su saldo.</string>
<string name="staking_notification_ton_extra_reserve_title">Se requiere reserva de TON</string>
<string name="staking_notification_ton_extra_reserve_info">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.</string>
<string name="staking_notification_ton_extra_reserve_is_required">Se requieren 0.2 GRAM para realizar esta operación, además de la tarifa de red. Por favor, recargue su saldo.</string>
<string name="staking_notification_ton_extra_reserve_title">Se requiere reserva de GRAM</string>
<string name="staking_notification_ton_have_to_unstake_all_text">Esta acción cerrará otras posiciones o las cambiará al estado de retiro, de acuerdo con las reglas de la red.</string>
<string name="staking_notification_ton_have_to_unstake_all_title">Estado de las posiciones</string>
<string name="staking_notification_unlock_text">Desbloquee su dinero para retirarlo del proceso de staking. El desbloqueo demora %s minuto.</string>

View file

@ -94,26 +94,44 @@
<string name="address_book_add_address">Ajouter une adresse</string>
<string name="address_book_add_address_description">Ajouter une adresse et sélectionner un réseau</string>
<string name="address_book_add_contact">Ajouter le contact</string>
<string name="address_book_address_copied">Adresse copiée</string>
<string name="address_book_address_taken_error">Cette adresse est déjà enregistrée sous le nom %1$s</string>
<plurals name="address_book_addresses">
<item quantity="one">adresse</item>
<item quantity="other">adresses</item>
</plurals>
<string name="address_book_choose_address">Choisir une adresse</string>
<string name="address_book_clear_all_networks">Tout supprimer</string>
<string name="address_book_contact">Contact</string>
<string name="address_book_contact_name">Nom du contact</string>
<string name="address_book_copy_address">Copier l\'adresse</string>
<string name="address_book_create_success_message">Contact sauvegardé</string>
<string name="address_book_creating_error">Nous n\'avons pas pu créer le contact. Veuillez réessayer plus tard.</string>
<string name="address_book_delete_contact">Supprimer le contact</string>
<string name="address_book_delete_contact_description">Ce contact sera supprimé de tous vos carnets d\'adresses</string>
<string name="address_book_delete_last_address">\"%1$s\" n\'a qu\'une seule adresse. La supprimer va aussi supprimer le contact. Continuer?</string>
<string name="address_book_deleting_error">Nous n\'avons pas pu supprimer le contact. Veuillez réessayer plus tard.</string>
<string name="address_book_description">Gérer les contacts &amp; adresses</string>
<string name="address_book_discard">Oui, annuler</string>
<string name="address_book_edit_address">Modifier l\'adresse</string>
<string name="address_book_enter_address">Entrer l\'adresse</string>
<string name="address_book_invalid_address_error">Adresse invalide</string>
<string name="address_book_keep_editing">Non, continuer</string>
<string name="address_book_max_networks_alert_description">Vous ne pouvez pas créer plus de 20 adresses. Supprimez-en une pour en ajouter une nouvelle.</string>
<string name="address_book_max_networks_alert_title">Impossible d\'ajouter une nouvelle adresse</string>
<string name="address_book_name_empty_error">Le nom du contact est requis </string>
<string name="address_book_name_invalid_chars_error">Le nom du contact contient des caractères invalides</string>
<string name="address_book_name_max_chars_error">Le nom du contact ne doit pas dépasser 50 caractères</string>
<string name="address_book_name_taken_error">Ce nom est déjà utilisé dans ce portefeuille</string>
<string name="address_book_new_contact">Nouveau contact</string>
<string name="address_book_no_contacts">Aucun contact pour le moment</string>
<string name="address_book_no_contacts_description">Les contacts que vous ajouterez vont apparaître ici</string>
<string name="address_book_remove_address">Supprimer l\'adresse</string>
<string name="address_book_save_contact">Enregistrer le contact</string>
<string name="address_book_save_to_wallet_title">Enregistrer dans le portefeuille</string>
<string name="address_book_save_wallet_to_description">Ce contact va être lié au carnet d\'adresses de ce portefeuille.</string>
<string name="address_book_search_no_results">Aucun résultat.\nEssayez un autre nom</string>
<string name="address_book_select_all_networks">Tout sélectionner</string>
<string name="address_book_select_network">Sélectionner un réseau</string>
<string name="address_book_title">Carnet d\'adresses</string>
<string name="address_book_unsaved_changes">Modifications non enregistrées</string>
@ -1182,6 +1200,7 @@
<string name="qr_scanner_error_unsupported_network_title">Aucun jeton pris en charge n\'a été trouvé</string>
<string name="qr_scanner_warning_unknown_parameters_message">Ce code QR contient des paramètres non reconnus : %s. Si vous continuez, certaines informations de paiement risquent d\'être perdues.</string>
<string name="qr_scanner_warning_unknown_parameters_title">Paramètres inconnus</string>
<string name="quick_action_send_and_swap_description">Envoyer en échangeant vers un autre token</string>
<string name="quick_top_up_title">Recharge rapide</string>
<string name="receive_bottom_sheet_no_memo_required_message">Aucun mémo requis</string>
<string name="receive_bottom_sheet_warning_message">%1$s (%2$s) sur le réseau %3$s</string>
@ -1355,7 +1374,7 @@
<string name="send_validation_invalid_total">Le total dépasse le solde</string>
<string name="send_with_swap_change_token_alert_message">Ê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.</string>
<string name="send_with_swap_change_token_alert_title">Changement de token</string>
<string name="send_with_swap_confirm_title">Échanger et envoyer</string>
<string name="send_with_swap_confirm_title">Échanger et Envoyer</string>
<string name="send_with_swap_convert_token_alert_message">Poursuivre l\'échange ? Cela effacera vos données précédentes.</string>
<string name="send_with_swap_convert_token_alert_title">Confirmer la conversion</string>
<string name="send_with_swap_correct_recipient_network_notification_message">L\'envoi de toute autre crypto entraînera sa perte irréversible.</string>
@ -1459,9 +1478,9 @@
<string name="staking_notification_ton_account_initialization_message">Pour commencer le staking, vous devez dabord activer votre compte TON</string>
<string name="staking_notification_ton_account_initialization_title">Activation du compte</string>
<string name="staking_notification_ton_activate_account">Pour commencer à staker TON, effectuez d\'abord une transaction sortante de n\'importe quel montant — cela activera votre portefeuille.</string>
<string name="staking_notification_ton_extra_reserve_info">Jusqu\'à 0,2 TON peuvent être requis en plus des frais de réseau pour terminer la transaction. Tout montant non utilisé sera remboursé.</string>
<string name="staking_notification_ton_extra_reserve_is_required">0,2 TON requis pour effectuer cette opération, en plus des frais de réseau. Veuillez recharger votre solde.</string>
<string name="staking_notification_ton_extra_reserve_title">Réserve de TON requise</string>
<string name="staking_notification_ton_extra_reserve_info">Jusqu\'à 0,2 GRAM peuvent être requis en plus des frais de réseau pour terminer la transaction. Tout montant non utilisé sera remboursé.</string>
<string name="staking_notification_ton_extra_reserve_is_required">0,2 GRAM requis pour effectuer cette opération, en plus des frais de réseau. Veuillez recharger votre solde.</string>
<string name="staking_notification_ton_extra_reserve_title">Réserve de GRAM requise</string>
<string name="staking_notification_ton_have_to_unstake_all_text">Cette action fermera d\'autres positions ou les fera passer au statut de retrait, conformément aux règles du réseau.</string>
<string name="staking_notification_ton_have_to_unstake_all_title">Statut des positions</string>
<string name="staking_notification_unlock_text">Débloquez votre argent pour le retirer du processus de staking. Le déverrouillage prend %s.</string>
@ -2138,6 +2157,8 @@
<string name="warning_hedera_missing_token_association_message_brief">Ce jeton doit être associé à votre compte Hedera avant que vous puissiez le recevoir</string>
<string name="warning_hedera_missing_token_association_title">Associez votre jeton</string>
<string name="warning_hedera_token_association_not_enough_hbar_message">%s insuffisant. Renflouez votre compte Hedera pour associer ce jeton</string>
<string name="warning_incomplete_backup_notification_message">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.</string>
<string name="warning_incomplete_backup_notification_title">Action requise. N\'utilisez pas votre portefeuille!</string>
<string name="warning_kaspa_unfinished_token_transaction_discard_message">Êtes-vous sûr(e) de vouloir annuler la transaction ? Vous ne pourrez plus réessayer.</string>
<string name="warning_kaspa_unfinished_token_transaction_message">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.</string>
<string name="warning_kaspa_unfinished_token_transaction_title">Vous avez une transaction inachevée</string>

View file

@ -98,14 +98,20 @@
<string name="address_book_add_address">アドレスを追加</string>
<string name="address_book_add_address_description">アドレスを追加し、ネットワークを選択してください。</string>
<string name="address_book_add_contact">連絡先を追加</string>
<string name="address_book_address_copied">アドレスをコピーしました</string>
<string name="address_book_address_taken_error">このアドレスはすでに%1$sとして保存されています</string>
<plurals name="address_book_addresses">
<item quantity="other">%d件のアドレス</item>
</plurals>
<string name="address_book_choose_address">アドレスを選択</string>
<string name="address_book_contact">連絡先</string>
<string name="address_book_contact_name">連絡先名</string>
<string name="address_book_copy_address">アドレスをコピー</string>
<string name="address_book_create_success_message">連絡先を保存しました</string>
<string name="address_book_creating_error">連絡先を作成できませんでした。しばらくしてからもう一度お試しください。</string>
<string name="address_book_delete_contact">連絡先を削除</string>
<string name="address_book_delete_contact_description">この連絡先は、すべてのアドレス帳から削除されます。</string>
<string name="address_book_delete_last_address">「%1$s」には1つのアドレスしかありません。削除すると、連絡先も削除されます。続行しますか</string>
<string name="address_book_deleting_error">連絡先を削除できませんでした。しばらくしてからもう一度お試しください。</string>
<string name="address_book_description">連絡先とアドレスを管理</string>
<string name="address_book_discard">破棄</string>
@ -113,11 +119,20 @@
<string name="address_book_enter_address">アドレスを入力</string>
<string name="address_book_invalid_address_error">無効なアドレス</string>
<string name="address_book_keep_editing">編集を続ける</string>
<string name="address_book_max_networks_alert_description">20件を超えるアドレスは作成できません。新しく追加するには、既存のアドレスを1件削除してください。</string>
<string name="address_book_max_networks_alert_title">新しいアドレスを追加できません</string>
<string name="address_book_name_empty_error">連絡先名を入力してください</string>
<string name="address_book_name_invalid_chars_error">連絡先名に使用できない文字が含まれています</string>
<string name="address_book_name_max_chars_error">連絡先名は50文字以内で入力してください</string>
<string name="address_book_name_taken_error">その名前はこのウォレットですでに使用されています</string>
<string name="address_book_new_contact">新しい連絡先</string>
<string name="address_book_no_contacts">連絡先はまだありません</string>
<string name="address_book_no_contacts_description">追加した連絡先はここに表示されます。</string>
<string name="address_book_remove_address">アドレスを削除</string>
<string name="address_book_save_contact">連絡先を保存</string>
<string name="address_book_save_to_wallet_title">ウォレットに保存</string>
<string name="address_book_save_wallet_to_description">この連絡先は、このウォレットのアドレス帳に紐付けられます。</string>
<string name="address_book_search_no_results">結果が見つかりませんでした。\n別の名前を試してください。</string>
<string name="address_book_select_network">ネットワークを選択</string>
<string name="address_book_title">連絡先</string>
<string name="address_book_unsaved_changes">保存されていない変更</string>
@ -1269,6 +1284,7 @@
<string name="quick_action_buy_description">クレジットカードまたは銀行口座</string>
<string name="quick_action_receive_description">アドレスまたはQRコードを共有してください</string>
<string name="quick_action_sell_description">暗号資産を安全に売却</string>
<string name="quick_action_send_and_swap_description">別のトークンにスワップして送る</string>
<string name="quick_action_send_description">別のウォレットに送信</string>
<string name="quick_action_swap_description">ポートフォリオ間で</string>
<string name="quick_top_up_chip_other">その他</string>
@ -1558,9 +1574,9 @@
<string name="staking_notification_ton_account_initialization_message">ステーキングを開始するには、まずTONアカウントを有効化してください。</string>
<string name="staking_notification_ton_account_initialization_title">アカウントの有効化</string>
<string name="staking_notification_ton_activate_account">TONのステーキングを開始するには、まず自分のアドレスに少額の取引を送信します。これによりウォレットが有効になります。</string>
<string name="staking_notification_ton_extra_reserve_info">取引を完了するには、ネットワーク手数料に加えて最大0.2TONが必要になる場合があります。未使用分は返金されます。</string>
<string name="staking_notification_ton_extra_reserve_is_required">この操作を続行するには、ネットワーク手数料に加えて0.2 TONが必要です。残高を補充してください。</string>
<string name="staking_notification_ton_extra_reserve_title">TONの準備金が必要です</string>
<string name="staking_notification_ton_extra_reserve_info">取引を完了するには、ネットワーク手数料に加えて最大0.2GRAMが必要になる場合があります。未使用分は返金されます。</string>
<string name="staking_notification_ton_extra_reserve_is_required">この操作を続行するには、ネットワーク手数料に加えて0.2 GRAMが必要です。残高を補充してください。</string>
<string name="staking_notification_ton_extra_reserve_title">GRAMの準備金が必要です</string>
<string name="staking_notification_ton_have_to_unstake_all_text">このアクションは、ネットワークのルールに従って、他のポジションをクローズするか、引き出しステータスに切り替えます。</string>
<string name="staking_notification_ton_have_to_unstake_all_title">ポジション状況</string>
<string name="staking_notification_unlock_text">資金をステーキングから引き出すには、ロックを解除してください。ロック解除には%sかかります。</string>

View file

@ -105,6 +105,7 @@
<item quantity="other">%d endereços</item>
</plurals>
<string name="address_book_choose_address">Escolha o endereço</string>
<string name="address_book_clear_all_networks">Limpar tudo</string>
<string name="address_book_contact">Contato</string>
<string name="address_book_contact_name">Nome do contato</string>
<string name="address_book_copy_address">Copiar endereço</string>
@ -134,6 +135,7 @@
<string name="address_book_save_to_wallet_title">Salvar na carteira</string>
<string name="address_book_save_wallet_to_description">Este contato será vinculado à agenda de endereços desta carteira.</string>
<string name="address_book_search_no_results">Nenhum resultado encontrado.\nTente outro nome</string>
<string name="address_book_select_all_networks">Selecionar tudo</string>
<string name="address_book_select_network">Selecione a rede</string>
<string name="address_book_title">Agenda de endereços</string>
<string name="address_book_unsaved_changes">Alterações não salvas</string>
@ -1174,13 +1176,13 @@
<string name="onboarding_navbar_upgrade_wallet_biometrics">Biometria</string>
<string name="onboarding_seed_button_read_more">Leia mais sobre a frase-semente.</string>
<plurals name="onboarding_seed_generate_message_words_count">
<item quantity="one">Escreva esta 1palavra na ordem indicada abaixo e guarde-a em um local seguro e secreto.</item>
<item quantity="other">Escreva estas %dpalavras na ordem indicada abaixo e guarde-as em um local seguro e secreto.</item>
<item quantity="one">Escreva esta %d palavra na ordem indicada abaixo e guarde-a em um local seguro e secreto.</item>
<item quantity="other">Escreva estas %d palavras na ordem indicada abaixo e guarde-as em um local seguro e secreto.</item>
</plurals>
<string name="onboarding_seed_generate_title">Sua frase-semente</string>
<plurals name="onboarding_seed_generate_words_count">
<item quantity="one">%dpalavra</item>
<item quantity="other">%dpalavras</item>
<item quantity="one">%d palavra</item>
<item quantity="other">%d palavras</item>
</plurals>
<string name="onboarding_seed_import_message">Para importar sua carteira, insira sua frase mnemônica no campo abaixo.</string>
<string name="onboarding_seed_intro_button_generate">Gerar frase-semente</string>
@ -1284,6 +1286,8 @@
<string name="onramp_title_available_from">Disponível em</string>
<string name="onramp_title_available_up_to">Disponível até</string>
<string name="onramp_title_you_get">Você recebe</string>
<string name="onramp_token_is_not_supported_banner_subtitle">Este token não é compatível. Escolha outro token para comprar.</string>
<string name="onramp_token_is_not_supported_banner_title">%s não é suportado</string>
<string name="onramp_tos_external_providers">O serviço é fornecido por um provedor externo. A Tangem não se responsabiliza por ele.</string>
<string name="onramp_transaction_status_footer_text">Você pode fechar esta tela e verificar o status da transação na tela de detalhes do token.</string>
<string name="onramp_up_to_rate">Até</string>
@ -1296,6 +1300,19 @@
<string name="organize_tokens_sort_by_balance">Por equilíbrio</string>
<string name="organize_tokens_title">Organizar tokens</string>
<string name="organize_tokens_ungroup">Desagrupar</string>
<string name="promo_campaign_already_activated_subtitle">O cashback elegível será distribuído para:</string>
<string name="promo_campaign_already_activated_title">Você já está matriculado(a) em %1$s</string>
<string name="promo_campaign_eligible_tokens">Tokens elegíveis</string>
<string name="promo_campaign_enroll">Inscreva-se</string>
<string name="promo_campaign_enroll_success_title">Você se inscreveu com sucesso em %1$s</string>
<string name="promo_campaign_not_active_subtitle">Esta campanha não existe mais ou expirou.</string>
<string name="promo_campaign_not_active_title">Campanha inativa</string>
<string name="promo_campaign_select_cashback_account">Selecione a conta de cashback</string>
<string name="promo_campaign_summary_title">Inscreva-se em %1$s</string>
<string name="promo_campaign_terms_agreement">Concordo com %1$s</string>
<string name="promo_campaign_terms_agreement_android">Concordo com</string>
<string name="promo_campaign_terms_link">%1$s Termos</string>
<string name="promo_campaign_whale_swap_summary_description">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.</string>
<string name="provider_name_support">%s suporte</string>
<string name="push_notification_settings_banner_description">As notificações push estão ativadas, mas só funcionarão depois que você as permitir nas configurações do seu dispositivo.</string>
<string name="push_notification_settings_banner_title">Permitir notificações</string>
@ -1627,9 +1644,9 @@
<string name="staking_notification_ton_account_initialization_message">Para começar a fazer staking, você precisa ativar sua conta TON primeiro.</string>
<string name="staking_notification_ton_account_initialization_title">Ativação de conta</string>
<string name="staking_notification_ton_activate_account">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.</string>
<string name="staking_notification_ton_extra_reserve_info">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.</string>
<string name="staking_notification_ton_extra_reserve_is_required">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.</string>
<string name="staking_notification_ton_extra_reserve_title">Reserva TON necessária</string>
<string name="staking_notification_ton_extra_reserve_info">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.</string>
<string name="staking_notification_ton_extra_reserve_is_required">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.</string>
<string name="staking_notification_ton_extra_reserve_title">Reserva GRAM necessária</string>
<string name="staking_notification_ton_have_to_unstake_all_text">Essa ação encerrará outras posições ou as converterá para o status de saque, de acordo com as regras da rede.</string>
<string name="staking_notification_ton_have_to_unstake_all_title">Status das posições</string>
<string name="staking_notification_unlock_text">Desbloqueie seu dinheiro para retirá-lo do processo de staking. O desbloqueio leva... %s.</string>
@ -1819,6 +1836,16 @@
<string name="tangempay_account_deactivated_message_title">Sua conta foi encerrada</string>
<string name="tangempay_account_unable_to_use_rooted">Não é possível usar em dispositivos com root.</string>
<string name="tangempay_available_balance">Saldo disponível</string>
<string name="tangempay_bank_transfer_fee_ach">ACH</string>
<string name="tangempay_bank_transfer_fee_fedwire">FedWire</string>
<string name="tangempay_bank_transfer_fee_header">Taxa de acesso à rampa</string>
<string name="tangempay_bank_transfer_intro_subtitle">Os USD recebidos serão convertidos para USDC na proporção de 1:1.</string>
<string name="tangempay_bank_transfer_intro_title">A transferência bancária pode levar de 1 a 2 dias úteis</string>
<string name="tangempay_bank_transfer_legal">Ao utilizar o serviço, você concorda com o provedor. %1$s e %2$s</string>
<string name="tangempay_bank_transfer_show_details">Mostrar detalhes</string>
<string name="tangempay_bank_transfer_success_subtitle">Isso pode levar um pouco de tempo.</string>
<string name="tangempay_bank_transfer_success_title">Preparando seus dados bancários</string>
<string name="tangempay_bank_transfer_swift_warning">Depósito somente via ACH ou FedWire. Transferências SWIFT serão devolvidas.</string>
<string name="tangempay_cancel_kyc">Ocultar KYC da tela principal</string>
<string name="tangempay_card_default_name">Cartão Tangem Pay 1</string>
<string name="tangempay_card_details_add_funds">Adicionar fundos</string>
@ -1888,6 +1915,7 @@
<string name="tangempay_change_pin_code">Alterar código PIN</string>
<string name="tangempay_come_back_if_forget_pin">Volte ao aplicativo se você se esquecer.</string>
<string name="tangempay_common_card">Cartão</string>
<string name="tangempay_common_error_loading">Erro ao carregar</string>
<string name="tangempay_current_plan_change">Alterar plano</string>
<string name="tangempay_current_plan_section_card">relacionado a cartões</string>
<string name="tangempay_current_plan_section_plan">Plano relacionado</string>
@ -2018,11 +2046,14 @@
<string name="tangempay_tangem_visa_card">Use USDC para pagamentos do dia a dia.</string>
<string name="tangempay_temporarily_unavailable">O serviço Tangem Pay está temporariamente inacessível.</string>
<string name="tangempay_title">Tangem Pay</string>
<string name="tangempay_topup_bank_transfer_body">Receba USD fiduciário via ACH/FedWire</string>
<string name="tangempay_topup_bank_transfer_title">Transferência bancária</string>
<string name="tangempay_topup_receive_body">Envie USDC Polygon para o endereço da sua conta</string>
<string name="tangempay_topup_receive_title">De outra carteira ou exchange</string>
<string name="tangempay_topup_swap_body">Recarregue sua conta com qualquer token da carteira</string>
<string name="tangempay_topup_swap_title">Da sua Tangem Wallet</string>
<string name="tangempay_usdc_on_polygon_network">USDC na rede Polygon</string>
<string name="tangempay_visa_benefits">Benefícios do visto</string>
<string name="tangempay_withdrawal_note_description">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</string>
<string name="tangempay_withdrawal_note_title">Observe</string>
<string name="tangempay_your_pin_code">Seu código PIN</string>
@ -2064,6 +2095,13 @@
<string name="token_details_unable_hide_alert_title">Não foi possível ocultar %s</string>
<string name="token_market_metrics_no_data">N/A</string>
<string name="token_receive_show_qr_code_title">Mostrar código QR</string>
<string name="token_summary_can_not_load_token">Não foi possível carregar os dados do token.</string>
<string name="token_summary_go_to_swap_button">Ir para o swap</string>
<string name="token_summary_last_update_subtitle">Última atualização: %1$s</string>
<string name="token_summary_negative_outlook_title">Perspectiva negativa</string>
<string name="token_summary_neutral_outlook_title">Perspectiva neutra</string>
<string name="token_summary_positive_outlook_title">Perspectiva positiva</string>
<string name="token_summary_title">Resumo do token</string>
<string name="token_swap_changelly_promotion_message">Troque este token por outro em %1$s taxas de serviço a partir de fevereiro %2$s-%3$s.</string>
<string name="token_swap_changelly_promotion_title">Trocar com Changelly, %s tarifas</string>
<string name="token_swap_promotion_button">Troque agora</string>
@ -2144,6 +2182,12 @@
<string name="user_wallet_list_rename_popup_title">Renomear carteira</string>
<string name="user_wallet_list_unlock_all">Desbloquear tudo</string>
<string name="user_wallet_list_unlock_all_with">Desbloqueie tudo com %s</string>
<string name="virtual_account_requisites_account_number">Número de conta</string>
<string name="virtual_account_requisites_bank_address">Endereço do banco</string>
<string name="virtual_account_requisites_bank_name">Nome do banco</string>
<string name="virtual_account_requisites_beneficiary_address">Endereço do beneficiário</string>
<string name="virtual_account_requisites_beneficiary_name">Nome do beneficiário</string>
<string name="virtual_account_requisites_routing_number">Número de roteamento</string>
<string name="virtual_account_title">Conta virtual</string>
<string name="virtual_account_transactions_empty">Ainda não há transações. Comece a gastar e veja o histórico aqui.</string>
<string name="visa_balance_limits_details_aml_verified">Verificado AML</string>
@ -2656,6 +2700,8 @@
<string name="yield_module_transaction_exit_subtitle">%1$s retirado de Aave</string>
<string name="yield_module_transaction_initialize">Modo de rendimento inicializado</string>
<string name="yield_module_transaction_reactivate">Modo de rendimento reativado</string>
<string name="yield_module_transaction_returned">Devolvido</string>
<string name="yield_module_transaction_supplied">Fornecido</string>
<string name="yield_module_transaction_topup">Fornecimento para Aave</string>
<string name="yield_module_transaction_topup_subtitle">%1$s fornecido à Aave</string>
<string name="yield_module_transaction_withdraw">Retirar do Aave</string>

View file

@ -81,7 +81,7 @@
<string name="action_buttons_you_want_to_receive">Выберите токен для получения</string>
<string name="action_buttons_you_want_to_swap">Выберите токен для обмена</string>
<string name="actionbutton_addfunds_title">Пополнить</string>
<string name="actionbutton_swap_title">Обмен</string>
<string name="actionbutton_swap_title">Обменять</string>
<string name="actionbutton_transfer_title">Перевести</string>
<string name="add_and_manage_sheet_manage_subtitle">Добавить в портфель</string>
<string name="add_and_manage_sheet_manage_title">Добавить токены</string>
@ -98,17 +98,24 @@
<string name="address_book_add_address">Добавить адрес</string>
<string name="address_book_add_address_description">Добавить адрес и выбрать сеть</string>
<string name="address_book_add_contact">Добавить контакт</string>
<string name="address_book_address_copied">Адрес скопирован</string>
<string name="address_book_address_taken_error">Этот адрес уже сохранен как %1$s</string>
<plurals name="address_book_addresses">
<item quantity="one">адрес</item>
<item quantity="few">адреса</item>
<item quantity="many">адресов</item>
<item quantity="other">адресов</item>
<item quantity="one">%d адрес</item>
<item quantity="few">%d адреса</item>
<item quantity="many">%d адресов</item>
<item quantity="other">%d адресов</item>
</plurals>
<string name="address_book_choose_address">Выберите адрес</string>
<string name="address_book_clear_all_networks">Очистить все</string>
<string name="address_book_contact">Контакт</string>
<string name="address_book_contact_name">Имя контакта</string>
<string name="address_book_copy_address">Копировать адрес</string>
<string name="address_book_create_success_message">Контакт сохранен</string>
<string name="address_book_creating_error">Не удалось создать контакт. Пожалуйста, попробуйте позже.</string>
<string name="address_book_delete_contact">Удалить контакт</string>
<string name="address_book_delete_contact_description">Этот контакт будет удален из всех ваших адресных книг</string>
<string name="address_book_delete_last_address">У \"%1$s\" только один адрес. Его удаление также приведет к удалению контакта. Продолжить?</string>
<string name="address_book_deleting_error">Не удалось удалить контакт. Пожалуйста, попробуйте позже.</string>
<string name="address_book_description">Управление контактами и адресами</string>
<string name="address_book_discard">Отменить</string>
@ -116,11 +123,21 @@
<string name="address_book_enter_address">Ввести адрес</string>
<string name="address_book_invalid_address_error">Неверный адрес</string>
<string name="address_book_keep_editing">Продолжить</string>
<string name="address_book_max_networks_alert_description">Вы не можете создать более 20 адресов. Удалите один, чтобы добавить новый.</string>
<string name="address_book_max_networks_alert_title">Невозможно добавить новый адрес</string>
<string name="address_book_name_empty_error">Имя контакта обязательно</string>
<string name="address_book_name_invalid_chars_error">Имя контакта содержит недопустимые символы</string>
<string name="address_book_name_max_chars_error">Имя контакта должно содержать не более 50 символов</string>
<string name="address_book_name_taken_error">Это имя уже используется в этом кошельке</string>
<string name="address_book_new_contact">Новый контакт</string>
<string name="address_book_no_contacts">Нет добавленных контактов</string>
<string name="address_book_no_contacts_description">Здесь отобразятся добавленные вами контакты.</string>
<string name="address_book_remove_address">Удалить адрес</string>
<string name="address_book_save_contact">Сохранить контакт</string>
<string name="address_book_save_to_wallet_title">Сохранить в кошелек</string>
<string name="address_book_save_wallet_to_description">Этот контакт будет привязан к этому кошельку в адресной книге.</string>
<string name="address_book_search_no_results">Ничего не найдено.\nПопробуйте другое имя</string>
<string name="address_book_select_all_networks">Выбрать все</string>
<string name="address_book_select_network">Выбрать сеть</string>
<string name="address_book_title">Адресная книга</string>
<string name="address_book_unsaved_changes">Несохраненные изменения</string>
@ -1335,6 +1352,7 @@
<string name="quick_action_buy_description">Банковская карта или банковский счет</string>
<string name="quick_action_receive_description">Поделитесь своим адресом или QR-кодом</string>
<string name="quick_action_sell_description">Продавайте криптовалюту безопасно</string>
<string name="quick_action_send_and_swap_description">Отправить с обменом на другой токен</string>
<string name="quick_action_send_description">Отправить на другой кошелек</string>
<string name="quick_action_swap_description">Между вашими портфелями</string>
<string name="quick_top_up_chip_other">Другие</string>
@ -1526,7 +1544,7 @@
<string name="send_validation_invalid_total">Отправляемая сумма превышает остаток</string>
<string name="send_with_swap_change_token_alert_message">Вы уверены, что хотите изменить токен для получения? Это действие сбросит ранее введённые данные.</string>
<string name="send_with_swap_change_token_alert_title">Изменение токена</string>
<string name="send_with_swap_confirm_title">Обмен и отправка</string>
<string name="send_with_swap_confirm_title">Обменять и отправить</string>
<string name="send_with_swap_convert_token_alert_message">Продолжить с обменом? Это действие удалит предыдущие данные</string>
<string name="send_with_swap_convert_token_alert_title">Подтвердить конвертацию</string>
<string name="send_with_swap_correct_recipient_network_notification_message">Отправка любой другой валюты приведёт к её безвозвратной потере.</string>
@ -1631,9 +1649,9 @@
<string name="staking_notification_ton_account_initialization_message">Чтобы начать стейкинг, сначала активируйте свой TON-аккаунт.</string>
<string name="staking_notification_ton_account_initialization_title">Активация аккаунта</string>
<string name="staking_notification_ton_activate_account">Чтобы начать стейкинг в TON, сначала отправьте небольшую транзакцию на свой же адрес — это активирует ваш кошелёк.</string>
<string name="staking_notification_ton_extra_reserve_info">До 0.2 TON может потребоваться сверх сетевой комиссии для завершения транзакции. Неиспользованная часть будет возвращена.</string>
<string name="staking_notification_ton_extra_reserve_is_required">Для выполнения операции требуется дополнительно 0.2 TON, помимо сетевой комиссии. Пожалуйста, пополните баланс.</string>
<string name="staking_notification_ton_extra_reserve_title">Требуется резерв TON</string>
<string name="staking_notification_ton_extra_reserve_info">До 0.2 GRAM может потребоваться сверх сетевой комиссии для завершения транзакции. Неиспользованная часть будет возвращена.</string>
<string name="staking_notification_ton_extra_reserve_is_required">Для выполнения операции требуется дополнительно 0.2 GRAM, помимо сетевой комиссии. Пожалуйста, пополните баланс.</string>
<string name="staking_notification_ton_extra_reserve_title">Требуется резерв GRAM</string>
<string name="staking_notification_ton_have_to_unstake_all_text">Это действие закроет другие позиции или переведёт их в статус вывода средств в соответствии с правилами сети.</string>
<string name="staking_notification_ton_have_to_unstake_all_title">Статус позиций</string>
<string name="staking_notification_unlock_text">Разблокируйте свои средства, чтобы вывести их из стейкинга. Разблокировка займёт %s.</string>
@ -2047,7 +2065,7 @@
<string name="token_details_token_type_subtitle_no_standard">Токен в сети %%image%% %1$s</string>
<string name="token_details_toolbar_subtitle_network">%s сеть</string>
<string name="token_details_toolbar_subtitle_standard">%1$s в сети %2$s</string>
<string name="token_details_toolbar_title_token_in_account">%1$sв %%image%% %2$s</string>
<string name="token_details_toolbar_title_token_in_account">%1$s в %%image%% %2$s</string>
<string name="token_details_toolbar_title_token_in_wallet">%1$s в %2$s %%image%%</string>
<string name="token_details_unable_hide_alert_message">Токен %1$s (%2$s) является основной валютой в сети %3$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети</string>
<string name="token_details_unable_hide_alert_title">Невозможно скрыть %s</string>
@ -2292,6 +2310,8 @@
<string name="warning_hedera_missing_token_association_message_brief">Этот токен должен быть ассоциирован с вашей учетной записью Hedera, прежде чем вы сможете его принять</string>
<string name="warning_hedera_missing_token_association_title">Ассоциируете свой токен</string>
<string name="warning_hedera_token_association_not_enough_hbar_message">Недостаточно %s. Пополните ваш аккаунт Hedera для ассоциации этого токена</string>
<string name="warning_incomplete_backup_notification_message">Мы обнаружили, что процесс активации карты не завершен должным образом из-за проблем с NFC-модулем устройства или неправильного прикладывания карт к телефону. Пожалуйста, обратитесь в службу поддержки для уточнения деталей.</string>
<string name="warning_incomplete_backup_notification_title">Требуется действие. Не используйте кошелек!</string>
<string name="warning_kaspa_unfinished_token_transaction_discard_message">Вы уверены что хотите отменить эту транзакцию? Вы не сможете отправить ее еще раз</string>
<string name="warning_kaspa_unfinished_token_transaction_message">Ваша транзакция %1$s %2$s не была завершена. Вы можете попробовать отправить ее еще раз.</string>
<string name="warning_kaspa_unfinished_token_transaction_title">У вас есть незавершенная транзакция</string>

View file

@ -81,7 +81,7 @@
<string name="action_buttons_you_want_to_receive">Оберіть токен для отримання</string>
<string name="action_buttons_you_want_to_swap">Оберіть токен для обміну</string>
<string name="actionbutton_addfunds_title">Поповнити</string>
<string name="actionbutton_swap_title">Обмін</string>
<string name="actionbutton_swap_title">Обміняти</string>
<string name="actionbutton_transfer_title">Переказ</string>
<string name="add_and_manage_sheet_manage_subtitle">Додати до портфеля</string>
<string name="add_and_manage_sheet_manage_title">Додати токени</string>
@ -98,17 +98,24 @@
<string name="address_book_add_address">Додати адресу</string>
<string name="address_book_add_address_description">Додати адресу та вибрати мережу</string>
<string name="address_book_add_contact">Додати контакт</string>
<string name="address_book_address_copied">Адресу скопійовано</string>
<string name="address_book_address_taken_error">Цю адресу вже збережено як %1$s</string>
<plurals name="address_book_addresses">
<item quantity="one">адреса</item>
<item quantity="few">адреси</item>
<item quantity="many">адрес</item>
<item quantity="other">адрес</item>
<item quantity="one">%d адреса</item>
<item quantity="few">%d адреси</item>
<item quantity="many">%d адрес</item>
<item quantity="other">%d адрес</item>
</plurals>
<string name="address_book_choose_address">Оберіть адресу</string>
<string name="address_book_clear_all_networks">Очистити все</string>
<string name="address_book_contact">Контакт</string>
<string name="address_book_contact_name">Ім\'я контакту</string>
<string name="address_book_copy_address">Скопіювати адресу</string>
<string name="address_book_create_success_message">Контакт збережено</string>
<string name="address_book_creating_error">Не вдалося створити контакт. Будь ласка, спробуйте пізніше.</string>
<string name="address_book_delete_contact">Видалити контакт</string>
<string name="address_book_delete_contact_description">Цей контакт буде видалено з усіх ваших адресних книг</string>
<string name="address_book_delete_last_address">У \"%1$s\" лише одна адреса. Її видалення також призведе до видалення контакту. Продовжити?</string>
<string name="address_book_deleting_error">Не вдалося видалити контакт. Будь ласка, спробуйте пізніше.</string>
<string name="address_book_description">Керування контактами та адресами</string>
<string name="address_book_discard">Скасувати</string>
@ -116,11 +123,21 @@
<string name="address_book_enter_address">Ввести адресу</string>
<string name="address_book_invalid_address_error">Недійсна адреса</string>
<string name="address_book_keep_editing">Продовжити</string>
<string name="address_book_max_networks_alert_description">Ви не можете створити більше 20 адрес. Видаліть одну, щоб додати нову.</string>
<string name="address_book_max_networks_alert_title">Неможливо додати нову адресу</string>
<string name="address_book_name_empty_error">Ім\'я контакту обов\'язкове</string>
<string name="address_book_name_invalid_chars_error">Ім\'я контакту містить недопустимі символи</string>
<string name="address_book_name_max_chars_error">Ім\'я контакту повинно містити не більше 50 символів</string>
<string name="address_book_name_taken_error">Це ім\'я вже використовується в цьому гаманці</string>
<string name="address_book_new_contact">Новий контакт</string>
<string name="address_book_no_contacts">Немає доданих контактів</string>
<string name="address_book_no_contacts_description">Тут відображатимуться додані вами контакти.</string>
<string name="address_book_remove_address">Видалити адресу</string>
<string name="address_book_save_contact">Зберегти контакт</string>
<string name="address_book_save_to_wallet_title">Зберегти в гаманець</string>
<string name="address_book_save_wallet_to_description">Цей контакт буде прив\'язано до цього гаманця в адресній книзі.</string>
<string name="address_book_search_no_results">Нічого не знайдено.\nСпробуйте інше ім\'я</string>
<string name="address_book_select_all_networks">Вибрати все</string>
<string name="address_book_select_network">Вибрати мережу</string>
<string name="address_book_title">Адресна книга</string>
<string name="address_book_unsaved_changes">Незбережені зміни</string>
@ -1335,6 +1352,7 @@
<string name="quick_action_buy_description">Банківська картка або банківський рахунок</string>
<string name="quick_action_receive_description">Поділіться своєю адресою або QR-кодом</string>
<string name="quick_action_sell_description">Безпечно продавайте криптовалюту</string>
<string name="quick_action_send_and_swap_description">Надіслати з обміном на інший токен</string>
<string name="quick_action_send_description">Надіслати на інший гаманець</string>
<string name="quick_action_swap_description">Між вашими портфелями</string>
<string name="quick_top_up_chip_other">Інші</string>
@ -1526,7 +1544,7 @@
<string name="send_validation_invalid_total">Сума, що відправляється, перевищує залишок</string>
<string name="send_with_swap_change_token_alert_message">Ви впевнені, що хочете змінити токен отримання? Це призведе до скидання раніше введених даних.</string>
<string name="send_with_swap_change_token_alert_title">Зміна токену</string>
<string name="send_with_swap_confirm_title">Обмін та надсилання</string>
<string name="send_with_swap_confirm_title">Обміняти та надіслати</string>
<string name="send_with_swap_convert_token_alert_message">Продовжити обмін? Це очистить ваші попередні дані.</string>
<string name="send_with_swap_convert_token_alert_title">Підтвердити конвертацію</string>
<string name="send_with_swap_correct_recipient_network_notification_message">Надсилання будь-якої іншої валюти призведе до її незворотної втрати.</string>
@ -1631,9 +1649,9 @@
<string name="staking_notification_ton_account_initialization_message">Щоб розпочати стейкінг, спочатку активуйте свій TON-акаунт.</string>
<string name="staking_notification_ton_account_initialization_title">Активація акаунту</string>
<string name="staking_notification_ton_activate_account">Щоб почати стейкінг в TON, спочатку здійсніть вихідну транзакцію на будь-яку суму — це активує ваш гаманець.</string>
<string name="staking_notification_ton_extra_reserve_info">До 0.2 TON може знадобитися додатково мережевої комісії для завершення транзакції. Невикористана частина буде повернута.</string>
<string name="staking_notification_ton_extra_reserve_is_required">Для виконання операції потребується додатково 0.2 TON, крім мережевої комісії. Будь ласка, поповніть баланс.</string>
<string name="staking_notification_ton_extra_reserve_title">Потрібен резерв TON</string>
<string name="staking_notification_ton_extra_reserve_info">До 0.2 GRAM може знадобитися додатково мережевої комісії для завершення транзакції. Невикористана частина буде повернута.</string>
<string name="staking_notification_ton_extra_reserve_is_required">Для виконання операції потребується додатково 0.2 GRAM, крім мережевої комісії. Будь ласка, поповніть баланс.</string>
<string name="staking_notification_ton_extra_reserve_title">Потрібен резерв GRAM</string>
<string name="staking_notification_ton_have_to_unstake_all_text">Ця дія закриє інші позиції або переведе їх у статус виведення, відповідно до правил мережі.</string>
<string name="staking_notification_ton_have_to_unstake_all_title">Статус позицій</string>
<string name="staking_notification_unlock_text">Розблокуйте свої кошти, щоб вивести їх зі стейкінгу. Розблокування займе %s.</string>
@ -2307,6 +2325,8 @@
<string name="warning_hedera_missing_token_association_message_brief">Цей токен повинен бути асоційований з вашим обліковим записом Hedera, перш ніж ви зможете його прийняти</string>
<string name="warning_hedera_missing_token_association_title">Асоціюйте свій токен</string>
<string name="warning_hedera_token_association_not_enough_hbar_message">Недостатньо %s. Поповніть ваш обліковий запис Hedera для асоціації цього токена</string>
<string name="warning_incomplete_backup_notification_message">Ми виявили, що процес активації картки не завершено належним чином через проблеми з NFC-модулем пристрою або неправильне прикладання карток до телефону. Будь ласка, зверніться до служби підтримки для уточнення деталей.</string>
<string name="warning_incomplete_backup_notification_title">Потрібна дія. Не використовуйте гаманець!</string>
<string name="warning_kaspa_unfinished_token_transaction_discard_message">Ви впевнені, що хочете скасувати цю транзакцію? Ви не зможете відправити її ще раз</string>
<string name="warning_kaspa_unfinished_token_transaction_message">Ваша транзакція %1$s %2$s не була завершена. Ви можете спробувати відправити її ще раз.</string>
<string name="warning_kaspa_unfinished_token_transaction_title">У вас є незавершена транзакція</string>

View file

@ -98,14 +98,21 @@
<string name="address_book_add_address">添加地址</string>
<string name="address_book_add_address_description">添加地址并选择网络</string>
<string name="address_book_add_contact">添加联系人</string>
<string name="address_book_address_copied">地址已复制</string>
<string name="address_book_address_taken_error">此地址已保存为 %1$s</string>
<plurals name="address_book_addresses" formatted="false">
<item quantity="other">%d地址\n%d地址</item>
</plurals>
<string name="address_book_choose_address">选择地址</string>
<string name="address_book_clear_all_networks">全部清除</string>
<string name="address_book_contact">联系人</string>
<string name="address_book_contact_name">联系人姓名</string>
<string name="address_book_copy_address">复制地址</string>
<string name="address_book_create_success_message">联系人已保存</string>
<string name="address_book_creating_error">无法创建联系人。请稍后再试。</string>
<string name="address_book_delete_contact">删除联系人</string>
<string name="address_book_delete_contact_description">该联系人将从您所有的通讯录中删除</string>
<string name="address_book_delete_last_address">“%1$s“只有一个地址。删除此地址也会删除联系人。继续吗</string>
<string name="address_book_deleting_error">无法删除联系人,请稍后再试。</string>
<string name="address_book_description">管理联系人及地址</string>
<string name="address_book_discard">取消</string>
@ -113,11 +120,21 @@
<string name="address_book_enter_address">输入地址</string>
<string name="address_book_invalid_address_error">无效地址</string>
<string name="address_book_keep_editing">继续编辑</string>
<string name="address_book_max_networks_alert_description">您最多只能创建 20 个地址。删除一个地址即可添加新地址。</string>
<string name="address_book_max_networks_alert_title">无法添加新地址</string>
<string name="address_book_name_empty_error">必须填写联系人姓名</string>
<string name="address_book_name_invalid_chars_error">联系人姓名包含无效字符</string>
<string name="address_book_name_max_chars_error">联系人姓名不得超过 50 个字符</string>
<string name="address_book_name_taken_error">这个钱包的名字已经被注册了</string>
<string name="address_book_new_contact">新联系人</string>
<string name="address_book_no_contacts">尚无联系人</string>
<string name="address_book_no_contacts_description">添加的联系人将显示在此处</string>
<string name="address_book_remove_address">移除地址</string>
<string name="address_book_save_contact">保存联系人</string>
<string name="address_book_save_to_wallet_title">保存到钱包</string>
<string name="address_book_save_wallet_to_description">该联系人将与该钱包的通讯录关联。</string>
<string name="address_book_search_no_results">未找到结果。\n请尝试其他名称</string>
<string name="address_book_select_all_networks">全选</string>
<string name="address_book_select_network">选择网络</string>
<string name="address_book_title">地址簿</string>
<string name="address_book_unsaved_changes">未保存的更改</string>
@ -1263,6 +1280,7 @@
<string name="quick_action_buy_description">信用卡或银行账户</string>
<string name="quick_action_receive_description">分享您的地址或二维码</string>
<string name="quick_action_sell_description">安全出售加密货币</string>
<string name="quick_action_send_and_swap_description">发送并兑换成另一种代币</string>
<string name="quick_action_send_description">发送到另一个钱包</string>
<string name="quick_action_swap_description">在您的投资组合之间</string>
<string name="quick_top_up_chip_other">其他</string>
@ -1550,9 +1568,9 @@
<string name="staking_notification_ton_account_initialization_message">要开始质押,您需要先激活您的 TON 账户。</string>
<string name="staking_notification_ton_account_initialization_title">激活账户</string>
<string name="staking_notification_ton_activate_account">要开始在 TON 上进行质押,首先向您自己的地址发送一笔小额交易——这将激活您的钱包。</string>
<string name="staking_notification_ton_extra_reserve_info">除网络费用外,完成交易可能还需要额外支付最多 0.2 TON 的费用。任何未使用的金额将予以退还。</string>
<string name="staking_notification_ton_extra_reserve_is_required">除网络费用外,本次操作还需要 0.2 TON。请充值。</string>
<string name="staking_notification_ton_extra_reserve_title">需要TON储备</string>
<string name="staking_notification_ton_extra_reserve_info">除网络费用外,完成交易可能还需要额外支付最多 0.2 GRAM 的费用。任何未使用的金额将予以退还。</string>
<string name="staking_notification_ton_extra_reserve_is_required">除网络费用外,本次操作还需要 0.2 GRAM。请充值。</string>
<string name="staking_notification_ton_extra_reserve_title">需要GRAM储备</string>
<string name="staking_notification_ton_have_to_unstake_all_text">根据网络规则,此操作将关闭其他仓位或将其切换为提现状态。</string>
<string name="staking_notification_ton_have_to_unstake_all_title">仓位状态</string>
<string name="staking_notification_unlock_text">解锁您的资金即可从质押过程中提款。解锁需要一定时间。 %s。</string>
@ -2262,6 +2280,8 @@
<string name="warning_hedera_missing_token_association_message_brief">您必须先将此代币与您的 Hedera 账户关联才能收到它。</string>
<string name="warning_hedera_missing_token_association_title">关联您的代币</string>
<string name="warning_hedera_token_association_not_enough_hbar_message">%s不够。请为您的 Hedera 账户充值以关联此代币</string>
<string name="warning_incomplete_backup_notification_message">我们发现由于您的设备NFC模块存在问题或者将卡片轻触手机的方式不正确导致卡片激活流程未能正常完成。请联系我们的客服团队以获取更多详情。</string>
<string name="warning_incomplete_backup_notification_title">必须采取行动。不要用你的钱包!</string>
<string name="warning_kaspa_unfinished_token_transaction_discard_message">您确定要取消交易吗?取消后您将无法再次尝试交易。</string>
<string name="warning_kaspa_unfinished_token_transaction_message">您金额为 %1$s %2$s 的交易未完成。您可以再次尝试完成交易。</string>
<string name="warning_kaspa_unfinished_token_transaction_title">您有未完成的交易</string>

View file

@ -289,10 +289,6 @@
<string name="common_approve">Approve</string>
<string name="common_approved">Approved</string>
<string name="common_approving">Approving</string>
<plurals name="common_assets">
<item quantity="one">%d asset</item>
<item quantity="other">%d assets</item>
</plurals>
<string name="common_attention">Attention</string>
<string name="common_available_networks">Available networks</string>
<string name="common_backup">Backup</string>
@ -642,6 +638,8 @@
<string name="express_exchange_notification_long_transaction_time_title">Long transaction time</string>
<string name="express_exchange_notification_refund_text">The transaction amount was refunded in %1$s to your wallet due to OKX or bridge rules. %2$s</string>
<string name="express_exchange_notification_refund_title">The amount was refunded in %1$s (%2$s network)</string>
<string name="express_exchange_notification_refunded_in_text">Your funds have been refunded in %1$s to your wallet on the %2$s network, in accordance with OKX exchange rules.</string>
<string name="express_exchange_notification_refunded_in_title">Refunded in %s</string>
<string name="express_exchange_notification_verification_text">Visit providers website for verification</string>
<string name="express_exchange_notification_verification_title">KYC verification required by provider</string>
<string name="express_exchange_status_bought">Purchase completed</string>
@ -1292,19 +1290,34 @@
<string name="onramp_title_available_up_to">Available up to</string>
<string name="onramp_title_you_get">You get</string>
<string name="onramp_token_is_not_supported_banner_subtitle">This token is not supported. Please choose a different token to buy.</string>
<string name="onramp_token_is_not_supported_banner_title">%s is not supported</string>
<string name="onramp_tos_external_providers">Service is provided by an external provider. \nTangem is not responsible.</string>
<string name="onramp_transaction_status_footer_text">You can close this screen and check the transaction status on the token details screen.</string>
<string name="onramp_up_to_rate">Up to</string>
<string name="onramp_via">Via</string>
<string name="onramp_via_provider">via %s</string>
<string name="onramp_you_will_pay_title">You will pay</string>
<string name="onramps_token_is_not_supported_banner_title">%s is not supported</string>
<string name="organize_tokens_group">Group</string>
<string name="organize_tokens_menu_group_by_networks">Group by networks</string>
<string name="organize_tokens_menu_sort_by_balance">Sort by balance</string>
<string name="organize_tokens_sort_by_balance">By balance</string>
<string name="organize_tokens_title">Organize tokens</string>
<string name="organize_tokens_ungroup">Ungroup</string>
<string name="promo_campaign_already_activated_subtitle">Eligible cashback will be distributed to:</string>
<string name="promo_campaign_already_activated_title">You\'re already enrolled in %1$s</string>
<string name="promo_campaign_eligible_tokens">Eligible tokens</string>
<string name="promo_campaign_enroll">Enroll</string>
<string name="promo_campaign_enroll_success_title">You\'re successfully enrolled in %1$s</string>
<string name="promo_campaign_not_active_subtitle">This campaign no longer exists or has expired</string>
<string name="promo_campaign_not_active_title">Campaign not active</string>
<string name="promo_campaign_reactivation_summary_description">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.</string>
<string name="promo_campaign_select_cashback_account">Select cashback account</string>
<string name="promo_campaign_select_token">Select token</string>
<string name="promo_campaign_summary_title">Enroll in %1$s</string>
<string name="promo_campaign_terms_agreement">I agree with %1$s</string>
<string name="promo_campaign_terms_agreement_android">I agree with</string>
<string name="promo_campaign_terms_link">%1$s Terms</string>
<string name="promo_campaign_whale_swap_summary_description">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.</string>
<string name="provider_name_support">%s support</string>
<string name="push_notification_settings_banner_description">Push Notifications are enabled but won\'t work until you allow them</string>
<string name="push_notification_settings_banner_title">Allow notifications</string>
@ -1528,7 +1541,7 @@
<string name="send_validation_invalid_total">Total amount exceeds balance</string>
<string name="send_with_swap_change_token_alert_message">Are you sure you want to change the receiving token? This will reset your previously entered data.</string>
<string name="send_with_swap_change_token_alert_title">Changing token</string>
<string name="send_with_swap_confirm_title">Swap and send</string>
<string name="send_with_swap_confirm_title">Swap &amp; Send</string>
<string name="send_with_swap_convert_token_alert_message">Proceed with swap? This will clear your previous data.</string>
<string name="send_with_swap_convert_token_alert_title">Confirm Conversion</string>
<string name="send_with_swap_correct_recipient_network_notification_message">Sending any other currency will result in its irreversible loss.</string>
@ -1636,9 +1649,9 @@
<string name="staking_notification_ton_account_initialization_message">To begin staking, you need to activate your TON account first.</string>
<string name="staking_notification_ton_account_initialization_title">Account activation</string>
<string name="staking_notification_ton_activate_account">To start staking in TON, first send a small transaction to your own address — this will activate your wallet.</string>
<string name="staking_notification_ton_extra_reserve_info">Up to 0.2 TON may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded.</string>
<string name="staking_notification_ton_extra_reserve_is_required">0.2 TON is required to proceed with this operation, in addition to the network fee. Please top up your balance.</string>
<string name="staking_notification_ton_extra_reserve_title">TON reserve required</string>
<string name="staking_notification_ton_extra_reserve_info">Up to 0.2 GRAM may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded.</string>
<string name="staking_notification_ton_extra_reserve_is_required">0.2 GRAM is required to proceed with this operation, in addition to the network fee. Please top up your balance.</string>
<string name="staking_notification_ton_extra_reserve_title">GRAM reserve required</string>
<string name="staking_notification_ton_have_to_unstake_all_text">This action will close other positions or switch them to withdrawal status, according to network rules.</string>
<string name="staking_notification_ton_have_to_unstake_all_title">Positions status</string>
<string name="staking_notification_unlock_text">Unlock your money to withdraw it from staking process. Unlocking takes %s.</string>
@ -2089,6 +2102,10 @@
<string name="token_receive_show_qr_code_title">Show QR code</string>
<string name="token_summary_can_not_load_token">Cant load data of the token</string>
<string name="token_summary_go_to_swap_button">Go to swap</string>
<string name="token_summary_last_update_subtitle">Last update: %1$s</string>
<string name="token_summary_negative_outlook_title">Negative outlook</string>
<string name="token_summary_neutral_outlook_title">Neutral outlook</string>
<string name="token_summary_positive_outlook_title">Positive outlook</string>
<string name="token_summary_title">Token summary</string>
<string name="token_swap_changelly_promotion_message">Exchange this token for another at %1$s service fees from February %2$s-%3$s.</string>
<string name="token_swap_changelly_promotion_title">Swap with Changelly, %s fees</string>

View file

@ -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()
},

View file

@ -57,4 +57,8 @@ dependencies {
api(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
// endregion
// region Tests dependencies
testImplementation(projects.test.core)
// endregion
}

View file

@ -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<TokenMarketListResponse, To
}
}
val tokens = value.tokens.map { token ->
val tokens = value.tokens.map { rawToken ->
val token = rawToken.applyL2Compatibility()
val stakingRate = token.stakingOpportunities
?.mapNotNull { it.apy }
?.max()

View file

@ -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<TokenMarketListResponse.Token.Network>?,
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,
)
}

View file

@ -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()

View file

@ -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)
}
}
}

View file

@ -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) &&

View file

@ -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,
),
)
}

View file

@ -5,4 +5,6 @@ import com.tangem.domain.staking.model.StakingIntegrationID
interface StakingFeatureToggles {
fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean
fun isSolanaUnstakeValidationEnabled(): Boolean
}

View file

@ -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(

View file

@ -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<AssetRequirementsCondition?>?,
): 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(

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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)

View file

@ -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,

View file

@ -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
}

View file

@ -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) },
)

View file

@ -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,

View file

@ -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)
}
}

View file

@ -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),

View file

@ -51,7 +51,7 @@ internal class MarketBlockDelegate @AssistedInject constructor(
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val visibleDefaultMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.Trending)
private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.MarketCap)
val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create(

View file

@ -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<TokensListItemUM>, 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(

View file

@ -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<TangemBottomSheetConfigContent.Empty>(
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(

View file

@ -8,9 +8,9 @@ internal sealed class ManageFundsAnalyticsEvent(
params: Map<String, String> = 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"
}
}

View file

@ -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(

View file

@ -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,

View file

@ -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<TokenActionsComponent.Params>()
@ -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,

View file

@ -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 },
)
}
}

View file

@ -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),

View file

@ -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()
},
),
)
}

View file

@ -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,
),
)
}

View file

@ -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) {

View file

@ -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,

View file

@ -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(

View file

@ -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

View file

@ -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)
}

View file

@ -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
}

View file

@ -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<OnrampMainComponentUM>,
@ -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

View file

@ -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<OnrampQuote>) {
quotes.forEach { errorState ->
when (errorState) {

View file

@ -125,7 +125,9 @@ private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: Strin
)
LaunchedEffect(key1 = Unit) {
requester.requestFocus()
if (!amountField.isError) {
requester.requestFocus()
}
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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<OnrampQuote>, tokenSymbol: String) {
val quote = quotes.findBestRateQuote() ?: return
send(
OnrampAnalyticsEvent.ProviderCalculated(
providerName = quote.provider.info.name,
tokenSymbol = tokenSymbol,
paymentMethod = quote.paymentMethod.name,
),
)
}
private fun List<OnrampQuote>.findBestRateQuote(): OnrampQuote.Data? {
return filterIsInstance<OnrampQuote.Data>().maxByOrNull { it.toAmount.value }
}

View file

@ -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 = "$",
)
}
}

View file

@ -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<OnrampQuote.Error>(), mockk<OnrampQuote.AmountError>())
// 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<OnrampQuote.Error>(),
createQuote(providerName = "Loaded", rate = BigDecimal("100")),
mockk<OnrampQuote.AmountError>(),
),
expectedProviderName = "Loaded",
),
)
private fun createQuote(providerName: String, rate: BigDecimal): OnrampQuote.Data {
return mockk<OnrampQuote.Data> {
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<OnrampQuote>,
val expectedProviderName: String,
) {
override fun toString(): String = name
}
private companion object {
const val TOKEN_SYMBOL = "BTC"
const val PAYMENT_METHOD = "Card"
}
}

View file

@ -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(),
),
)
}

View file

@ -14,6 +14,7 @@ internal class AmountChangeStateTransformer(
private val minimumTransactionAmount: EnterAmountBoundary?,
private val value: String,
private val integration: StakingIntegration,
private val isSolanaUnstakeValidationEnabled: Boolean,
) : Transformer<StakingUiState> {
private val maxEnterAmountConverter = MaxEnterAmountConverter()
@ -43,6 +44,7 @@ internal class AmountChangeStateTransformer(
maxAmount = maxEnterAmount,
integration = integration,
actionType = prevState.actionType,
isSolanaUnstakeValidationEnabled = isSolanaUnstakeValidationEnabled,
).transform(updatedAmountState),
)
}

View file

@ -14,6 +14,7 @@ internal class AmountMaxValueStateTransformer(
private val minimumTransactionAmount: EnterAmountBoundary?,
private val actionType: StakingActionCommonType,
private val integration: StakingIntegration,
private val isSolanaUnstakeValidationEnabled: Boolean,
) : Transformer<StakingUiState> {
private val maxEnterAmountConverter = MaxEnterAmountConverter()
@ -40,6 +41,7 @@ internal class AmountMaxValueStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
integration = integration,
actionType = prevState.actionType,
isSolanaUnstakeValidationEnabled = isSolanaUnstakeValidationEnabled,
).transform(updatedAmountState),
)
}

View file

@ -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<AmountState> {
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,

View file

@ -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,
)
}

View file

@ -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()
}
}

View file

@ -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<SwapProvider, SwapState>? {
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<SwapProvider, SwapState> {
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<SwapProvider, SwapState> {
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,

View file

@ -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)

View file

@ -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<String>()
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<String>()
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<String>()
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<String>()
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<String>()
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<String>()
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<String>()
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 {

View file

@ -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<TransactionData>(),
)
// 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<TransactionData>(),
)
}
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<TransactionData>(),
)
}
}
}
// -------------------------------------------------------------------------
// EVM IllegalStateException → fallback to GetEthSpecificFeeUseCase

View file

@ -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))

View file

@ -12,6 +12,7 @@ interface TokenDetailsComponent : ComposableContentComponent {
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val navigationAction: NavigationAction? = null,
val shouldShowMarketBlock: Boolean = true,
)
interface Factory : ComponentFactory<Params, TokenDetailsComponent>

View file

@ -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"),

View file

@ -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)
}

View file

@ -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,

View file

@ -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<TokenActionsState.ActionState>,
private val networkSource: StatusSource,
private val clickIntents: TokenDetailsClickIntents,
private val analyticsEventHandler: AnalyticsEventHandler,
private val onActionDispatched: () -> Unit,
) : Transformer<TokenDetailsUM> {
@ -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)
},

View file

@ -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)
}

View file

@ -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<TransferAnalyticsEvent.ButtonSend>())
onActionDispatched.invoke()
clickIntents.onSendClick(ScenarioUnavailabilityReason.None)
}
@ -130,6 +134,7 @@ class UpdateTransferTransformerTest {
// THEN
verifyOrder {
analyticsEventHandler.send(ofType<TransferAnalyticsEvent.ButtonSell>())
onActionDispatched.invoke()
clickIntents.onSellClick(ScenarioUnavailabilityReason.None)
}
@ -225,6 +230,7 @@ class UpdateTransferTransformerTest {
// THEN
verifyOrder {
analyticsEventHandler.send(ofType<TransferAnalyticsEvent.ButtonSwap>())
onActionDispatched.invoke()
clickIntents.onSwapFromClick(ScenarioUnavailabilityReason.None)
}
@ -347,6 +353,7 @@ class UpdateTransferTransformerTest {
// Assert
verifyOrder {
analyticsEventHandler.send(ofType<TransferAnalyticsEvent.ButtonSwapAndSend>())
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,
)

View file

@ -125,6 +125,7 @@ internal class WalletClickIntents @Inject constructor(
}
fun onTransferClick(userWalletId: UserWalletId) {
analyticsEventHandler.send(MainScreenAnalyticsEvent.ButtonTransfer())
router.openTransfer(userWalletId)
}

View file

@ -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,
)
}

View file

@ -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),
)
}

View file

@ -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)
}
}

View file

@ -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),
)
}

View file

@ -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<CoinsResponse.Coin.Network>.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 <T> List<T>.appendMissingL2Networks(
networkId: (T) -> String,
createNetwork: (networkId: String) -> T,
): List<T> {
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 {

View file

@ -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.Network>?,
) = 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,
)
}