Updated on 2026-08-14
This commit is contained in:
commit
997194b3fb
92 changed files with 2454 additions and 391 deletions
|
|
@ -301,6 +301,7 @@ internal class ChildFactory @Inject constructor(
|
|||
userWalletId = route.userWalletId,
|
||||
currency = route.currency,
|
||||
navigationAction = route.navigationAction,
|
||||
shouldShowMarketBlock = route.shouldShowMarketBlock,
|
||||
),
|
||||
componentFactory = tokenDetailsComponentFactory,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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,29 +48,51 @@ 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
|
||||
}
|
||||
}.toImmutableList()
|
||||
else -> {
|
||||
val state = byBsAction[action] ?: return@mapNotNull null
|
||||
val um = state.toQuickActionUM(isRedesignEnabled) ?: return@mapNotNull null
|
||||
QuickActionState(um, isEnabled = state.unavailabilityReason == ScenarioUnavailabilityReason.None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ sealed class MainScreenAnalyticsEvent(
|
|||
event = "Button - Add Funds",
|
||||
)
|
||||
|
||||
class ButtonTransfer : MainScreenAnalyticsEvent(
|
||||
event = "Button - Transfer",
|
||||
)
|
||||
|
||||
class LimitsClicked : MainScreenAnalyticsEvent(
|
||||
event = "Limits Clicked",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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 d’abord 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 provider’s 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 & 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">Can’t 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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -57,4 +57,8 @@ dependencies {
|
|||
api(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
// endregion
|
||||
|
||||
// region Tests dependencies
|
||||
testImplementation(projects.test.core)
|
||||
// endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -5,4 +5,6 @@ import com.tangem.domain.staking.model.StakingIntegrationID
|
|||
interface StakingFeatureToggles {
|
||||
|
||||
fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean
|
||||
|
||||
fun isSolanaUnstakeValidationEnabled(): Boolean
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,12 +152,21 @@ internal class ManageFundsModel @Inject constructor(
|
|||
}
|
||||
|
||||
override fun onQuickActionClick(action: TokenActionsBSContentUM.Action, shouldDismiss: Boolean) {
|
||||
val event = when (action) {
|
||||
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) {
|
||||
params.onDismiss()
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,7 +147,8 @@ internal class TokenActionsUiBuilder @Inject constructor(
|
|||
}
|
||||
|
||||
private fun createPortfolioBadge(cryptoCurrencyData: CryptoCurrencyData): PortfolioBadgeUM {
|
||||
return if (cryptoCurrencyData.isAccountMode) {
|
||||
return when {
|
||||
cryptoCurrencyData.isAccountMode -> {
|
||||
val icon = CryptoPortfolioIconConverter.convert(cryptoCurrencyData.account.account.icon)
|
||||
val name = cryptoCurrencyData
|
||||
.account
|
||||
|
|
@ -166,7 +169,9 @@ internal class TokenActionsUiBuilder @Inject constructor(
|
|||
shouldRespectIconTint = true,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
}
|
||||
isSingleWallet() -> PortfolioBadgeUM.None
|
||||
else -> {
|
||||
val userWallet = cryptoCurrencyData.userWallet
|
||||
PortfolioBadgeUM.Wallet(
|
||||
name = stringReference(userWallet.name),
|
||||
|
|
@ -176,6 +181,12 @@ internal class TokenActionsUiBuilder @Inject constructor(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
.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) {
|
||||
|
|
|
|||
|
|
@ -125,8 +125,10 @@ private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: Strin
|
|||
)
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
if (!amountField.isError) {
|
||||
requester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 = "$",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,12 +105,18 @@ internal class AmountRequirementStateTransformer(
|
|||
)
|
||||
}
|
||||
is StakingActionCommonType.Exit -> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,7 +59,8 @@ 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(
|
||||
private val testParams
|
||||
get() = StakingComponent.Params(
|
||||
userWalletId = testUserWalletId,
|
||||
cryptoCurrency = testCryptoCurrency,
|
||||
integrationId = testIntegrationId,
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -124,21 +124,80 @@ 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 {
|
||||
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)
|
||||
|
||||
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.
|
||||
// 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(),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,7 +113,9 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
|
|||
},
|
||||
)
|
||||
|
||||
private val tokenMarketBlockComponent = params.currency.toTokenMarketParam()?.let { tokenMarketParams ->
|
||||
private val tokenMarketBlockComponent = params.currency.toTokenMarketParam()
|
||||
?.takeIf { params.shouldShowMarketBlock }
|
||||
?.let { tokenMarketParams ->
|
||||
tokenMarketBlockComponentFactory.create(
|
||||
appComponentContext = child("tokenMarketBlockComponent"),
|
||||
params = tokenMarketParams,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ internal class WalletClickIntents @Inject constructor(
|
|||
}
|
||||
|
||||
fun onTransferClick(userWalletId: UserWalletId) {
|
||||
analyticsEventHandler.send(MainScreenAnalyticsEvent.ButtonTransfer())
|
||||
router.openTransfer(userWalletId)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue