Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 17:11:05 +04:00
commit 28f550e633
44 changed files with 846 additions and 196 deletions

View file

@ -433,6 +433,14 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- SurveySparrow SDK exports this activity (exported="true") in its own manifest. -->
<!-- We launch it only via an explicit in-app intent, so force it non-exported to -->
<!-- prevent any other installed app from loading arbitrary web content into it. -->
<activity
android:name="com.surveysparrow.ss_android_sdk.SsSurveyActivity"
android:exported="false"
tools:replace="android:exported" />
</application>
<queries>
<!-- Needed from Android 11 to open Google Wallet for payment with Visa card -->

View file

@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.model.PendingOfframp
import com.tangem.domain.offramp.model.PendingOfframp.Companion.EXPIRY_MS
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.data.converter.PendingOfframpEntryConverter
@ -59,7 +60,7 @@ internal class DefaultOfframpRepository(
requestId
}
override suspend fun consumePendingOfframp(
override suspend fun resolvePendingOfframp(
requestId: String,
userWalletId: UserWalletId,
currencyId: String,
@ -73,9 +74,9 @@ internal class DefaultOfframpRepository(
entry.currencyId == currencyId &&
!entry.isExpired(now)
}
// Remove only the fully-matched record (single-use); always prune expired ones. A request_id that
// matches but with a mismatched wallet/currency is left intact so a tampered redirect cannot burn it.
stored.filter { it != matched }.filterNotExpired(now)
// Keep the matched record so the same redirect can be followed again until it expires; only prune the
// expired ones. The record is dropped naturally once it ages past EXPIRY_MS.
stored.filterNotExpired(now)
}
matched?.let(converter::convert)
}
@ -84,8 +85,10 @@ internal class DefaultOfframpRepository(
pendingOfframpStore.data.first().map(converter::convert)
}
// Returns the same instance when nothing is expired, so DataStore.updateData sees an unchanged value and skips
// both the extra allocation and the write.
private fun List<PendingOfframpEntry>.filterNotExpired(now: Long): List<PendingOfframpEntry> =
filterNot { it.isExpired(now) }
if (none { now - it.createdAt >= EXPIRY_MS }) this else filter { now - it.createdAt < EXPIRY_MS }
private fun PendingOfframpEntry.isExpired(now: Long): Boolean = converter.convert(this).isExpired(now)
}

View file

@ -156,13 +156,13 @@ internal class DefaultOfframpRepositoryTest {
}
@Test
fun `GIVEN registered pending offramp WHEN consume with matching wallet and currency THEN returns record`() =
fun `GIVEN registered pending offramp WHEN resolve with matching wallet and currency THEN returns record`() =
runTest {
// Arrange
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
// Act
val pending = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId)
val pending = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId)
// Assert
assertThat(pending).isNotNull()
@ -172,35 +172,36 @@ internal class DefaultOfframpRepositoryTest {
}
@Test
fun `GIVEN unknown request id WHEN consume THEN returns null`() = runTest {
fun `GIVEN unknown request id WHEN resolve THEN returns null`() = runTest {
repository.registerPendingOfframp(userWalletId, currencyId)
assertThat(repository.consumePendingOfframp("unknown", userWalletId, currencyId)).isNull()
assertThat(repository.resolvePendingOfframp("unknown", userWalletId, currencyId)).isNull()
}
@Test
fun `GIVEN already consumed pending offramp WHEN consume again THEN returns null`() = runTest {
fun `GIVEN already resolved pending offramp WHEN resolve again THEN still returns record`() = runTest {
// Arrange
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
// Act
val first = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId)
val second = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId)
// Act — resolving does NOT consume the record; the same redirect may be followed again until it expires
val first = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId)
val second = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId)
// Assert
assertThat(first).isNotNull()
assertThat(second).isNull()
assertThat(second).isNotNull()
assertThat(second?.requestId).isEqualTo(storedRequestId)
}
@Test
fun `GIVEN mismatched currency WHEN consume THEN returns null and does NOT burn the pending sell`() = runTest {
fun `GIVEN mismatched currency WHEN resolve THEN returns null and leaves the pending sell resolvable`() = runTest {
// Arrange
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
// Act — a tampered redirect with the right request_id but a wrong currency must not consume the token
val mismatched = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId = "ethereum")
// ...so the legitimate redirect can still succeed afterwards
val legitimate = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId)
// Act — a tampered redirect with the right request_id but a wrong currency must not match
val mismatched = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId = "ethereum")
// ...and the legitimate redirect must still resolve afterwards
val legitimate = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId)
// Assert
assertThat(mismatched).isNull()
@ -209,16 +210,16 @@ internal class DefaultOfframpRepositoryTest {
}
@Test
fun `GIVEN mismatched wallet WHEN consume THEN returns null`() = runTest {
fun `GIVEN mismatched wallet WHEN resolve THEN returns null`() = runTest {
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
val result = repository.consumePendingOfframp(storedRequestId, UserWalletId("ffeeddccbbaa9988"), currencyId)
val result = repository.resolvePendingOfframp(storedRequestId, UserWalletId("ffeeddccbbaa9988"), currencyId)
assertThat(result).isNull()
}
@Test
fun `GIVEN expired pending offramp WHEN consume THEN returns null`() = runTest {
fun `GIVEN expired pending offramp WHEN resolve THEN returns null`() = runTest {
// Arrange — seed a record created 2 hours ago (past the 1h expiry)
val expiredId = "expired-id"
pendingStoreState.value = listOf(
@ -231,7 +232,7 @@ internal class DefaultOfframpRepositoryTest {
)
// Act
val pending = repository.consumePendingOfframp(expiredId, userWalletId, currencyId)
val pending = repository.resolvePendingOfframp(expiredId, userWalletId, currencyId)
// Assert
assertThat(pending).isNull()
@ -245,7 +246,7 @@ internal class DefaultOfframpRepositoryTest {
// Act
val stored = repository.getAllStoredOfframps()
// ...the record must survive the read so it can still be consumed afterwards
val consumed = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId)
val consumed = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId)
// Assert
assertThat(stored).hasSize(1)

View file

@ -13,7 +13,6 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.platform.testTag
@ -35,6 +34,7 @@ import com.tangem.common.ui.earn.EarnBlockUM.Type
import com.tangem.core.ui.R
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.haze.hazeForegroundEffectTangem
import com.tangem.core.ui.ds.button.*
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
@ -44,6 +44,8 @@ import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
import com.tangem.core.res.R as CoreResR
private const val TINTED_BACKGROUND_ALPHA = 0.1f
@ -365,7 +367,14 @@ private fun EarnBlockIcon(type: Type, iconUM: EarnBlockUM.IconUM, modifier: Modi
Box(
modifier = Modifier
.size(TangemTheme.dimens2.x6)
.blur(radius = TangemTheme.dimens2.x4, edgeTreatment = BlurredEdgeTreatment.Unbounded)
.hazeForegroundEffectTangem(
style = HazeStyle(
tint = HazeTint(Color.Transparent),
blurRadius = TangemTheme.dimens2.x4,
),
) {
blurredEdgeTreatment = BlurredEdgeTreatment.Unbounded
}
.background(color = type.accentGlow().copy(alpha = GLOW_ALPHA), shape = glowShape),
)
}

View file

@ -274,7 +274,7 @@
<string name="common_action_failed">%s fehlgeschlagen</string>
<string name="common_activate">Aktivieren</string>
<string name="common_add">Hinzufügen</string>
<string name="common_add_funds">Guthaben hinzufügen</string>
<string name="common_add_funds">Einzahlen</string>
<string name="common_add_to_portfolio">Zum Portfolio hinzufügen</string>
<string name="common_add_token">Token hinzufügen</string>
<string name="common_add_tokens">Token hinzufügen</string>
@ -725,6 +725,11 @@
<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_earn_opportunities">Verdienstmöglichkeiten</string>
<string name="for_you_earn_opportunities_all_tokens_active">Alle Ihre Ressourcen sind im Einsatz entdecken Sie neue Möglichkeiten</string>
<string name="for_you_earn_opportunities_no_available_tokens">Erhalten Sie jährlich bis zu %1$s</string>
<string name="for_you_earn_opportunities_tokens_rewards">Maximale potenzielle Gewinne %1$s</string>
<string name="for_you_earn_per_year">%1$s/Jahr</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>
@ -923,20 +928,22 @@
<string name="markets_insights_info_description_message">Die Daten dieses Abschnitts stammen aus den folgenden Netzwerken: %s</string>
<string name="markets_loading_error_title">Die Daten konnten nicht geladen werden...</string>
<string name="markets_loading_no_data_title">Keine Daten</string>
<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_add_token_title">**Füge dieses Produkt Deinem Portfolio hinzu**, um es zu kaufen, zu tauschen oder zu erhalten.</string>
<string name="markets_portfolio_block_subtitle">In Deinem Portfolio</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_portfolio_eligible_block_title">Andere zulässige Token</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>
<string name="markets_search_header_title">Token suchen</string>
<string name="markets_search_hint_header">Neueste</string>
<string name="markets_search_hint_header">Letzte Suchanfragen</string>
<string name="markets_search_portfolio_header">In Ihrem Portfolio</string>
<string name="markets_search_recent_tokens_header">Aktuelle Token</string>
<string name="markets_search_result_title">Ergebnis</string>
<string name="markets_search_see_tokens_under_100k">Token unter 100k USD Marktkapitalisierung anzeigen</string>
<string name="markets_search_show_tokens">Token anzeigen</string>
<string name="markets_search_title_placeholder">Krypto, Nachrichten und mehr</string>
<string name="markets_search_title_placeholder">Krypto, Neuigkeiten und mehr</string>
<string name="markets_search_token_no_result_title">Kein Ergebnis</string>
<string name="markets_select_network">Netzwerk auswählen</string>
<string name="markets_select_wallet">Wallet auswählen</string>
@ -1010,7 +1017,7 @@
<string name="markets_token_details_market_rating_description">Position im Krypto-Rating zwischen allen Coins basierend auf der Marktkapitalisierung</string>
<string name="markets_token_details_market_rating_full">Marktposition</string>
<string name="markets_token_details_max_supply">Maximale Versorgung</string>
<string name="markets_token_details_max_supply_and_circulation_full">Zirkulation und maximale Versorgung</string>
<string name="markets_token_details_max_supply_and_circulation_full">Umlauf- und Maximalversorgung</string>
<string name="markets_token_details_max_supply_description">Die maximale Anzahl von Coins oder Tokens, die jemals für eine bestimmte Kryptowährung existieren können</string>
<string name="markets_token_details_max_supply_full">Maximale Versorgung</string>
<string name="markets_token_details_metrics">Metriken</string>
@ -1028,7 +1035,7 @@
<string name="markets_token_details_trading_volume">Handelsvolumen (24h)</string>
<string name="markets_token_details_trading_volume_24h_description">Der Gesamtbetrag einer Kryptowährung, der innerhalb der letzten 24 Stunden gehandelt wurde, wobei das Aktivitäts- und Liquiditätsniveau auf dem Markt angegeben wird</string>
<string name="markets_token_details_trading_volume_full">Handelsvolumen (24h)</string>
<string name="markets_token_details_valuation_value_in_total">%s insgesamt</string>
<string name="markets_token_details_valuation_value_in_total">%s in Summe</string>
<string name="markets_token_details_volume">Volumen</string>
<string name="markets_tooltip_message">Rufe dies auf oder tippe auf die Suchleiste, um Token direkt vom Markt hinzuzufügen</string>
<string name="markets_tooltip_title">Token hinzufügen</string>
@ -1352,10 +1359,10 @@
<string name="qr_scanner_warning_unknown_parameters_title">Unbekannte Parameter</string>
<string name="quick_action_buy_description">Kreditkarte oder Bankkonto</string>
<string name="quick_action_receive_description">Teilen deine Adresse oder dein QR-Code</string>
<string name="quick_action_sell_description">Sicherer Verkauf von Kryptowährungen</string>
<string name="quick_action_sell_description">Kryptowährung sicher verkaufen</string>
<string name="quick_action_send_and_swap_description">Senden Sie mit Tausch an ein anderes Token</string>
<string name="quick_action_send_description">An eine andere Wallet senden</string>
<string name="quick_action_swap_description">Zwischen deinen Portfolios</string>
<string name="quick_action_swap_description">Krypto gegen Krypto</string>
<string name="quick_top_up_chip_other">Andere</string>
<string name="quick_top_up_title">Schnell aufladen</string>
<string name="receive_bottom_sheet_no_memo_required_message">Kein Memo erforderlich</string>
@ -1917,6 +1924,10 @@
<item quantity="one">%d Karte</item>
<item quantity="other">%d Karten</item>
</plurals>
<string name="tangempay_cashback_deactivated_description">Dies geschah aufgrund Ihres verdächtigen Verhaltens. Wenden Sie sich an den Support, um mehr zu erfahren.</string>
<string name="tangempay_cashback_deactivated_title">Cashback deaktiviert</string>
<string name="tangempay_cashback_deposited_on">Wird eingezahlt am %1$s</string>
<string name="tangempay_cashback_widget_title">%1$s Cashback in %2$s</string>
<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>
@ -1980,6 +1991,7 @@
<string name="tangempay_kyc_rejected_button_text">KYC-Sperre ausblenden</string>
<string name="tangempay_kyc_rejected_description">Leider konnten wir Ihre Identität</string>
<string name="tangempay_kyc_rejected_description_span">nicht verifizieren.</string>
<string name="tangempay_main_select_plan">Tarif auswählen</string>
<string name="tangempay_maximum_cards_issued_description">Du kannst bis zu 3 Karten haben. Lösche eine, um eine neue Karte hinzuzufügen.</string>
<string name="tangempay_maximum_cards_issued_title">Maximale Anzahl ausgegebener Karten</string>
<string name="tangempay_newonboard_Q1_body">Ja — für die Nutzung einer regulierten Visa-Karte ist eine Identitätsprüfung Pflicht. Das KYC wird von Sumsub abgewickelt, dem Compliance-Partner.</string>
@ -2043,6 +2055,7 @@
<string name="tangempay_set_pin_header">Neue PIN einrichten</string>
<string name="tangempay_set_pin_title">PIN einstellen</string>
<string name="tangempay_status_deactivated">Konto geschlossen</string>
<string name="tangempay_status_inactive">Inaktiv</string>
<string name="tangempay_status_replacing">Ersetzen deine Karte</string>
<string name="tangempay_sync_needed">Karte oder Ring verwenden, um die Sitzung zu verlängern</string>
<string name="tangempay_sync_needed_body">Karte oder Ring verwenden, um die Sitzung zu verlängern</string>
@ -2058,6 +2071,8 @@
<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_va_banking_details_error_description">Bitte versuche es später erneut. Sollte das Problem weiterhin bestehen, kontaktiere bitte unseren Support. Wir helfen Dir gerne bei der Lösung.</string>
<string name="tangempay_va_banking_details_error_title">Die Bankdaten konnten nicht geladen werden</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>
@ -2622,7 +2637,7 @@
<string name="yield_module_approve_sheet_fee_note">Die Gebühr wird abgezogen und Dein Vermögen wird erneut verliehen.</string>
<string name="yield_module_approve_sheet_subtitle">Um weiterhin Geld verdienen zu können, ist eine Genehmigung erforderlich.</string>
<string name="yield_module_approve_sheet_title">Genehmigung bestätigen</string>
<string name="yield_module_average_apy">Durchschnittlicher Jahreszins %1$s%%</string>
<string name="yield_module_average_apy">Aktueller effektiver Jahreszins %1$s%%</string>
<string name="yield_module_balance_info_sheet_subtitle">Deine Gelder werden derzeit dem Aave-Protokoll bereitgestellt, Du kannst sie jedoch jederzeit verwalten.</string>
<string name="yield_module_balance_info_sheet_title">Deine%s ist in Aave hinterlegt</string>
<string name="yield_module_chart_loading_error">Chart konnte nicht geladen werden...</string>

View file

@ -709,6 +709,7 @@
<string name="generic_error">Ocurrió un error</string>
<string name="generic_error_code">Ocurrió un error. Código: %s</string>
<string name="generic_requires_memo_error">Requiere memo</string>
<string name="get_token_title">Obtener %1$s</string>
<string name="give_permission_current_transaction">Esta transacción</string>
<string name="give_permission_policy_type_footer">Al aprobar, permite que el contrato inteligente utilice sus tokens en transacciones futuras.</string>
<string name="give_permission_rows_amount">Montante %s</string>
@ -725,6 +726,8 @@
<string name="hardware_wallet_key_feature_title">Generación de claves</string>
<string name="hardware_wallet_security_feature_description">Todas las operaciones criptográficas ocurren dentro del chip seguro, certificado contra la clonación y la manipulación física.</string>
<string name="hardware_wallet_security_feature_title">Seguridad a nivel de hardware</string>
<string name="high_fee_warning_description">La actividad en la red es alta. Puede continuar ahora o intentarlo de nuevo más tarde, cuando las tarifas sean más bajas.</string>
<string name="high_fee_warning_title">La tarifa de red es más alta de lo habitual.</string>
<string name="home_button_add_existing_wallet">Agregar Billetera Existente</string>
<string name="home_button_create_new_wallet">Crear Nueva Billetera</string>
<string name="home_button_order">Pedir Tangem</string>
@ -880,12 +883,14 @@
<string name="markets_portfolio_block_add_token_title">**Añadir a su portafolio** para empezar a comprar, intercambiar o recibir este activo</string>
<string name="markets_portfolio_block_subtitle">En su portafolio</string>
<string name="markets_portfolio_block_title">Su portafolio</string>
<string name="markets_portfolio_block_token_unsupported">**Token no soportado**. Este token no está soportado actualmente en la billetera.</string>
<string name="markets_pulse_common_title">Análisis del Mercado</string>
<string name="markets_quick_actions">Acciones rápidas</string>
<string name="markets_search_clear_all_hints">Borrar todo</string>
<string name="markets_search_header_title">Buscar tokens</string>
<string name="markets_search_hint_header">Recientes</string>
<string name="markets_search_hint_header">Búsquedas recientes</string>
<string name="markets_search_portfolio_header">En su portafolio</string>
<string name="markets_search_recent_tokens_header">Tokens recientes</string>
<string name="markets_search_result_title">Resultado</string>
<string name="markets_search_see_tokens_under_100k">Ver tokens con marketcap inferior a 100.000$</string>
<string name="markets_search_show_tokens">Mostrar tokens</string>
@ -1256,6 +1261,21 @@
<string name="organize_tokens_sort_by_balance">Por saldo</string>
<string name="organize_tokens_title">Organizar tokens</string>
<string name="organize_tokens_ungroup">Desagrupar</string>
<string name="promo_campaign_already_activated_subtitle">El reembolso elegible se distribuirá a:</string>
<string name="promo_campaign_already_activated_title">Ya está inscrito en %1$s</string>
<string name="promo_campaign_eligible_tokens">Tokens elegibles</string>
<string name="promo_campaign_enroll">Inscribirse</string>
<string name="promo_campaign_enroll_success_title">Se ha inscrito correctamente en %1$s</string>
<string name="promo_campaign_not_active_subtitle">Esta campaña ya no existe o ha caducado.</string>
<string name="promo_campaign_not_active_title">Campaña inactiva</string>
<string name="promo_campaign_reactivation_summary_description">Gane un reembolso de 0.5% por cada intercambio superior a 500 $, en cualquier par de divisas, excepto entre stablecoins. El pago máximo es de 50 $ por intercambio.\n\nRealice cinco intercambios válidos y desbloquee una bonificación adicional de 10 $.\n\nLas recompensas se abonan semanalmente en USDT o USDC en la dirección seleccionada.</string>
<string name="promo_campaign_select_cashback_account">Seleccione una cuenta de reembolso</string>
<string name="promo_campaign_select_token">Seleccionar token</string>
<string name="promo_campaign_summary_title">Inscríbase en %1$s</string>
<string name="promo_campaign_terms_agreement">Estoy de acuerdo con %1$s</string>
<string name="promo_campaign_terms_agreement_android">Estoy de acuerdo con</string>
<string name="promo_campaign_terms_link">%1$s Términos</string>
<string name="promo_campaign_whale_swap_summary_description">Consiga reembolsos en cada intercambio a partir de 10,000 $ hasta finales de Julio.\n\nLas tasas aumentan según el volumen: 0.10% a partir de 10,000 $, 0,20% a partir de 20,000 $ y 0.50% a partir de 100,000 $.\n\nPago máximo: 500 $ por intercambio y 10,000 $ por billetera por dirección de intercambio mientras dure la campaña. Quedan excluidos los intercambios de monedas estables por otras monedas estables.\n\nEl pago se realiza semanalmente en la dirección de USDT o USDC que elija.</string>
<string name="provider_name_support">Soporte de %s</string>
<string name="push_notification_settings_banner_description">Las notificaciones push están activadas, pero no funcionarán hasta que las autorice</string>
<string name="push_notification_settings_banner_title">Permitir notificaciones</string>
@ -1293,7 +1313,7 @@
<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_action_swap_description">Cambia una criptomoneda por otra</string>
<string name="quick_top_up_chip_other">Otro</string>
<string name="quick_top_up_title">Recarga rápida</string>
<string name="receive_bottom_sheet_no_memo_required_message">No se requiere nota</string>

View file

@ -83,6 +83,7 @@
<string name="actionbutton_addfunds_title">Ajouter des fonds</string>
<string name="actionbutton_swap_title">Échanger</string>
<string name="actionbutton_transfer_title">Transférer</string>
<string name="add_and_manage_sheet_manage_subtitle">Ajouter à votre portfolio</string>
<string name="add_and_manage_sheet_manage_title">Ajouter des jetons</string>
<string name="add_custom_token_choose_network">Choisissez le réseau</string>
<string name="add_custom_token_title">Ajouter un jeton personnalisé</string>
@ -268,6 +269,7 @@
<string name="common_accounts">Comptes</string>
<string name="common_activate">Activer</string>
<string name="common_add">Ajouter</string>
<string name="common_add_funds">Ajouter fonds</string>
<string name="common_add_to_portfolio">Ajouter au portfolio</string>
<string name="common_add_token">Ajouter un jeton</string>
<string name="common_add_tokens">Ajouter des jetons</string>
@ -349,6 +351,7 @@
<string name="common_from">Du</string>
<string name="common_from_wallet_name">De %s</string>
<string name="common_generate_addresses">Synchroniser les adresses</string>
<string name="common_get">Obtenir du</string>
<string name="common_get_started">Commencer</string>
<string name="common_get_token">Obtenir un token</string>
<string name="common_go_to_provider">Aller au fournisseur</string>
@ -538,6 +541,7 @@
<string name="domain_receive_assets_onboarding_description">Lenvoi dactifs sur dautres réseaux entraînera une perte définitive.</string>
<string name="domain_receive_assets_onboarding_network_name">%s réseau</string>
<string name="domain_receive_assets_onboarding_title">Envoyez des fonds en utilisant uniquement</string>
<string name="dynamic_addresses_receive_badge">Adresse dynamique</string>
<string name="earn_best_opportunities">Meilleures opportunités</string>
<string name="earn_clear_filter">Effacer le filtre</string>
<string name="earn_filter_all_networks">Tous les réseaux</string>
@ -658,6 +662,7 @@
<string name="generic_error">Une erreur s\'est produite</string>
<string name="generic_error_code">Une erreur s\'est produite. Code:%s</string>
<string name="generic_requires_memo_error">Un mémo est requis</string>
<string name="get_token_title">Obtenir des %1$s</string>
<string name="give_permission_current_transaction">Cette transaction</string>
<string name="give_permission_policy_type_footer">Spécifiez la limite approuvée pour le jeton sélectionné</string>
<string name="give_permission_rows_amount">Montant %s</string>
@ -673,6 +678,8 @@
<string name="hardware_wallet_key_feature_title">Génération de clés</string>
<string name="hardware_wallet_security_feature_description">Toutes les opérations cryptographiques s\'effectuent à l\'intérieur de la puce sécurisée, certifiée contre le clonage et la falsification physique.</string>
<string name="hardware_wallet_security_feature_title">Sécurité au niveau matériel</string>
<string name="high_fee_warning_description">L\'activité du réseau est élevée. Vous pouvez continuer maintenant ou réessayer plus tard, lorsque les frais seront peut-être moins élevés.</string>
<string name="high_fee_warning_title">Les frais de réseau sont plus élevés que d\'habitude</string>
<string name="home_button_add_existing_wallet">Ajouter un Portefeuille existant</string>
<string name="home_button_create_new_wallet">Créer un nouveau Portefeuille</string>
<string name="home_button_order">Commandez</string>
@ -821,12 +828,19 @@
<string name="markets_insights_info_description_message">Les données de cette section proviennent des réseaux suivants : %s</string>
<string name="markets_loading_error_title">Impossible de charger les données…</string>
<string name="markets_loading_no_data_title">Aucune donnée</string>
<string name="markets_portfolio_block_add_token_title">**Ajouter à votre portfolio** pour acheter, échanger ou recevoir cet asset</string>
<string name="markets_portfolio_block_subtitle">Dans votre portfolio</string>
<string name="markets_portfolio_block_title">Votre portfolio</string>
<string name="markets_portfolio_block_token_unsupported">**Token non pris en charge**. Ce token n\'est actuellement pas pris en charge dans le portefeuille</string>
<string name="markets_pulse_common_title">Rythme du marché</string>
<string name="markets_quick_actions">Actions rapides</string>
<string name="markets_search_header_title">Rechercher sur le marché</string>
<string name="markets_search_hint_header">Recherches récentes</string>
<string name="markets_search_recent_tokens_header">Tokens récents</string>
<string name="markets_search_result_title">Résultat</string>
<string name="markets_search_see_tokens_under_100k">Voir les jetons de moins de 100 000 $ de capitalisation boursière</string>
<string name="markets_search_show_tokens">Afficher les jetons</string>
<string name="markets_search_title_placeholder">Crypto, actualités et plus</string>
<string name="markets_search_token_no_result_title">Aucun résultat</string>
<string name="markets_select_network">Sélectionnez un réseau</string>
<string name="markets_select_wallet">Sélectionnez un portefeuille</string>
@ -895,6 +909,7 @@
<string name="markets_token_details_market_rating_description">Position dans le classement des crypto-monnaies entre toutes les pièces en fonction de la capitalisation boursière</string>
<string name="markets_token_details_market_rating_full">Évaluation du marché</string>
<string name="markets_token_details_max_supply">Approvisionnement maximal</string>
<string name="markets_token_details_max_supply_and_circulation_full">Offre en circulation et offre maximale</string>
<string name="markets_token_details_max_supply_description">Le nombre maximal de pièces ou de jetons pouvant exister pour une crypto-monnaie particulière</string>
<string name="markets_token_details_max_supply_full">Approvisionnement maximal</string>
<string name="markets_token_details_metrics">Métriques</string>
@ -910,6 +925,7 @@
<string name="markets_token_details_trading_volume">Volume des échanges (24h)</string>
<string name="markets_token_details_trading_volume_24h_description">Le montant total d\'une crypto-monnaie qui a été échangé au cours des dernières 24 heures, indiquant le niveau d\'activité et de liquidité du marché</string>
<string name="markets_token_details_trading_volume_full">Volume des échanges (24h)</string>
<string name="markets_token_details_valuation_value_in_total">%s au total</string>
<string name="markets_token_details_volume">Volume</string>
<string name="markets_tooltip_message">Tirez vers le haut ou appuyez sur la barre de recherche pour ajouter des jetons directement depuis le marché</string>
<string name="markets_tooltip_title">Ajouter des jetons</string>
@ -1163,6 +1179,8 @@
<string name="onramp_title_available_from">Disponible auprès de</string>
<string name="onramp_title_available_up_to">Disponible jusqu\'à</string>
<string name="onramp_title_you_get">Vous obtenez</string>
<string name="onramp_token_is_not_supported_banner_subtitle">Ce token n\'est pas pris en charge. Veuillez choisir un autre token à acheter.</string>
<string name="onramp_token_is_not_supported_banner_title">%s n\'est pas pris en charge</string>
<string name="onramp_tos_external_providers">Le service est fourni par un prestataire externe. Tangem n\'est pas responsable.</string>
<string name="onramp_transaction_status_footer_text">Vous pouvez vérifier l\'état de la transaction à partir de la page du jeton</string>
<string name="onramp_up_to_rate">Jusqu\'à</string>
@ -1175,6 +1193,21 @@
<string name="organize_tokens_sort_by_balance">Par solde</string>
<string name="organize_tokens_title">Organiser les jetons</string>
<string name="organize_tokens_ungroup">Dégrouper</string>
<string name="promo_campaign_already_activated_subtitle">Le cashback éligible sera distribué à :</string>
<string name="promo_campaign_already_activated_title">Vous êtes déjà inscrit(e) à %1$s</string>
<string name="promo_campaign_eligible_tokens">Tokens éligibles</string>
<string name="promo_campaign_enroll">S\'inscrire</string>
<string name="promo_campaign_enroll_success_title">Vous êtes bien inscrit(e) à %1$s</string>
<string name="promo_campaign_not_active_subtitle">Cette campagne n\'existe plus ou a expiré</string>
<string name="promo_campaign_not_active_title">Campagne non active</string>
<string name="promo_campaign_reactivation_summary_description">Gagnez 0,5% de cashback sur chaque swap de plus de 500 $, sur toute paire sauf stablecoin vers stablecoin. Gain maximum : 50 $ par swap.\n\nComplétez cinq swaps éligibles et débloquez un bonus supplémentaire de 10 $.\n\nLes récompenses sont versées chaque semaine en USDT ou USDC à l\'adresse sélectionnée.</string>
<string name="promo_campaign_select_cashback_account">Sélectionner le compte pour recevoir le cashback</string>
<string name="promo_campaign_select_token">Sélectionner le token</string>
<string name="promo_campaign_summary_title">S\'inscrire à %1$s</string>
<string name="promo_campaign_terms_agreement">J\'accepte les %1$s</string>
<string name="promo_campaign_terms_agreement_android">J\'accepte les</string>
<string name="promo_campaign_terms_link">Conditions de %1$s</string>
<string name="promo_campaign_whale_swap_summary_description">Gagnez du cashback sur chaque swap à partir de 10 000 $ jusqu\'à fin juillet.\n\nLes taux augmentent selon le montant : 0,10% à partir de 10 000 $, 0,20% à partir de 20 000 $, 0,50% à partir de 100 000 $.\n\nGain maximum : 500 $ par swap, et 10 000 $ par wallet et par sens de swap pendant toute la durée de la campagne. Les swaps stablecoin vers stablecoin sont exclus.\n\nLe paiement arrive chaque semaine à l\'adresse USDT ou USDC de votre choix.</string>
<string name="provider_name_support">Assistance %s</string>
<string name="push_notification_settings_banner_description">Les notifications push sont activées mais ne fonctionneront pas avant que vous les autorisiez</string>
<string name="push_notification_settings_banner_title">Autoriser les notifications</string>
@ -1200,7 +1233,12 @@
<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_buy_description">Carte ou compte en banque</string>
<string name="quick_action_receive_description">Partager votre adresse ou code QR</string>
<string name="quick_action_sell_description">Vendre de la crypto en sécurité</string>
<string name="quick_action_send_and_swap_description">Envoyer en échangeant vers un autre token</string>
<string name="quick_action_send_description">Envoyer vers un autre portefeuille</string>
<string name="quick_action_swap_description">Une crypto contre une autre</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>
@ -1614,6 +1652,7 @@
<string name="tangem_pay_beta_notification_title">Tangem Pay en version bêta</string>
<string name="tangem_pay_card_frozen">Carte gelée</string>
<string name="tangem_pay_card_payment">Paiement par carte</string>
<string name="tangem_pay_close_card_disabled_last_card">Vous ne pouvez pas clôturer la dernière carte</string>
<string name="tangem_pay_close_card_popup_description">Il disparaîtra de lapplication</string>
<string name="tangem_pay_close_card_popup_primary_button_title">Clôturer la carte</string>
<string name="tangem_pay_close_card_popup_secondary_button_title">Retour</string>
@ -1776,6 +1815,7 @@
<string name="tangempay_kyc_rejected_description">Désolé, nous n\'avons pas pu vérifier</string>
<string name="tangempay_kyc_rejected_description_span">votre identité.</string>
<string name="tangempay_maximum_cards_issued_description">Vous pouvez avoir jusqu\'à 3 cartes. Supprimez une carte pour pouvoir en ajouter une nouvelle.</string>
<string name="tangempay_maximum_cards_issued_title">Nombre maximum de cartes émises</string>
<string name="tangempay_newonboard_Q1_body">Oui — pour utiliser une carte Visa réglementée, la vérification didentité est obligatoire. Le KYC est géré par Sumsub, partenaire conformité.</string>
<string name="tangempay_newonboard_Q1_title">Dois-je fournir mes documents ?</string>
<string name="tangempay_newonboard_Q2_body">Non. Le KYC sapplique uniquement au compte Tangem Pay. Votre Tangem Wallet reste un environnement distinct, en self-custody et sans KYC.</string>
@ -1820,6 +1860,9 @@
<string name="tangempay_reissue_card_insufficient_funds_subtitle">Déposez des USDC sur le compte de paiement pour couvrir les frais démission</string>
<string name="tangempay_reissue_card_insufficient_funds_title">Impossible de couvrir les frais</string>
<string name="tangempay_reissue_card_title">Réémettre votre carte ?</string>
<string name="tangempay_remove_account">Retirer le compte</string>
<string name="tangempay_remove_account_alert_description">Tangem Pay sera retiré de l\'écran principal et ne réapparaîtra plus, même après une réinstallation de l\'application.</string>
<string name="tangempay_remove_account_alert_title">Retirer le compte?</string>
<string name="tangempay_service_unavailable_description">Nous réparons un problème technique. Veuillez réessayer plus tard.</string>
<string name="tangempay_service_unavailable_title">Service temporairement indisponible</string>
<string name="tangempay_service_unreachable_try_later">Les données ne peuvent pas être affichées pour le moment, mais les paiements par carte fonctionnent toujours.</string>
@ -2358,6 +2401,7 @@
<string name="yield_module_approve_sheet_fee_note">Les frais seront prélevés et vos actifs seront à nouveau prêtés.</string>
<string name="yield_module_approve_sheet_subtitle">Pour continuer à percevoir des revenus, une autorisation est nécessaire.</string>
<string name="yield_module_approve_sheet_title">Confirmer l\'approbation</string>
<string name="yield_module_average_apy">APY actuel %1$s%%</string>
<string name="yield_module_balance_info_sheet_subtitle">Vos fonds sont actuellement affectés au protocole Aave, mais vous pouvez les gérer à tout moment.</string>
<string name="yield_module_balance_info_sheet_title">Vos %s sont déposés dans Aave.</string>
<string name="yield_module_chart_loading_error">Impossible de charger le graphique...</string>

View file

@ -717,6 +717,7 @@
<string name="generic_error">エラーが発生しました</string>
<string name="generic_error_code">エラーが発生しました。コード: %s 。</string>
<string name="generic_requires_memo_error">メモが必要</string>
<string name="get_token_title">%1$sを入手</string>
<string name="give_permission_current_transaction">取引</string>
<string name="give_permission_policy_type_footer">承認すると、このスマートコントラクトが今後の取引でトークンを利用できるようになります。</string>
<string name="give_permission_rows_amount">金額%s</string>
@ -733,6 +734,8 @@
<string name="hardware_wallet_key_feature_title">鍵生成</string>
<string name="hardware_wallet_security_feature_description">すべての暗号処理は、複製や物理的な改ざんに対する認証を受けたセキュアチップ内で行われます。</string>
<string name="hardware_wallet_security_feature_title">ハードウェアレベルのセキュリティ</string>
<string name="high_fee_warning_description">ネットワークが混雑しています。今すぐ続けることもできますが、後でお試しいただくと、手数料が安くなる場合があります。</string>
<string name="high_fee_warning_title">ネットワーク手数料が通常より高くなっています</string>
<string name="home_button_add_existing_wallet">既存のウォレットを追加</string>
<string name="home_button_create_new_wallet">新しいウォレットを作成する</string>
<string name="home_button_order">Tangemを注文</string>
@ -887,12 +890,14 @@
<string name="markets_portfolio_block_add_token_title">**ポートフォリオに追加して**、この資産の買付・交換・受け取りを始めましょう</string>
<string name="markets_portfolio_block_subtitle">ポートフォリオ内</string>
<string name="markets_portfolio_block_title">ポートフォリオ</string>
<string name="markets_portfolio_block_token_unsupported">**トークンはサポートされていません**。このトークンは現在、このウォレットではサポートされていません</string>
<string name="markets_pulse_common_title">マーケット動向</string>
<string name="markets_quick_actions">クイックアクション</string>
<string name="markets_search_clear_all_hints">すべてクリア</string>
<string name="markets_search_header_title">トークンを探す</string>
<string name="markets_search_hint_header">最近の検索</string>
<string name="markets_search_portfolio_header">ポートフォリオ内</string>
<string name="markets_search_recent_tokens_header">最近表示したトークン</string>
<string name="markets_search_result_title">結果</string>
<string name="markets_search_see_tokens_under_100k">時価総額10万ドル以下のトークンを見る</string>
<string name="markets_search_show_tokens">トークンを表示</string>
@ -1238,6 +1243,8 @@
<string name="onramp_title_available_from">利用可能:</string>
<string name="onramp_title_available_up_to">利用可能金額</string>
<string name="onramp_title_you_get">以下が手に入ります。</string>
<string name="onramp_token_is_not_supported_banner_subtitle">このトークンはサポートされていません。別のトークンを選択して購入してください。</string>
<string name="onramp_token_is_not_supported_banner_title">%sはサポートされていません</string>
<string name="onramp_tos_external_providers">サービスは外部プロバイダーによって提供されます。 \nTangemは責任を負いません。</string>
<string name="onramp_transaction_status_footer_text">この画面を閉じて、トークンの詳細画面で取引状況を確認できます。</string>
<string name="onramp_up_to_rate">最大</string>
@ -1301,7 +1308,7 @@
<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_action_swap_description">暗号資産を別の暗号資産に交換</string>
<string name="quick_top_up_chip_other">その他</string>
<string name="quick_top_up_title">クイック入金</string>
<string name="receive_bottom_sheet_no_memo_required_message">メモ不要</string>
@ -2511,7 +2518,7 @@
<string name="yield_module_approve_sheet_fee_note">手数料が差し引かれ、暗号資産が補充されます。</string>
<string name="yield_module_approve_sheet_subtitle">利息を継続的に生み出すには承認が必要です。</string>
<string name="yield_module_approve_sheet_title">承認を確定する</string>
<string name="yield_module_average_apy">平均APY %1$s%%</string>
<string name="yield_module_average_apy">現在のAPY %1$s%%</string>
<string name="yield_module_balance_info_sheet_subtitle">あなたの資金は現在Aaveプロトコルに預けられていますが、いつでも自由に管理できます。</string>
<string name="yield_module_balance_info_sheet_title">%sはAaveに供給されています</string>
<string name="yield_module_chart_loading_error">チャートを読み込めません・・</string>

View file

@ -131,6 +131,7 @@
<string name="address_book_no_contacts">Ainda não há contatos.</string>
<string name="address_book_no_contacts_description">Os contatos que você adicionar aparecerão aqui.</string>
<string name="address_book_remove_address">Remover endereço</string>
<string name="address_book_save_address">Salvar endereço</string>
<string name="address_book_save_contact">Salvar contato</string>
<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>
@ -638,6 +639,8 @@
<string name="express_exchange_notification_long_transaction_time_title">Tempo de transação longo</string>
<string name="express_exchange_notification_refund_text">O valor da transação foi reembolsado em %1$s para sua carteira devido às regras do OKX ou da ponte. %2$s</string>
<string name="express_exchange_notification_refund_title">O valor foi reembolsado em %1$s (%2$s rede)</string>
<string name="express_exchange_notification_refunded_in_text">Seus fundos foram reembolsados em %1$s para sua carteira no %2$s rede, de acordo com as regras da bolsa OKX.</string>
<string name="express_exchange_notification_refunded_in_title">Reembolsado em %s</string>
<string name="express_exchange_notification_verification_text">Visite o site do fornecedor para verificação.</string>
<string name="express_exchange_notification_verification_title">Verificação KYC exigida pelo fornecedor</string>
<string name="express_exchange_status_bought">Compra concluída</string>
@ -924,12 +927,14 @@
<string name="markets_portfolio_block_subtitle">Em seu portfólio</string>
<string name="markets_portfolio_block_title">Seu portfólio</string>
<string name="markets_portfolio_block_token_unsupported">**Token não suportado**. Este token não é suportado na carteira neste momento.</string>
<string name="markets_portfolio_eligible_block_title">Outros tokens elegíveis</string>
<string name="markets_pulse_common_title">Pulso do mercado</string>
<string name="markets_quick_actions">Ações rápidas</string>
<string name="markets_search_clear_all_hints">Limpar tudo</string>
<string name="markets_search_header_title">Pesquisar tokens</string>
<string name="markets_search_hint_header">Recentes</string>
<string name="markets_search_hint_header">Pesquisas recentes</string>
<string name="markets_search_portfolio_header">Em seu portfólio</string>
<string name="markets_search_recent_tokens_header">Tokens recentes</string>
<string name="markets_search_result_title">Resultado</string>
<string name="markets_search_see_tokens_under_100k">Veja tokens com capitalização de mercado inferior a 100 mil dólares.</string>
<string name="markets_search_show_tokens">Mostrar tokens</string>
@ -1305,9 +1310,11 @@
<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_subtitle">Esta campanha não existe ou expirou.</string>
<string name="promo_campaign_not_active_title">Campanha inativa</string>
<string name="promo_campaign_reactivation_summary_description">Ganhe 0,5% Cashback em todas as trocas acima de US$ 500, em qualquer par, exceto entre pares estáveis. Pagamento máximo de US$ 50 por troca.\n\nComplete cinco trocas qualificadas e desbloqueie um bônus extra de US$ 10.\n\nAs recompensas são pagas semanalmente em USDT ou USDC no endereço selecionado.</string>
<string name="promo_campaign_select_cashback_account">Selecione a conta de cashback</string>
<string name="promo_campaign_select_token">Selecionar token</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>
@ -1350,7 +1357,7 @@
<string name="quick_action_sell_description">Venda criptomoedas com segurança</string>
<string name="quick_action_send_and_swap_description">Enviar com troca para outro token</string>
<string name="quick_action_send_description">Enviar para outra carteira</string>
<string name="quick_action_swap_description">Entre seus portfólios</string>
<string name="quick_action_swap_description">Uma cripto por outra</string>
<string name="quick_top_up_chip_other">Outro</string>
<string name="quick_top_up_title">Recarga rápida</string>
<string name="receive_bottom_sheet_no_memo_required_message">Não é necessário memorando</string>
@ -1975,6 +1982,7 @@
<string name="tangempay_kyc_rejected_button_text">Ocultar bloco KYC</string>
<string name="tangempay_kyc_rejected_description">Desculpe, não foi possível verificar.</string>
<string name="tangempay_kyc_rejected_description_span">Seu perfil.</string>
<string name="tangempay_main_select_plan">Selecione o plano</string>
<string name="tangempay_maximum_cards_issued_description">Você pode ter até 3 cartões. Exclua um para adicionar um novo.</string>
<string name="tangempay_maximum_cards_issued_title">Número máximo de cartões emitidos</string>
<string name="tangempay_newonboard_Q1_body">Sim — para usar um cartão Visa regulado, a verificação de identidade é obrigatória. O KYC é feito pela Sumsub, parceira de compliance.</string>

View file

@ -377,7 +377,7 @@
<string name="common_from">Из</string>
<string name="common_from_wallet_name">Из %s</string>
<string name="common_generate_addresses">Синхронизировать адреса</string>
<string name="common_get">Приобрести</string>
<string name="common_get">Пополнить</string>
<string name="common_get_started">Начать</string>
<string name="common_get_token">Получить токен</string>
<string name="common_go_to_provider">К провайдеру</string>
@ -749,6 +749,7 @@
<string name="generic_error">Произошла ошибка</string>
<string name="generic_error_code">Произошла ошибка. Код: %s.</string>
<string name="generic_requires_memo_error">Требуется memo</string>
<string name="get_token_title">Пополнить %1$s</string>
<string name="give_permission_current_transaction">Транзакция</string>
<string name="give_permission_policy_type_footer">Укажите лимит доступа к выбранному токену</string>
<string name="give_permission_rows_amount">Количество %s</string>
@ -765,6 +766,8 @@
<string name="hardware_wallet_key_feature_title">Генерация ключа</string>
<string name="hardware_wallet_security_feature_description">Операции с криптографией выполняются внутри защищённого чипа, устойчивого к клонированию и физическому взлому.</string>
<string name="hardware_wallet_security_feature_title">Аппаратная безопасность</string>
<string name="high_fee_warning_description">Высокая активность в сети. Вы можете продолжить сейчас или попробовать позже, когда комиссии будут ниже.</string>
<string name="high_fee_warning_title">Комиссия сети выше, чем обычно</string>
<string name="home_button_add_existing_wallet">Добавить существующий кошелек</string>
<string name="home_button_create_new_wallet">Создать новый кошелек</string>
<string name="home_button_order">Купить</string>
@ -922,12 +925,14 @@
<string name="markets_portfolio_block_add_token_title">**Добавьте в свой портфель**, чтобы покупать, обменивать или получать этот актив.</string>
<string name="markets_portfolio_block_subtitle">В вашем портфеле</string>
<string name="markets_portfolio_block_title">Ваш портфель</string>
<string name="markets_portfolio_block_token_unsupported">**Токен не поддерживается**. В настоящее время этот токен не поддерживается в кошельке</string>
<string name="markets_pulse_common_title">Пульс рынка</string>
<string name="markets_quick_actions">Быстрые действия</string>
<string name="markets_search_clear_all_hints">Очистить всё</string>
<string name="markets_search_header_title">Поиск токенов</string>
<string name="markets_search_hint_header">Недавние</string>
<string name="markets_search_hint_header">Недавние запросы</string>
<string name="markets_search_portfolio_header">В вашем портфеле</string>
<string name="markets_search_recent_tokens_header">Недавние токены</string>
<string name="markets_search_result_title">Результат</string>
<string name="markets_search_see_tokens_under_100k">Токены с капитализацией меньше 100к USD</string>
<string name="markets_search_show_tokens">Показать токены</string>
@ -1306,6 +1311,8 @@
<string name="onramp_title_available_from">Доступно от</string>
<string name="onramp_title_available_up_to">Доступно до</string>
<string name="onramp_title_you_get">Вы получите</string>
<string name="onramp_token_is_not_supported_banner_subtitle">Этот токен не поддерживается. Пожалуйста, выберите другой токен для покупки.</string>
<string name="onramp_token_is_not_supported_banner_title">%s не поддерживается</string>
<string name="onramp_tos_external_providers">Услуга предоставляется сторонним провайдером.\nTangem не несет ответственности.</string>
<string name="onramp_transaction_status_footer_text">Вы можете закрыть этот экран и проверить статус транзакции на экране информации о токене.</string>
<string name="onramp_up_to_rate">До</string>
@ -1318,6 +1325,20 @@
<string name="organize_tokens_sort_by_balance">По балансу</string>
<string name="organize_tokens_title">Упорядочить токены</string>
<string name="organize_tokens_ungroup">Список</string>
<string name="promo_campaign_already_activated_subtitle">Начисленный кешбэк будет отправлен на:</string>
<string name="promo_campaign_already_activated_title">Вы уже участвуете в %1$s</string>
<string name="promo_campaign_eligible_tokens">Доступные токены</string>
<string name="promo_campaign_enroll">Участвовать</string>
<string name="promo_campaign_enroll_success_title">Вы успешно стали участником в %1$s</string>
<string name="promo_campaign_not_active_subtitle">Эта кампания больше не существует или срок ее действия истек</string>
<string name="promo_campaign_not_active_title">Кампания неактивна</string>
<string name="promo_campaign_reactivation_summary_description">Получайте 0.5% кешбэка за каждый обмен от $500 в любой паре, кроме стейблкоина на стейблкоин. Максимальная выплата: $50 за обмен.\n\nСовершите пять соответствующих условиям обменов и разблокируйте дополнительный бонус в $10.\n\nНаграды выплачиваются еженедельно в USDT или USDC на выбранный адрес.</string>
<string name="promo_campaign_select_cashback_account">Выберите счет для кешбэка</string>
<string name="promo_campaign_select_token">Выберите токен</string>
<string name="promo_campaign_summary_title">Участвовать в %1$s</string>
<string name="promo_campaign_terms_agreement">Я принимаю %1$s</string>
<string name="promo_campaign_terms_link">%1$s Условия</string>
<string name="promo_campaign_whale_swap_summary_description">Получайте кешбэк за каждый обмен от $10K до конца июля.\n\nСтавка растет вместе с суммой: 0.10% от $10K, 0.20% от $20K, 0.50% от $100K.\n\nМаксимальная выплата: $500 за обмен и $10,000 на кошелек для каждого направления обмена, пока длится кампания. Обмены стейблкоина на стейблкоин исключены.\n\nВыплата поступает еженедельно на выбранный вами адрес USDT или USDC.</string>
<string name="provider_name_support">Поддержка %s</string>
<string name="push_notification_settings_banner_description">Push-уведомления включены, но не будут работать без вашего разрешения</string>
<string name="push_notification_settings_banner_title">Разрешить уведомления</string>
@ -1350,12 +1371,12 @@
<string name="qr_scanner_error_unsupported_network_title">Поддерживаемые токены не найдены</string>
<string name="qr_scanner_warning_unknown_parameters_message">Этот QR-код содержит параметры, которые не распознаны: %s. Некоторые данные платежа могут быть утеряны, если вы продолжите.</string>
<string name="qr_scanner_warning_unknown_parameters_title">Неизвестные параметры</string>
<string name="quick_action_buy_description">Банковская карта или банковский счет</string>
<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_action_swap_description">Обменяйте одну криптовалюту на другую</string>
<string name="quick_top_up_chip_other">Другие</string>
<string name="quick_top_up_title">Быстрое пополнение</string>
<string name="receive_bottom_sheet_no_memo_required_message">Memo не требуется</string>

View file

@ -377,6 +377,7 @@
<string name="common_from">З</string>
<string name="common_from_wallet_name">З %s</string>
<string name="common_generate_addresses">Синхронізувати адреси</string>
<string name="common_get">Поповнити</string>
<string name="common_get_started">Почати</string>
<string name="common_get_token">Отримати токен</string>
<string name="common_go_to_provider">Перейти до провайдера</string>
@ -748,6 +749,7 @@
<string name="generic_error">Виникла помилка</string>
<string name="generic_error_code">Виникла помилка. Код: %s.</string>
<string name="generic_requires_memo_error">Вимагається memo</string>
<string name="get_token_title">Поповнити %1$s</string>
<string name="give_permission_current_transaction">Транзакція</string>
<string name="give_permission_policy_type_footer">Вкажіть ліміт доступу для обраного токена</string>
<string name="give_permission_rows_amount">Кількість %s</string>
@ -764,6 +766,8 @@
<string name="hardware_wallet_key_feature_title">Генерація ключа</string>
<string name="hardware_wallet_security_feature_description">Операції з криптографією відбуваються всередині захищеного чіпу, стійкого до клонування та фізичному взлому.</string>
<string name="hardware_wallet_security_feature_title">Безпека на апаратному рівні</string>
<string name="high_fee_warning_description">Висока активність у мережі. Ви можете продовжити зараз або спробувати пізніше, коли комісії будуть нижчими.</string>
<string name="high_fee_warning_title">Комісія мережі вища, ніж зазвичай</string>
<string name="home_button_add_existing_wallet">Додати існуючий гаманець</string>
<string name="home_button_create_new_wallet">Створити новий гаманець</string>
<string name="home_button_order">Купити</string>
@ -921,12 +925,14 @@
<string name="markets_portfolio_block_add_token_title">**Додайте до свого портфеля**, щоб купувати, обмінювати або отримувати цей актив</string>
<string name="markets_portfolio_block_subtitle">У вашому портфелі</string>
<string name="markets_portfolio_block_title">Ваш портфель</string>
<string name="markets_portfolio_block_token_unsupported">**Токен не підтримується**. Наразі цей токен не підтримується в гаманці</string>
<string name="markets_pulse_common_title">Пульс ринку</string>
<string name="markets_quick_actions">Швидкі дії</string>
<string name="markets_search_clear_all_hints">Очистити все</string>
<string name="markets_search_header_title">Шукати на маркеті</string>
<string name="markets_search_hint_header">Нещодавні</string>
<string name="markets_search_hint_header">Нещодавні запити</string>
<string name="markets_search_portfolio_header">У вашому портфелі</string>
<string name="markets_search_recent_tokens_header">Нещодавні токени</string>
<string name="markets_search_result_title">Результат</string>
<string name="markets_search_see_tokens_under_100k">Переглянути токени до 100к USD ринкової капіталізації</string>
<string name="markets_search_show_tokens">Показати токени</string>
@ -1305,6 +1311,8 @@
<string name="onramp_title_available_from">Доступно з</string>
<string name="onramp_title_available_up_to">Доступно до</string>
<string name="onramp_title_you_get">Ви отримаєте</string>
<string name="onramp_token_is_not_supported_banner_subtitle">Цей токен не підтримується. Будь ласка, виберіть інший токен для купівлі.</string>
<string name="onramp_token_is_not_supported_banner_title">%s не підтримується</string>
<string name="onramp_tos_external_providers">Послуга надається зовнішнім провайдером. \nTangem не несе відповідальності.</string>
<string name="onramp_transaction_status_footer_text">Ви можете перевірити статус транзакції на сторінці токена</string>
<string name="onramp_up_to_rate">До</string>
@ -1317,6 +1325,20 @@
<string name="organize_tokens_sort_by_balance">За балансом</string>
<string name="organize_tokens_title">Сортування токенів</string>
<string name="organize_tokens_ungroup">Список</string>
<string name="promo_campaign_already_activated_subtitle">Нарахований кешбек буде надіслано на:</string>
<string name="promo_campaign_already_activated_title">Ви вже долучилися до %1$s</string>
<string name="promo_campaign_eligible_tokens">Доступні токени</string>
<string name="promo_campaign_enroll">Долучитися</string>
<string name="promo_campaign_enroll_success_title">Ви успішно долучилися до %1$s</string>
<string name="promo_campaign_not_active_subtitle">Ця кампанія більше не існує або термін її дії закінчився</string>
<string name="promo_campaign_not_active_title">Кампанія неактивна</string>
<string name="promo_campaign_reactivation_summary_description">Отримуйте 0.5% кешбеку за кожний обмін від $500 у будь-якій парі, крім стейблкоїна на стейблкоїн. Максимальна виплата: $50 за обмін.\n\nЗдійсніть п\'ять відповідних умовам обмінів та розблокуйте додатковий бонус у $10.\n\nНагороди виплачуються щотижня в USDT або USDC на обрану адресу.</string>
<string name="promo_campaign_select_cashback_account">Оберіть рахунок для кешбеку</string>
<string name="promo_campaign_select_token">Оберіть токен</string>
<string name="promo_campaign_summary_title">Долучитися до %1$s</string>
<string name="promo_campaign_terms_agreement">Я приймаю %1$s</string>
<string name="promo_campaign_terms_link">%1$s Умови</string>
<string name="promo_campaign_whale_swap_summary_description">Отримуйте кешбек за кожний обмін від $10K до кінця липня.\n\nСтавка зростає разом із сумою: 0.10% від $10K, 0.20% від $20K, 0.50% від $100K.\n\nМаксимальна виплата: $500 за обмін та $10,000 на гаманець для кожного напрямку обмінів, поки триває кампанія. Обміни стейблкоїна на стейблкоїн виключено.\n\nВиплата надходить щотижня на обрану вами адресу USDT або USDC.</string>
<string name="provider_name_support">Підтримка %s</string>
<string name="push_notification_settings_banner_description">Push-сповіщення ввімкнені, але не працюватимуть без вашого дозволу</string>
<string name="push_notification_settings_banner_title">Дозволити сповіщення</string>
@ -1354,7 +1376,7 @@
<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_action_swap_description">Обміняйте одну криптовалюту на іншу</string>
<string name="quick_top_up_chip_other">Інші</string>
<string name="quick_top_up_title">Швидке поповнення</string>
<string name="receive_bottom_sheet_no_memo_required_message">Memo не вимагається</string>
@ -2010,6 +2032,7 @@
<string name="tangempay_reissue_card_insufficient_funds_subtitle">Поповніть платіжний рахунок в USDC, щоб покрити комісію за випуск</string>
<string name="tangempay_reissue_card_insufficient_funds_title">Неможливо покрити комісію</string>
<string name="tangempay_reissue_card_title">Перевипустити картку?</string>
<string name="tangempay_remove_account">Видалити аккаунт</string>
<string name="tangempay_service_unavailable_description">Ми усуваємо технічну проблему. Будь ласка, спробуйте пізніше.</string>
<string name="tangempay_service_unavailable_title">Сервіс тимчасово недоступний</string>
<string name="tangempay_service_unreachable_try_later">Не можемо показати дані картки, але оплати продовжують працювати.</string>
@ -2534,7 +2557,7 @@
<string name="yield_module_approve_sheet_fee_note">Комісія буде знята, а ваші активи знову почнуть приносити дохід.</string>
<string name="yield_module_approve_sheet_subtitle">Щоб продовжити заробляти, потрібне схвалення.</string>
<string name="yield_module_approve_sheet_title">Підтвердити дозвіл</string>
<string name="yield_module_average_apy">Середній APY %1$s%%</string>
<string name="yield_module_average_apy">Поточний APY %1$s%%</string>
<string name="yield_module_balance_info_sheet_subtitle">Наразі ваші кошти розміщені у протоколі Aave, але ви можете керувати ними в будь-який час.</string>
<string name="yield_module_balance_info_sheet_title">Ваш %s внесений у Aave</string>
<string name="yield_module_chart_loading_error">Неможливо завантажити графік</string>

View file

@ -713,6 +713,7 @@
<string name="generic_error">发生错误</string>
<string name="generic_error_code">发生错误。错误代码: %s。</string>
<string name="generic_requires_memo_error">需要备忘录</string>
<string name="get_token_title">获取 %1$s</string>
<string name="give_permission_current_transaction">交易</string>
<string name="give_permission_policy_type_footer">批准后,您即允许智能合约在未来的交易中使用您的代币。</string>
<string name="give_permission_rows_amount">金额 %s</string>
@ -729,6 +730,8 @@
<string name="hardware_wallet_key_feature_title">密钥生成</string>
<string name="hardware_wallet_security_feature_description">所有加密操作都在安全芯片内部进行,该芯片经过认证,可防止克隆和物理篡改。</string>
<string name="hardware_wallet_security_feature_title">硬件级安全</string>
<string name="high_fee_warning_description">网络流量较大,您现在可以继续,也可以稍后再试,届时手续费可能会更低。</string>
<string name="high_fee_warning_title">网络费用比平时高</string>
<string name="home_button_add_existing_wallet">添加现有钱包</string>
<string name="home_button_create_new_wallet">创建新钱包</string>
<string name="home_button_order">订购 Tangem</string>
@ -887,8 +890,9 @@
<string name="markets_quick_actions">快速行动</string>
<string name="markets_search_clear_all_hints">全部清除</string>
<string name="markets_search_header_title">搜索代币</string>
<string name="markets_search_hint_header">最近</string>
<string name="markets_search_hint_header">最近搜索</string>
<string name="markets_search_portfolio_header">在您的投资组合中</string>
<string name="markets_search_recent_tokens_header">近期代币</string>
<string name="markets_search_result_title">结果</string>
<string name="markets_search_see_tokens_under_100k">查看市值低于 10 万美元的代币</string>
<string name="markets_search_show_tokens">显示代币</string>
@ -1234,6 +1238,8 @@
<string name="onramp_title_available_from">可从</string>
<string name="onramp_title_available_up_to">最高可提供</string>
<string name="onramp_title_you_get">你得到</string>
<string name="onramp_token_is_not_supported_banner_subtitle">不支持该代币。请选择其他代币进行购买。</string>
<string name="onramp_token_is_not_supported_banner_title">%s 不支持</string>
<string name="onramp_tos_external_providers">服务由外部供应商提供。\nTangem 不承担任何责任。</string>
<string name="onramp_transaction_status_footer_text">您可以关闭此屏幕,并在代币详情屏幕上查看交易状态。</string>
<string name="onramp_up_to_rate">至多</string>
@ -1940,6 +1946,9 @@
<string name="tangempay_reissue_card_insufficient_funds_subtitle">将USDC存入支付账户以支付发行费用</string>
<string name="tangempay_reissue_card_insufficient_funds_title">无法支付费用</string>
<string name="tangempay_reissue_card_title">要重新发行您的卡片吗?</string>
<string name="tangempay_remove_account">删除账户</string>
<string name="tangempay_remove_account_alert_description">Tangem Pay 将从主屏幕中移除,即使重新安装该应用,它也不会再次出现。</string>
<string name="tangempay_remove_account_alert_title">删除账号?</string>
<string name="tangempay_service_unavailable_description">我们正在修复技术问题,请稍后再试。</string>
<string name="tangempay_service_unavailable_title">服务暂时不可用</string>
<string name="tangempay_service_unreachable_try_later">无法显示详细信息。但刷卡支付功能仍然可用。</string>

View file

@ -93,7 +93,7 @@
<string name="addfunds_buy_row_description">Credit card or bank account</string>
<string name="addfunds_fund_token">Fund token</string>
<string name="addfunds_receive_row_description">Share your address or QR-code</string>
<string name="addfunds_swap_row_description">Between your portfolios</string>
<string name="addfunds_swap_row_description">Exchange one crypto for another</string>
<string name="addfunds_you_receive_title">You receive</string>
<string name="address_book_add_address">Add address</string>
<string name="address_book_add_address_description">Add address and select network</string>
@ -726,6 +726,11 @@
<string name="feedback_subject_tx_failed">Can\'t send a transaction</string>
<string name="feedback_token_description_error">Coin description error</string>
<string name="for_you_description">Review portfolio and explore earn opportunities</string>
<string name="for_you_earn_opportunities">Earn opportunities</string>
<string name="for_you_earn_opportunities_all_tokens_active">All your assets are at work, explore new opportunities</string>
<string name="for_you_earn_opportunities_no_available_tokens">Get up to %1$s annually</string>
<string name="for_you_earn_opportunities_tokens_rewards">Max potential rewards %1$s</string>
<string name="for_you_earn_per_year">%1$s/year</string>
<string name="for_you_portfolio_review_title">Portfolio review</string>
<string name="for_you_title">For You</string>
<string name="force_update_action">Update now</string>
@ -933,8 +938,9 @@
<string name="markets_quick_actions">Quick actions</string>
<string name="markets_search_clear_all_hints">Clear all</string>
<string name="markets_search_header_title">Search tokens</string>
<string name="markets_search_hint_header">Recent\'s</string>
<string name="markets_search_hint_header">Recent searches</string>
<string name="markets_search_portfolio_header">In your portfolio</string>
<string name="markets_search_recent_tokens_header">Recent tokens</string>
<string name="markets_search_result_title">Result</string>
<string name="markets_search_see_tokens_under_100k">See tokens under 100k USD market cap</string>
<string name="markets_search_show_tokens">Show tokens</string>
@ -1352,12 +1358,12 @@
<string name="qr_scanner_error_unsupported_network_title">No supported tokens found</string>
<string name="qr_scanner_warning_unknown_parameters_message">This QR code contains parameters that are not recognized: %s. Some payment details may be lost if you continue.</string>
<string name="qr_scanner_warning_unknown_parameters_title">Unknown Parameters</string>
<string name="quick_action_buy_description">Credit card or bank account</string>
<string name="quick_action_buy_description">Get crypto by card, bank transfer &amp; more</string>
<string name="quick_action_receive_description">Share your address or QR-code</string>
<string name="quick_action_sell_description">Sell crypto securely</string>
<string name="quick_action_send_and_swap_description">Send with swap to another token</string>
<string name="quick_action_send_description">Send to another wallet</string>
<string name="quick_action_swap_description">Between your portfolios</string>
<string name="quick_action_sell_description">Convert crypto to fiat currency</string>
<string name="quick_action_send_and_swap_description">Exchange and send in one step</string>
<string name="quick_action_send_description">Transfer crypto to another wallet</string>
<string name="quick_action_swap_description">Exchange one crypto for another</string>
<string name="quick_top_up_chip_other">Other</string>
<string name="quick_top_up_title">Quick top up</string>
<string name="receive_bottom_sheet_no_memo_required_message">No memo required</string>
@ -1986,6 +1992,7 @@
<string name="tangempay_kyc_rejected_button_text">Hide KYC block</string>
<string name="tangempay_kyc_rejected_description">Sorry, we couldn\'t verify</string>
<string name="tangempay_kyc_rejected_description_span">your profile.</string>
<string name="tangempay_main_select_plan">Select plan</string>
<string name="tangempay_maximum_cards_issued_description">You can have up to 3 cards. Delete one to add a new card.</string>
<string name="tangempay_maximum_cards_issued_title">Maximum Cards Issued</string>
<string name="tangempay_newonboard_Q1_body">Yes to operate a regulated Visa card, identity verification is mandatory. KYC is handled by Sumsub (compliance partner).</string>
@ -2019,8 +2026,6 @@
<string name="tangempay_onboarding_setup_wallet_title">We\'ll set up a wallet</string>
<string name="tangempay_onboarding_title">Get your Tangem Pay Card in minutes</string>
<string name="tangempay_pay_support">Pay Support</string>
<string name="tangempay_main_select_plan">Select plan</string>
<string name="tangempay_status_inactive">Inactive</string>
<string name="tangempay_payment_account">Payment account</string>
<string name="tangempay_payment_account_sync_needed">Session expired</string>
<string name="tangempay_pin_validation_error_message">Invalid PIN: avoid sequences or repeats</string>
@ -2051,6 +2056,7 @@
<string name="tangempay_set_pin_header">Set up new PIN</string>
<string name="tangempay_set_pin_title">Set PIN</string>
<string name="tangempay_status_deactivated">Account closed</string>
<string name="tangempay_status_inactive">Inactive</string>
<string name="tangempay_status_replacing">Replacing your card</string>
<string name="tangempay_sync_needed">Use your card or ring to renew session</string>
<string name="tangempay_sync_needed_body">Use your card or ring to renew session</string>
@ -2066,6 +2072,8 @@
<string name="tangempay_topup_swap_body">Use crypto from your wallet to top up your payment account</string>
<string name="tangempay_topup_swap_title">From your Tangem Wallet</string>
<string name="tangempay_usdc_on_polygon_network">USDC on Polygon network</string>
<string name="tangempay_va_banking_details_error_description">Please try again or contact support if the issue persists</string>
<string name="tangempay_va_banking_details_error_title">Couldn\'t load banking details</string>
<string name="tangempay_visa_benefits">Visa Benefits</string>
<string name="tangempay_withdrawal_note_description">Funds from refunded purchases wont be returned your on-chain Polygon balance or be available for withdrawal, but will stay on your card balance for purchases</string>
<string name="tangempay_withdrawal_note_title">Please note</string>

View file

@ -73,7 +73,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
return@flow
}
val dAppUri = URI(sdkSessionProposal.url)
val dAppUri = URI(sdkVerifyContext.getDappOriginUrl())
if (dAppUri.host.isNullOrEmpty()) {
emit(WcPairState.Error(WcPairError.InvalidDomainURL))
return@flow

View file

@ -15,8 +15,8 @@ interface OfframpRepository {
* @param cryptoCurrency crypto currency to sell
* @param fiatCurrencyCode fiat currency code (e.g., "USD", "EUR")
* @param walletAddress wallet address for the refund
* @param requestId single-use nonce embedded into the provider redirect URL to authenticate the
* returning `redirect_sell` deeplink
* @param requestId nonce embedded into the provider redirect URL to authenticate the returning
* `redirect_sell` deeplink
* @return URL for offramp service or null if not available
*/
fun getOfframpUrl(
@ -28,16 +28,19 @@ interface OfframpRepository {
/**
* Registers a new app-initiated sell for [userWalletId] / [currencyId], prunes expired records, and returns a
* fresh single-use `request_id` to embed in the provider redirect URL.
* fresh `request_id` to embed in the provider redirect URL. The record stays valid until it expires.
*/
suspend fun registerPendingOfframp(userWalletId: UserWalletId, currencyId: String): String
/**
* Returns and removes (single-use) the pending sell matching [requestId] only when it is not expired and was
* registered for the same [userWalletId] and [currencyId]. Returns `null` otherwise, leaving a non-matching
* record untouched so a tampered redirect cannot burn a legitimate pending sell.
* Returns the pending sell matching [requestId] when it is not expired and was registered for the same
* [userWalletId] and [currencyId]; returns `null` otherwise.
*
* The matching record is **not** removed it remains valid until it expires, so the same `redirect_sell`
* deeplink can be followed repeatedly within that window (e.g. the user re-opens it). Expired records are pruned
* as a side effect.
*/
suspend fun consumePendingOfframp(
suspend fun resolvePendingOfframp(
requestId: String,
userWalletId: UserWalletId,
currencyId: String,

View file

@ -7,6 +7,7 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.backStack
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.tangem.common.ui.markets.action.TokenActionsContext
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
@ -64,6 +65,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
callbacks = model,
data = model.tokenActionsData,
bottomAction = flowOf(BottomAction.GoToToken),
context = TokenActionsContext.AddFunds,
),
)
}

View file

@ -28,6 +28,14 @@ internal enum class SwapMarketCategory(
title = resourceReference(R.string.markets_sort_by_top_losers_title),
order = TokenMarketListConfig.Order.TopLosers,
),
ExperiencedBuyers(
title = resourceReference(R.string.markets_sort_by_experienced_buyers_title),
order = TokenMarketListConfig.Order.Buyers,
),
Trending(
title = resourceReference(R.string.markets_sort_by_trending_title),
order = TokenMarketListConfig.Order.Trending,
),
}
/**

View file

@ -9,13 +9,9 @@ import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.commonfeatures.api.R
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooserBlock
import com.tangem.features.commonfeatures.api.choosetoken.*
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute
import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarToggleTransformer
import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarUpdateQueryTransformer
@ -24,7 +20,9 @@ import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenFullUM
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM
import com.tangem.features.commonfeatures.impl.choosetoken.ui.state.ChooserBlockUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ -106,6 +104,13 @@ internal class ChooseTokenModel @Inject constructor(
)
init {
if (bridge.settings.chooserBlock == ChooserBlock.Market) {
modelScope.launch {
delay(MARKETS_INITIAL_LOAD_DELAY)
marketBlockDelegate.loadDefaultMarkets()
}
}
addToPortfolioManager.onDismiss.receiveAsFlow()
.onEach { bottomSheetNavigation.dismiss() }
.launchIn(modelScope)
@ -159,5 +164,8 @@ internal class ChooseTokenModel @Inject constructor(
companion object {
const val DEBOUNCE_SEARCH_DELAY = 500L
/** Roughly the bottom sheet entrance animation duration. */
private const val MARKETS_INITIAL_LOAD_DELAY = 400L
}
}

View file

@ -34,6 +34,8 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
private const val MARKET_PULSE_ITEM_LIMIT = 5
@Suppress("LongParameterList")
internal class MarketBlockDelegate @AssistedInject constructor(
private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory,
@ -77,7 +79,7 @@ internal class MarketBlockDelegate @AssistedInject constructor(
* When single-currency wallets aren't selectable here (e.g. swap), the wallet is always
* multi-currency, so we skip the per-wallet logic entirely and return [baseMarketsStateFlow].
*/
val marketsStateFlow: Flow<SwapMarketState?> = if (!shouldShowSingleCurrencyWallets) {
private val walletAwareMarketsStateFlow: Flow<SwapMarketState?> = if (!shouldShowSingleCurrencyWallets) {
baseMarketsStateFlow
} else {
selectedWalletFlow
@ -85,6 +87,10 @@ internal class MarketBlockDelegate @AssistedInject constructor(
.distinctUntilChanged()
}
val marketsStateFlow: Flow<SwapMarketState?> = walletAwareMarketsStateFlow
.map { it.limitMarketPulseItems() }
.distinctUntilChanged()
private val defaultMarketsListManager by lazy {
marketsListBatchFlowManagerFactory.create(
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
@ -113,9 +119,6 @@ internal class MarketBlockDelegate @AssistedInject constructor(
}
.launchIn(modelScope)
// Initial load of default markets
defaultMarketsListManager.reload()
visibleMarketItemIds
.mapNotNull { rawIDS ->
if (rawIDS.isNotEmpty()) {
@ -144,14 +147,22 @@ internal class MarketBlockDelegate @AssistedInject constructor(
.launchIn(modelScope)
}
/**
* Starts the initial load of the default markets list. Not invoked in [init] on purpose:
* the caller decides *if* (market block may be disabled entirely, e.g. Transfer flow) and
* *when* (deferred past the bottom sheet entrance animation, [REDACTED_TASK_KEY]) to trigger it.
*/
fun loadDefaultMarkets() {
defaultMarketsListManager.reload()
}
private fun createDefaultMarketsFlow(): Flow<SwapMarketState> {
val marketsTitle = TextReference.Res(R.string.markets_pulse_common_title)
return combine(
flow = defaultMarketsListManager.uiItems,
flow2 = defaultMarketsListManager.isInInitialLoadingErrorState,
flow3 = defaultMarketsListManager.totalCount,
flow4 = selectedCategoryFlow,
) { uiItems, isError, total, selectedCategory ->
flow3 = selectedCategoryFlow,
) { uiItems, isError, selectedCategory ->
val categories = buildCategoriesUM(selectedCategory)
when {
isError -> SwapMarketState.LoadingError(
@ -167,10 +178,10 @@ internal class MarketBlockDelegate @AssistedInject constructor(
)
else -> SwapMarketState.Content(
items = uiItems,
loadMore = { defaultMarketsListManager.loadMore() },
loadMore = {},
onItemClick = { item -> addToPortfolioItem(item) },
visibleIdsChanged = { visibleDefaultMarketItemIds.value = it },
total = total ?: uiItems.size,
total = uiItems.size,
marketsTitle = marketsTitle,
shouldAssetsCount = false,
categories = categories,
@ -258,6 +269,13 @@ internal class MarketBlockDelegate @AssistedInject constructor(
}
}
private fun SwapMarketState?.limitMarketPulseItems(): SwapMarketState? {
if (this !is SwapMarketState.Content || shouldAssetsCount) return this
if (items.size <= MARKET_PULSE_ITEM_LIMIT) return this
val limitedItems = items.take(MARKET_PULSE_ITEM_LIMIT).toImmutableList()
return copy(items = limitedItems, total = limitedItems.size)
}
private fun addToPortfolioItem(item: MarketsListItemUM) {
val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id)
?: searchMarketsListManager.getTokenMarketById(item.id) ?: return

View file

@ -19,12 +19,14 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
import com.tangem.features.commonfeatures.impl.choosetoken.SettingContextUseCase
import com.tangem.features.commonfeatures.impl.choosetoken.converter.ChooseTokenListItemConverter
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.mapNotNullValues
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@Suppress("LongParameterList")
@ -33,6 +35,7 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor(
private val settingContext: SettingContextUseCase,
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val getWalletsUseCase: GetWalletsUseCase,
dispatchers: CoroutineDispatcherProvider,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
@Assisted private val featureSettings: ChooseTokenBridge.Settings,
@ -48,6 +51,8 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor(
val portfolioList: SharedFlow<Map<UserWalletId, TokenListUMData>> = buildDataFlow()
.distinctUntilChanged()
.throttleLatest(windowMs = UM_UPDATES_THROTTLE_MS)
.flowOn(dispatchers.default)
.shareIn(modelScope, SharingStarted.Eagerly, replay = 1)
private fun buildDataFlow(): Flow<Map<UserWalletId, TokenListUMData>> = channelFlow {
@ -153,4 +158,12 @@ internal interface ClickIntents {
fun onAccountExpandClick(account: Account)
fun onAccountCollapseClick(account: Account)
}
private const val UM_UPDATES_THROTTLE_MS = 100L
/** Emits the first value immediately, then at most one (latest) value per [windowMs]. */
private fun <T> Flow<T>.throttleLatest(windowMs: Long): Flow<T> = conflate().transform { value ->
emit(value)
delay(windowMs)
}

View file

@ -3,6 +3,7 @@ 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
import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
@ -97,6 +98,7 @@ import com.tangem.utils.StringsSigns.DOT
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.first
import kotlin.random.Random
private const val LOAD_MORE_BUFFER = 25
@ -173,7 +175,7 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) {
val nestedScrollConnection = rememberHideKeyboardNestedScrollConnection()
val lazyListState = rememberLazyListState()
TangemSharedTransitionLayout(modifier) {
Box(modifier) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
@ -249,10 +251,13 @@ private fun SetupMarketScrollTracker(marketsState: SwapMarketState, lazyListStat
@Composable
private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapMarketState.Content) {
val visibleItems by remember {
val keyToId = remember(marketState.items) {
marketState.items.associateBy({ it.getComposeKey() }, { it.id })
}
val visibleItems by remember(keyToId) {
derivedStateOf {
lazyListState.layoutInfo.visibleItemsInfo.mapNotNull { itemInfo ->
marketState.items.find { it.getComposeKey() == itemInfo.key }?.id
(itemInfo.key as? String)?.let(keyToId::get)
}
}
}
@ -282,14 +287,36 @@ private fun LazyListScope.assetsTitle() {
private fun LazyListScope.walletListItem(walletList: WalletListUM) {
if (walletList.items.isEmpty()) return
item("wallet_list") {
LazyRow(
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(walletList.items) { um ->
WalletTabItem(um)
}
WalletList(walletList)
}
}
@Composable
private fun WalletList(walletList: WalletListUM, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
val selectedIndex = walletList.items.indexOfFirst { it.isSelected }
LaunchedEffect(selectedIndex) {
if (selectedIndex < 0) return@LaunchedEffect
val layoutInfo = snapshotFlow { listState.layoutInfo }
.first { it.visibleItemsInfo.isNotEmpty() }
val selectedItem = layoutInfo.visibleItemsInfo.firstOrNull { it.index == selectedIndex }
val isFullyVisible = selectedItem != null &&
selectedItem.offset >= layoutInfo.viewportStartOffset &&
selectedItem.offset + selectedItem.size <= layoutInfo.viewportEndOffset
if (!isFullyVisible) {
listState.scrollToItem(selectedIndex)
}
}
LazyRow(
state = listState,
modifier = modifier.padding(top = 16.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(walletList.items) { um ->
WalletTabItem(um)
}
}
}
@ -497,7 +524,7 @@ private fun AccountRow(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
ProvideSharedTransitionScope(Modifier.weight(1f)) {
AccountRowSharedTransitionLayout(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) }
@ -549,9 +576,7 @@ private fun AccountRow(
)
val startStyle = TangemTheme.typography2.captionSemibold12
val stopStyle = TangemTheme.typography2.bodySemibold16
val textStyle by remember(animationFraction.value) {
derivedStateOf { lerp(startStyle, stopStyle, animationFraction.value) }
}
val textStyle = lerp(startStyle, stopStyle, animationFraction.value)
val resizedTitle = when (val titleUM = tokenRowUM.titleUM) {
is TangemTokenRowUM.TitleUM.Content -> titleUM.copy(
text = styledStringReference(
@ -600,6 +625,17 @@ private fun AccountRow(
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun AccountRowSharedTransitionLayout(
modifier: Modifier = Modifier,
content: @Composable SharedTransitionScope.() -> Unit,
) {
TangemSharedTransitionLayout(modifier) {
ProvideSharedTransitionScope(content = content)
}
}
@Composable
private fun AccountTail(isExpanded: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(

View file

@ -7,6 +7,7 @@ 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.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.TransferAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
@ -23,6 +24,7 @@ import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData
@ -153,12 +155,7 @@ internal class ManageFundsModel @Inject constructor(
override fun onQuickActionClick(action: TokenActionsBSContentUM.Action, shouldDismiss: Boolean) {
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.AddFunds -> addFundsQuickActionEvent(action)
ManageFundsComponent.FlowType.Transfer -> when (action) {
TokenActionsBSContentUM.Action.Send -> TransferAnalyticsEvent.ButtonSend()
TokenActionsBSContentUM.Action.Exchange -> TransferAnalyticsEvent.ButtonSwap()
@ -173,6 +170,51 @@ internal class ManageFundsModel @Inject constructor(
}
}
private fun addFundsQuickActionEvent(action: TokenActionsBSContentUM.Action): AnalyticsEvent? {
val request = tokenActionsTrigger.value
return if (launchMode is ManageFundsComponent.LaunchMode.TokenActionsOnly && request != null) {
tokenScreenQuickActionEvent(action, request)
} else {
when (action) {
TokenActionsBSContentUM.Action.Buy -> ManageFundsAnalyticsEvent.ButtonBuy()
TokenActionsBSContentUM.Action.Exchange -> ManageFundsAnalyticsEvent.ButtonSwap()
TokenActionsBSContentUM.Action.Receive -> ManageFundsAnalyticsEvent.ButtonReceive()
else -> null
}
}
}
private fun tokenScreenQuickActionEvent(
action: TokenActionsBSContentUM.Action,
request: TokenActionsRequest,
): AnalyticsEvent? {
val token = request.status.currency.symbol
val blockchain = request.status.currency.network.name
val derivationIndex = request.account.account
.takeUnless { it.isMainAccount }
?.derivationIndex?.value
return when (action) {
TokenActionsBSContentUM.Action.Buy -> TokenScreenAnalyticsEvent.ButtonWithParams.ButtonBuy(
token = token,
blockchain = blockchain,
status = TokenScreenAnalyticsEvent.AVAILABLE,
derivationIndex = derivationIndex,
)
TokenActionsBSContentUM.Action.Exchange -> TokenScreenAnalyticsEvent.ButtonWithParams.ButtonExchange(
token = token,
status = TokenScreenAnalyticsEvent.AVAILABLE,
blockchain = blockchain,
derivationIndex = derivationIndex,
)
TokenActionsBSContentUM.Action.Receive -> TokenScreenAnalyticsEvent.ButtonWithParams.ButtonReceive(
token = token,
status = TokenScreenAnalyticsEvent.AVAILABLE,
blockchain = blockchain,
)
else -> null
}
}
fun buildAvailableToAddDataForChooser(): AvailableToAddData {
val byWallet = filteredEntries.value.groupBy { it.userWallet.walletId }
return AvailableToAddData(

View file

@ -28,6 +28,7 @@ import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -115,6 +116,23 @@ internal class MarketBlockDelegateTest {
assertThat((result as SwapMarketState.Content).items).containsExactly(item1, item2).inOrder()
}
@Test
fun `GIVEN more than 5 market items WHEN default flow emitted THEN only first 5 shown`() = runTest {
// Arrange
val items = (1..7).map { marketItem("token-$it") }
defaultUiItems.value = items.toPersistentList()
val delegate = createDelegate(wallet = MockUserWalletFactory.create())
// Act
val result = lastMarketState(delegate)
// Assert
assertThat(result).isInstanceOf(SwapMarketState.Content::class.java)
val content = result as SwapMarketState.Content
assertThat(content.items).containsExactlyElementsIn(items.take(5)).inOrder()
assertThat(content.total).isEqualTo(5)
}
@Test
fun `GIVEN single-currency wallet WHEN trending emitted THEN market block is hidden`() = runTest {
// Arrange
@ -187,6 +205,28 @@ internal class MarketBlockDelegateTest {
assertThat(result).isNull()
}
@Test
fun `GIVEN NODL wallet WHEN network token is beyond first 5 THEN block still shows it`() = runTest {
// Arrange — 5 tokens on another network first, the wallet-network token only at position 6.
val nodlWallet = MockUserWalletFactory.createSingleWalletWithToken()
val otherNetworkItems = (1..5).map { marketItem("token-eth-$it") }
val walletNetworkItem = marketItem("token-stellar")
(1..5).forEach { tokenMarketsByRawId["token-eth-$it"] = tokenMarket(ETHEREUM_NETWORK_ID) }
tokenMarketsByRawId["token-stellar"] = tokenMarket(STELLAR_NETWORK_ID)
defaultUiItems.value = (otherNetworkItems + walletNetworkItem).toPersistentList()
every {
singleAccountStatusListSupplier(nodlWallet.walletId)
} returns flowOf(accountStatusList(STELLAR_NETWORK_ID))
// Act
val result = lastMarketState(createDelegate(wallet = nodlWallet))
// Assert — network filtering runs on the full list before the 5-item cap, so it isn't dropped.
assertThat(result).isInstanceOf(SwapMarketState.Content::class.java)
assertThat((result as SwapMarketState.Content).items).containsExactly(walletNetworkItem)
}
// region Helpers
private fun TestScope.lastMarketState(delegate: MarketBlockDelegate): SwapMarketState? {

View file

@ -87,7 +87,11 @@ internal class DefaultFeedComponent(
modifier = modifier,
state = state,
promoBannersBlockComponent = ComposableContentComponent { promoModifier ->
promoBannersBlockComponent.ContentWithPadding(modifier = promoModifier, horizontalItemPadding = 16.dp)
promoBannersBlockComponent.ContentWithPadding(
modifier = promoModifier,
horizontalItemPadding = 16.dp,
walletId = null,
)
},
contentPadding = contentPadding,
)

View file

@ -141,13 +141,16 @@ private fun LazyListScope.searchHistoryItems(
onHintClick: (String) -> Unit,
onHistoryTokenClick: (MarketsListItemUM) -> Unit,
) {
if (!history.textHints.isEmpty() || !history.recentTokens.isEmpty()) {
item(key = "recents") {
if (!history.textHints.isEmpty()) {
item(key = "recent searches") {
SectionHeader(
title = stringResourceSafe(R.string.markets_search_hint_header),
onClearAllClick = onClearAllClick,
)
}
item {
SpacerH(12.dp)
}
}
itemsIndexed(
items = history.textHints,
@ -162,7 +165,15 @@ private fun LazyListScope.searchHistoryItems(
}
}
item {
SpacerH(TangemTheme.dimens2.x2)
SpacerH(24.dp)
}
if (!history.recentTokens.isEmpty()) {
item(key = "recent tokens") {
SectionHeader(title = stringResourceSafe(R.string.markets_search_recent_tokens_header))
}
item {
SpacerH(12.dp)
}
}
items(
items = history.recentTokens,

View file

@ -24,8 +24,8 @@ import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.foryou.impl.model.ForYouNotification
import com.tangem.features.foryou.impl.entity.ForYouUM
import com.tangem.features.foryou.impl.model.ForYouNotification
import com.tangem.features.foryou.impl.ui.preview.ForYouPortfolioReviewPreviewData
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import kotlinx.collections.immutable.persistentListOf
@ -55,6 +55,7 @@ internal fun ForYouContent(
) {
promoBannersBlockComponent.ContentWithPadding(
modifier = Modifier.padding(top = 12.dp),
walletId = null,
horizontalItemPadding = 16.dp,
)
@ -94,7 +95,7 @@ private fun ForYouContent_Preview(@PreviewParameter(ForYouContentPreviewProvider
bottomSheetState = remember { mutableStateOf(BottomSheetState.EXPANDED) },
promoBannersBlockComponent = object : PromoBannersBlockComponent {
@Composable
override fun ContentWithPadding(horizontalItemPadding: Dp, modifier: Modifier) {
override fun ContentWithPadding(horizontalItemPadding: Dp, walletId: String?, modifier: Modifier) {
}
override fun setVisibleOnScreen(isVisible: Boolean) {}

View file

@ -76,6 +76,7 @@ internal class AllOffersModel @Inject constructor(
analyticsEventHandler.send(
event = OnrampAnalyticsEvent.OnPaymentMethodChosen(paymentMethod = method.methodConfig.method.name),
)
analyticsEventHandler.send(OnrampAnalyticsEvent.ProvidersScreenOpened())
}
}

View file

@ -1,14 +1,21 @@
package com.tangem.features.promobanners.api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.factory.ComponentFactory
@Immutable
interface PromoBannersBlockComponent {
/**
* @param walletId when non-null, renders the banners of that specific wallet (used by the wallet
* pager so each page shows its own banners synchronously while swiping); when null, renders the
* currently selected wallet's banners.
*/
@Composable
fun ContentWithPadding(horizontalItemPadding: Dp, modifier: Modifier)
fun ContentWithPadding(horizontalItemPadding: Dp, walletId: String?, modifier: Modifier)
fun setVisibleOnScreen(isVisible: Boolean)

View file

@ -26,8 +26,15 @@ internal class DefaultPromoBannersBlockComponent @AssistedInject constructor(
}
@Composable
override fun ContentWithPadding(horizontalItemPadding: Dp, modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
override fun ContentWithPadding(horizontalItemPadding: Dp, walletId: String?, modifier: Modifier) {
val state = if (walletId != null) {
val statesByWallet by model.bannerStates.collectAsStateWithLifecycle()
statesByWallet[walletId]
} else {
val selectedState by model.uiState.collectAsStateWithLifecycle()
selectedState
} ?: return
PromoBannersBlock(
state = state,
modifier = modifier,

View file

@ -1,11 +1,11 @@
package com.tangem.features.promobanners.impl.model
import androidx.core.net.toUri
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.promobanners.impl.analytics.PromoBannerAnalyticsEvent
@ -13,11 +13,13 @@ import com.tangem.features.promobanners.impl.converters.PromoBannerDisplayToNoti
import com.tangem.features.promobanners.impl.repository.PromoBannersRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.collections.immutable.persistentListOf
import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@ -39,20 +41,58 @@ internal class PromoBannersBlockModel @Inject constructor(
private val placeholderName: String = params.placeholder.value
private val shownBannerIds: MutableSet<ShownBannerKey> = ConcurrentHashMap.newKeySet()
private var isVisibleOnScreen: Boolean = params.isInitiallyVisibleOnScreen
private var wasCarouselScrolled = false
private val savedDisplayIdByWalletId: MutableMap<String, Int> = mutableMapOf()
private val prefetchedWalletIds: MutableSet<String> = ConcurrentHashMap.newKeySet()
private val prefetchSemaphore = Semaphore(permits = PREFETCH_PARALLELISM)
val uiState: StateFlow<PromoBannersBlockUM>
field = MutableStateFlow(getInitialState())
/** Raw banners per wallet, filled from cache/prefetch/network. Source of truth for the UI. */
private val rawBannersByWalletId = MutableStateFlow<Map<String, List<PromoBannerDisplay>>>(emptyMap())
private val selectedWalletId = MutableStateFlow<String?>(null)
private val screenVisible = MutableStateFlow(params.isInitiallyVisibleOnScreen)
private val baseBannerStates: StateFlow<Map<String, PromoBannersBlockUM>> =
rawBannersByWalletId
.map { rawByWallet ->
rawByWallet.mapValues { (walletId, banners) -> buildState(walletId, banners, isVisible = false) }
}
.stateIn(modelScope, SharingStarted.Eagerly, emptyMap())
/**
* Per-wallet UI states, so a pager page can render its own wallet's banners synchronously from
* cache as it slides in instead of the whole block waiting for the wallet selection to settle.
* Only cheaply toggles [PromoBannersBlockUM.isVisibleOnScreen] on selection/visibility change.
*/
val bannerStates: StateFlow<Map<String, PromoBannersBlockUM>> =
combine(baseBannerStates, selectedWalletId, screenVisible) { base, selected, visible ->
base.mapValues { (walletId, state) ->
// Only the active wallet is "visible on screen" for analytics purposes; pre-composed
// off-screen pager pages must not emit "banner shown" events.
val shouldBeVisible = visible && walletId == selected
if (state.isVisibleOnScreen == shouldBeVisible) {
state
} else {
state.copy(
isVisibleOnScreen = shouldBeVisible,
)
}
}
}.stateIn(modelScope, SharingStarted.Eagerly, emptyMap())
val uiState: StateFlow<PromoBannersBlockUM> =
combine(bannerStates, selectedWalletId) { states, selected ->
states[selected] ?: getInitialState()
}.stateIn(modelScope, SharingStarted.Eagerly, getInitialState())
init {
subscribeOnSelectedWallet()
prefetchAllWallets()
}
fun setVisibleOnScreen(visible: Boolean) {
isVisibleOnScreen = visible
uiState.update { it.copy(isVisibleOnScreen = visible) }
screenVisible.value = visible
}
private fun subscribeOnSelectedWallet() {
@ -61,50 +101,97 @@ internal class PromoBannersBlockModel @Inject constructor(
.filterNotNull()
.map { it.walletId.stringValue }
.distinctUntilChanged()
.onEach {
.collectLatest { walletId ->
wasCarouselScrolled = false
selectedWalletId.value = walletId
loadWallet(walletId)
}
.collectLatest { walletId -> loadBanners(walletId) }
}
}
private suspend fun loadBanners(walletId: String) {
val languageISOCode = Locale.getDefault().language
/**
* Warms the banners for every wallet in the background so switching to another wallet renders its
* banners instantly from cache instead of after a network gap. Each wallet is fetched at most once
* ([prefetchedWalletIds]); [PromoBannersRepository.getBanners] is a no-op once the wallet is cached.
*/
private fun prefetchAllWallets() {
modelScope.launch {
val languageISOCode = Locale.getDefault().language
userWalletsListRepository.userWallets
.filterNotNull()
.collect { wallets ->
wallets.forEach { wallet ->
val walletId = wallet.walletId.stringValue
if (prefetchedWalletIds.add(walletId)) {
modelScope.launch {
prefetchSemaphore.withPermit {
runSuspendCatching {
repository.getBanners(walletId, params.placeholder, languageISOCode)
}.onSuccess { putBanners(walletId, it) }
.onFailure { prefetchedWalletIds.remove(walletId) }
}
}
}
}
}
}
}
private suspend fun loadWallet(walletId: String) {
val cached = runSuspendCatching { repository.getCachedBanners(walletId, params.placeholder) }.getOrNull()
if (cached != null) {
putBanners(walletId, cached)
return
}
putBanners(walletId, banners = emptyList())
runSuspendCatching {
repository.getBanners(walletId, params.placeholder, languageISOCode)
repository.getBanners(walletId, params.placeholder, Locale.getDefault().language)
}.onSuccess { banners ->
val bannerUMs = banners.map { banner ->
converter.convert(
banner = banner,
onDeeplinkClick = { deeplink -> onButtonClick(banner.id, deeplink) },
onDismiss = { displayId -> onBannerDismiss(walletId, displayId) },
)
}.toImmutableList()
val savedDisplayId = savedDisplayIdByWalletId[walletId]
val initialPage = if (savedDisplayId != null) {
bannerUMs.indexOfFirst { it.displayId == savedDisplayId }.coerceAtLeast(0)
} else {
0
}
uiState.value = PromoBannersBlockUM(
userWalletId = walletId,
initialPage = initialPage,
banners = bannerUMs,
isVisibleOnScreen = isVisibleOnScreen,
placeholder = params.placeholder,
onBannerShown = { displayId -> onBannerShown(walletId, displayId) },
onCarouselScrolled = ::onCarouselScrolled,
onPageChanged = { displayId -> savedDisplayIdByWalletId[walletId] = displayId },
)
putBanners(walletId, banners)
}.onFailure { error ->
TangemLogger.w("Failed to load promo banners", error)
}
}
private fun putBanners(walletId: String, banners: List<PromoBannerDisplay>) {
rawBannersByWalletId.update { it + (walletId to banners) }
}
private fun buildState(
walletId: String,
banners: List<PromoBannerDisplay>,
isVisible: Boolean,
): PromoBannersBlockUM {
val bannerUMs = banners.map { banner ->
converter.convert(
banner = banner,
onDeeplinkClick = { deeplink -> onButtonClick(banner.id, deeplink) },
onDismiss = { displayId -> onBannerDismiss(walletId, displayId) },
)
}.toImmutableList()
val savedDisplayId = savedDisplayIdByWalletId[walletId]
val initialPage = if (savedDisplayId != null) {
bannerUMs.indexOfFirst { it.displayId == savedDisplayId }.coerceAtLeast(0)
} else {
0
}
return PromoBannersBlockUM(
userWalletId = walletId,
initialPage = initialPage,
banners = bannerUMs,
isVisibleOnScreen = isVisible,
placeholder = params.placeholder,
onBannerShown = { displayId -> onBannerShown(walletId, displayId) },
onCarouselScrolled = ::onCarouselScrolled,
onPageChanged = { displayId -> savedDisplayIdByWalletId[walletId] = displayId },
)
}
private fun onBannerShown(walletId: String, displayId: Int) {
if (walletId != selectedWalletId.value) return
if (shownBannerIds.add(walletId to displayId)) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Shown(displayId, placeholderName))
}
@ -137,7 +224,7 @@ internal class PromoBannersBlockModel @Inject constructor(
userWalletId = "",
initialPage = 0,
banners = persistentListOf(),
isVisibleOnScreen = isVisibleOnScreen,
isVisibleOnScreen = screenVisible.value,
placeholder = params.placeholder,
onBannerShown = {},
onCarouselScrolled = {},
@ -146,12 +233,9 @@ internal class PromoBannersBlockModel @Inject constructor(
private fun onBannerDismiss(walletId: String, displayId: Int) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Dismissed(displayId, placeholderName))
uiState.update { state ->
state.copy(
banners = state.banners
.filterNot { it.displayId == displayId }
.toImmutableList(),
)
rawBannersByWalletId.update { byWallet ->
val banners = byWallet[walletId] ?: return@update byWallet
byWallet + (walletId to banners.filterNot { it.id == displayId })
}
modelScope.launch {
runSuspendCatching {
@ -169,5 +253,6 @@ internal class PromoBannersBlockModel @Inject constructor(
const val DEEPLINK_SCHEME_TANGEM = "tangem"
const val DEEPLINK_HOST_SURVEY = "survey"
const val QUERY_DISPLAY_ID = "display_id"
const val PREFETCH_PARALLELISM = 3
}
}

View file

@ -52,6 +52,10 @@ internal class DefaultPromoBannersRepository(
return banners
}
override suspend fun getCachedBanners(walletId: String, placeholder: Placeholder): List<PromoBannerDisplay>? {
return cache.getSyncOrNull()?.get(BannersCacheKey(walletId, placeholder))
}
override suspend fun dismissBanner(walletId: String, displayId: Int) {
cache.update(default = emptyMap()) { current ->
current.mapValues { (key, banners) ->

View file

@ -11,5 +11,7 @@ internal interface PromoBannersRepository {
languageISOCode: String,
): List<PromoBannerDisplay>
suspend fun getCachedBanners(walletId: String, placeholder: Placeholder): List<PromoBannerDisplay>?
suspend fun dismissBanner(walletId: String, displayId: Int)
}

View file

@ -57,9 +57,11 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor(
scope.launch {
// Only trust the redirect if it carries a request_id we issued for a sell this
// app actually started (single-use, bound to the wallet + currency). Otherwise an external
// deeplink could inject a locked attacker recipient/amount into the Send confirm screen.
val pendingOfframp = offrampRepository.consumePendingOfframp(
// app actually started (bound to the wallet + currency, valid until it expires). Otherwise an
// external deeplink could inject a locked attacker recipient/amount into the Send confirm
// screen. The record is kept until expiry so the user can re-open the redirect within that
// window.
val pendingOfframp = offrampRepository.resolvePendingOfframp(
requestId = requestId,
userWalletId = userWallet.walletId,
currencyId = currencyId,

View file

@ -43,28 +43,43 @@ internal class DefaultSellRedirectDeepLinkHandlerTest {
@Test
fun `GIVEN matching pending offramp WHEN deeplink handled THEN request passes the gate`() = runTest {
coEvery { offrampRepository.consumePendingOfframp(requestId, userWalletId, currencyId) } returns pendingOfframp()
coEvery { offrampRepository.resolvePendingOfframp(requestId, userWalletId, currencyId) } returns pendingOfframp()
createHandler(validParams())
advanceUntilIdle()
coVerify(exactly = 1) { offrampRepository.consumePendingOfframp(requestId, userWalletId, currencyId) }
coVerify(exactly = 1) { offrampRepository.resolvePendingOfframp(requestId, userWalletId, currencyId) }
coVerify(exactly = 1) { singleAccountListSupplier.getSyncOrNull(userWalletId) }
}
@Test
fun `GIVEN matching pending offramp WHEN deeplink handled twice THEN gate passes both times`() = runTest {
// The pending sell is not single-use: resolving it does not remove it, so re-opening the same deeplink must
// pass the gate again. Guards against reintroducing single-use behavior in the handler.
coEvery { offrampRepository.resolvePendingOfframp(requestId, userWalletId, currencyId) } returns pendingOfframp()
createHandler(validParams())
advanceUntilIdle()
createHandler(validParams())
advanceUntilIdle()
coVerify(exactly = 2) { offrampRepository.resolvePendingOfframp(requestId, userWalletId, currencyId) }
coVerify(exactly = 2) { singleAccountListSupplier.getSyncOrNull(userWalletId) }
}
@Test
fun `GIVEN no request_id WHEN deeplink handled THEN rejected without touching the store`() = runTest {
createHandler(validParams() - REQUEST_ID_KEY)
advanceUntilIdle()
coVerify(exactly = 0) { offrampRepository.consumePendingOfframp(any(), any(), any()) }
coVerify(exactly = 0) { offrampRepository.resolvePendingOfframp(any(), any(), any()) }
coVerify(exactly = 0) { singleAccountListSupplier.getSyncOrNull(any<UserWalletId>()) }
verify(exactly = 0) { appRouter.push(any()) }
}
@Test
fun `GIVEN no matching pending offramp WHEN deeplink handled THEN rejected`() = runTest {
coEvery { offrampRepository.consumePendingOfframp(requestId, userWalletId, currencyId) } returns null
coEvery { offrampRepository.resolvePendingOfframp(requestId, userWalletId, currencyId) } returns null
createHandler(validParams())
advanceUntilIdle()

View file

@ -13,14 +13,13 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
@ -31,6 +30,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import com.tangem.common.ui.footers.SendingText
@ -305,7 +305,16 @@ private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size48)
.shadow(elevation = 2.dp, shape = CircleShape)
.dropShadow(
shape = CircleShape,
shadow = Shadow(
radius = 6.dp,
spread = 0.5.dp,
color = Color.Black.copy(alpha = 0.1f),
offset = DpOffset(1.dp, 1.dp),
),
)
.clip(CircleShape)
.background(TangemTheme.colors.background.action)
.clickable(
enabled = state.changeCardsButtonState == ChangeCardsButtonState.ENABLED,

View file

@ -92,7 +92,11 @@ internal class TangemPayDetailsComponent(
val state by model.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
val promoBannersBlock = ComposableContentComponent { promoModifier ->
promoBannersBlockComponent.ContentWithPadding(modifier = promoModifier, horizontalItemPadding = 16.dp)
promoBannersBlockComponent.ContentWithPadding(
modifier = promoModifier,
walletId = null,
horizontalItemPadding = 16.dp,
)
}
CompositionLocalProvider(LocalVisaRedesignEnabled provides model.isRedesignEnabled()) {
NavigationBar3ButtonsScrim()

View file

@ -25,7 +25,6 @@ import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.tokens.model.details.TokenAction
import com.tangem.feature.wallet.child.managetokens.AddAndManageBottomSheetComponent
import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
import com.tangem.feature.wallet.child.tokenActions.DefaultTokenActionsComponent
import com.tangem.feature.wallet.child.tokenActions.TokenActionsComponent
import com.tangem.feature.wallet.child.wallet.model.WalletModel
@ -36,6 +35,7 @@ import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen2
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedComponent
import com.tangem.feature.walletsettings.component.RenameWalletComponent
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
@ -295,14 +295,10 @@ internal class WalletComponent @AssistedInject constructor(
var headerSize by remember { mutableStateOf(0.dp) }
val dialog by dialog.subscribeAsState()
val uiState by model.uiState.collectAsStateWithLifecycle()
val promoBannersBlockComponentContentComponent = ComposableContentComponent { promoModifier ->
promoBannersBlockComponent.ContentWithPadding(modifier = promoModifier, horizontalItemPadding = 12.dp)
}
if (designFeatureToggles.isRedesignEnabled) {
WalletScreen2(
state = uiState,
promoBannersBlockComponent = promoBannersBlockComponentContentComponent,
promoBannersBlockComponent = promoBannersBlockComponent,
tangemPayComponent = tangemPayMainBlockComponent,
virtualAccountComponent = virtualAccountMainBlockComponent,
bottomSheetContent = { onExpandSheet ->
@ -319,7 +315,7 @@ internal class WalletComponent @AssistedInject constructor(
} else {
WalletScreen(
state = uiState,
promoBannersBlockComponent = promoBannersBlockComponentContentComponent,
promoBannersBlockComponent = promoBannersBlockComponent,
tangemPayComponent = tangemPayMainBlockComponent,
virtualAccountComponent = virtualAccountMainBlockComponent,
bottomSheetContent = { onExpandSheet ->

View file

@ -333,15 +333,19 @@ internal class WalletContentClickIntentsImplementor @Inject constructor(
}
override fun onNFTClick(userWallet: UserWallet) {
val selectedWallet = stateHolder.getSelectedWallet() as? WalletState.MultiCurrency.Content ?: return
when (val state = selectedWallet.nftState) {
val nftState = if (stateHolder.value.isRedesignEnabled) {
stateHolder.getSelectedWalletUM().nftState
} else {
(stateHolder.getSelectedWallet() as? WalletState.MultiCurrency.Content)?.nftState
}
when (nftState) {
is WalletNFTItemUM.Content -> {
analyticsEventHandler.send(
NFTAnalyticsEvent.NFTListScreenOpened(
state = AnalyticsParam.EmptyFull.Full,
allAssetsCount = state.allAssetsCount,
collectionsCount = state.collectionsCount,
noCollectionAssetsCount = state.noCollectionAssetsCount,
allAssetsCount = nftState.allAssetsCount,
collectionsCount = nftState.collectionsCount,
noCollectionAssetsCount = nftState.noCollectionAssetsCount,
),
)
}
@ -358,6 +362,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor(
is WalletNFTItemUM.Failed,
is WalletNFTItemUM.Hidden,
is WalletNFTItemUM.Loading,
null,
-> Unit
}

View file

@ -24,6 +24,7 @@ import com.tangem.core.ui.R as CoreUiR
*/
internal enum class WalletNotificationType {
Status,
AddFundsPromo,
Critical,
Warning,
Promo,
@ -367,7 +368,7 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t
),
),
),
type = WalletNotificationType.Promo,
type = WalletNotificationType.AddFundsPromo,
)
data class NoteMigration(val onClick: () -> Unit) : WalletNotificationUM(

View file

@ -1,6 +1,8 @@
package com.tangem.feature.wallet.presentation.wallet.subscribers
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.stories.GetStoryContentUseCase
import com.tangem.domain.stories.models.StoryContentIds
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsSingleEventSender
@ -17,6 +19,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class WalletNotificationsSubscriber @AssistedInject constructor(
@ -27,6 +30,7 @@ internal class WalletNotificationsSubscriber @AssistedInject constructor(
private val getWalletNotificationsCarouselFactory: GetWalletNotificationsCarouselFactory,
private val walletWarningsAnalyticsSender: WalletWarningsAnalyticsSender,
private val walletWarningsSingleEventSender: WalletWarningsSingleEventSender,
private val getStoryContentUseCase: GetStoryContentUseCase,
) : WalletSubscriber() {
override fun create(coroutineScope: CoroutineScope): Flow<ImmutableList<WalletNotificationUM>> {
@ -35,6 +39,15 @@ internal class WalletNotificationsSubscriber @AssistedInject constructor(
flow2 = getWalletNotificationsCarouselFactory.create(userWallet, clickIntents).conflate()
.distinctUntilChanged(),
) { notifications, notificationsCarousel ->
if (notificationsCarousel.any { it is WalletNotificationUM.YieldBoostPromo }) {
coroutineScope.launch {
getStoryContentUseCase.invokeSync(
id = StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id,
refresh = true,
)
}
}
val displayedWalletUM = stateHolder.getWalletUM(userWallet.walletId)
// Wait until the wallet appears in the list

View file

@ -59,7 +59,6 @@ import com.tangem.core.ui.components.sheetscaffold.*
import com.tangem.core.ui.components.snackbar.CopiedTextSnackbar
import com.tangem.core.ui.components.snackbar.TangemSnackbar
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.softLayerShadow
import com.tangem.core.ui.extensions.stringResourceSafe
@ -83,6 +82,7 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency
import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.organizeTokensButton
import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.marketPriceBlock
import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.tangempay.component.TangemPayMainBlockComponent
import com.tangem.features.tangempay.entity.TangemPayMainUM
import com.tangem.features.virtualaccount.main.component.VirtualAccountMainBlockComponent
@ -98,7 +98,7 @@ internal fun WalletScreen(
state: WalletScreenState,
tangemPayComponent: TangemPayMainBlockComponent,
virtualAccountComponent: VirtualAccountMainBlockComponent,
promoBannersBlockComponent: ComposableContentComponent? = null,
promoBannersBlockComponent: PromoBannersBlockComponent? = null,
bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
@ -142,7 +142,7 @@ private fun WalletContent(
snackbarHostState: SnackbarHostState,
isAutoScroll: State<Boolean>,
onAutoScrollReset: () -> Unit,
promoBannersBlockComponent: ComposableContentComponent? = null,
promoBannersBlockComponent: PromoBannersBlockComponent? = null,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
@ -226,7 +226,11 @@ private fun WalletContent(
promoBannersBlockComponent?.let { component ->
item(key = "PromoBannersBlock") {
component.Content(modifier = itemModifier)
component.ContentWithPadding(
horizontalItemPadding = 12.dp,
modifier = itemModifier,
walletId = null,
)
}
}

View file

@ -55,7 +55,6 @@ import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.components.rememberIsKeyboardVisible
import com.tangem.core.ui.components.sheetscaffold.*
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingTopBar
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
@ -74,12 +73,15 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.common.Wallet
import com.tangem.feature.wallet.presentation.wallet.ui.components.common.WalletPagerIndicator
import com.tangem.feature.wallet.presentation.wallet.ui.components.common.WalletTopBar
import com.tangem.feature.wallet.presentation.wallet.ui.utils.lazyListStateMapSaver
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.tangempay.component.TangemPayMainBlockComponent
import com.tangem.features.tangempay.entity.TangemPayMainUM
import com.tangem.features.virtualaccount.main.component.VirtualAccountMainBlockComponent
import com.tangem.features.virtualaccount.main.entity.VirtualAccountMainUM
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeTint
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
@ -95,7 +97,7 @@ internal fun WalletScreen2(
tangemPayComponent: TangemPayMainBlockComponent,
virtualAccountComponent: VirtualAccountMainBlockComponent,
modifier: Modifier = Modifier,
promoBannersBlockComponent: ComposableContentComponent? = null,
promoBannersBlockComponent: PromoBannersBlockComponent? = null,
bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
@ -147,7 +149,7 @@ internal fun WalletScreen2(
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
onBottomSheetStateChange = onBottomSheetStateChange,
modifier = modifier,
listStates = listStates,
listStates = remember(listStates) { listStates.toImmutableMap() },
)
WalletEventEffect(
@ -171,9 +173,9 @@ private fun WalletContent2(
tangemPayComponent: TangemPayMainBlockComponent,
virtualAccountComponent: VirtualAccountMainBlockComponent,
behavior: TangemCollapsingAppBarBehavior,
listStates: Map<Int, LazyListState>,
listStates: ImmutableMap<Int, LazyListState>,
modifier: Modifier = Modifier,
promoBannersBlockComponent: ComposableContentComponent? = null,
promoBannersBlockComponent: PromoBannersBlockComponent? = null,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
@ -282,6 +284,7 @@ private fun WalletContent2(
val currentWallet = state.wallets2.getOrElse(currentWalletIndex) {
state.wallets2[state.selectedWalletIndex]
}
val currentWalletId = currentWallet.walletsBalanceUM.id.stringValue
LaunchedEffect(walletsPagerState.currentPage, currentWallet.walletsBalanceUM) {
if (walletsPagerState.currentPage == currentWalletIndex) {
@ -350,6 +353,7 @@ private fun WalletContent2(
contentPadding = contentPadding,
tangemPayComponent = tangemPayComponent,
promoBannersBlockComponent = promoBannersBlockComponent,
walletId = currentWalletId,
virtualAccountComponent = virtualAccountComponent,
modifier = Modifier
.fillMaxSize()

View file

@ -10,12 +10,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import com.tangem.common.ui.notifications.notifications
import com.tangem.common.ui.notifications.notificationsCarousel
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.transactions.txHistoryItems
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.MainScreenTestTags
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
@ -26,6 +26,7 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.nftCollection
import com.tangem.feature.wallet.presentation.wallet.ui.components.organizeTokens2
import com.tangem.feature.wallet.presentation.wallet.ui.components.tangemPay
import com.tangem.feature.wallet.presentation.wallet.ui.components.virtualAccount
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.tangempay.component.TangemPayMainBlockComponent
import com.tangem.features.virtualaccount.main.component.VirtualAccountMainBlockComponent
import kotlinx.collections.immutable.toPersistentList
@ -40,7 +41,8 @@ internal fun WalletListContent(
virtualAccountComponent: VirtualAccountMainBlockComponent,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
promoBannersBlockComponent: ComposableContentComponent? = null,
promoBannersBlockComponent: PromoBannersBlockComponent? = null,
walletId: String? = null,
) {
val containerColor = TangemTheme.colors2.surface.level1
@ -67,7 +69,11 @@ internal fun WalletListContent(
promoBannersBlockComponent?.let { component ->
item(key = "PromoBannersBlock") {
component.Content(modifier = Modifier.padding(top = TangemTheme.dimens2.x3))
component.ContentWithPadding(
horizontalItemPadding = 12.dp,
modifier = Modifier.padding(top = TangemTheme.dimens2.x3),
walletId = walletId,
)
}
}

View file

@ -0,0 +1,123 @@
package com.tangem.feature.wallet.presentation.wallet.subscribers
import arrow.core.right
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.stories.GetStoryContentUseCase
import com.tangem.domain.stories.models.StoryContentIds
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsSingleEventSender
import com.tangem.feature.wallet.presentation.wallet.domain.GetWalletNotificationsCarouselFactory
import com.tangem.feature.wallet.presentation.wallet.domain.GetWalletNotificationsFactory
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotificationUM
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class WalletNotificationsSubscriberTest {
private val stateHolder: WalletStateController = mockk(relaxed = true)
private val clickIntents: WalletClickIntents = mockk(relaxed = true)
private val getWalletNotificationsFactory: GetWalletNotificationsFactory = mockk()
private val getWalletNotificationsCarouselFactory: GetWalletNotificationsCarouselFactory = mockk()
private val walletWarningsAnalyticsSender: WalletWarningsAnalyticsSender = mockk(relaxed = true)
private val walletWarningsSingleEventSender: WalletWarningsSingleEventSender = mockk(relaxed = true)
private val getStoryContentUseCase: GetStoryContentUseCase = mockk()
private val userWallet: UserWallet = mockk(relaxed = true)
private val subscriber = WalletNotificationsSubscriber(
userWallet = userWallet,
stateHolder = stateHolder,
clickIntents = clickIntents,
getWalletNotificationsFactory = getWalletNotificationsFactory,
getWalletNotificationsCarouselFactory = getWalletNotificationsCarouselFactory,
walletWarningsAnalyticsSender = walletWarningsAnalyticsSender,
walletWarningsSingleEventSender = walletWarningsSingleEventSender,
getStoryContentUseCase = getStoryContentUseCase,
)
@BeforeEach
fun setup() {
clearMocks(
stateHolder,
getWalletNotificationsFactory,
getWalletNotificationsCarouselFactory,
getStoryContentUseCase,
userWallet,
)
every { userWallet.walletId } returns WALLET_ID
val walletUM = mockk<WalletUM>(relaxed = true)
every { walletUM.walletsBalanceUM.id } returns WALLET_ID
val screenState = mockk<WalletScreenState>(relaxed = true)
every { screenState.wallets2 } returns persistentListOf(walletUM)
every { stateHolder.uiState } returns MutableStateFlow(screenState)
every { stateHolder.getWalletUM(any()) } returns walletUM
every { getWalletNotificationsFactory.create(any(), any()) } returns flowOf(persistentListOf())
coEvery { getStoryContentUseCase.invokeSync(any(), any()) } returns null.right()
}
@Test
fun `GIVEN carousel has yield boost banner WHEN subscribed THEN yield story is prefetched`() = runTest {
// Arrange
every { getWalletNotificationsCarouselFactory.create(any(), any()) } returns flowOf(yieldBoostCarousel())
// Act
val dispatcher = UnconfinedTestDispatcher(testScheduler)
val scope = CoroutineScope(dispatcher)
subscriber.subscribe(scope, dispatcher)
advanceUntilIdle()
scope.cancel()
// Assert
coVerify(exactly = 1) {
getStoryContentUseCase.invokeSync(id = StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id, refresh = true)
}
}
@Test
fun `GIVEN carousel has no yield boost banner WHEN subscribed THEN yield story is not prefetched`() = runTest {
// Arrange
every { getWalletNotificationsCarouselFactory.create(any(), any()) } returns flowOf(persistentListOf())
// Act
val dispatcher = UnconfinedTestDispatcher(testScheduler)
val scope = CoroutineScope(dispatcher)
subscriber.subscribe(scope, dispatcher)
advanceUntilIdle()
scope.cancel()
// Assert
coVerify(exactly = 0) {
getStoryContentUseCase.invokeSync(id = StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id, refresh = true)
}
}
private fun yieldBoostCarousel(): ImmutableList<WalletNotificationUM> = persistentListOf(
WalletNotificationUM.YieldBoostPromo(onExploreClick = {}, onLaterClick = {}),
)
private companion object {
val WALLET_ID = UserWalletId("01")
}
}