diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 43dc8567c8..d77020adb5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -433,6 +433,14 @@ + + + + + diff --git a/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt b/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt index bce83f2ec7..368f58cf49 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt @@ -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.filterNotExpired(now: Long): List = - 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) } \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt b/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt index 99151c5a99..0bd4d697cb 100644 --- a/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt @@ -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) diff --git a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt index ebd2c78785..a371efa5e8 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt @@ -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), ) } diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 92c99b8dfa..26eb9b5062 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -274,7 +274,7 @@ %s fehlgeschlagen Aktivieren Hinzufügen - Guthaben hinzufügen + Einzahlen Zum Portfolio hinzufügen Token hinzufügen Token hinzufügen @@ -725,6 +725,11 @@ Eine Transaktion kann nicht gesendet werden Fehler in der Coinbeschreibung Portfolio prüfen und Verdienstmöglichkeiten erkunden + Verdienstmöglichkeiten + Alle Ihre Ressourcen sind im Einsatz – entdecken Sie neue Möglichkeiten + Erhalten Sie jährlich bis zu %1$s + Maximale potenzielle Gewinne %1$s + %1$s/Jahr Portfolio-Überprüfung Für dich Jetzt aktualisieren @@ -923,20 +928,22 @@ Die Daten dieses Abschnitts stammen aus den folgenden Netzwerken: %s Die Daten konnten nicht geladen werden... Keine Daten - **Hinzufügen zu Ihrem Portfolio**, um mit dem Kauf, Tausch oder Erhalt dieses Vermögenswerts zu beginnen - Zum Portfolio hinzufügen + **Füge dieses Produkt Deinem Portfolio hinzu**, um es zu kaufen, zu tauschen oder zu erhalten. + In Deinem Portfolio Dein Portfolio **Token nicht unterstützt**. Dieser Token wird derzeit in der Wallet nicht unterstützt. + Andere zulässige Token Marktimpuls Schnelle Aktionen Alles löschen Token suchen - Neueste + Letzte Suchanfragen In Ihrem Portfolio + Aktuelle Token Ergebnis Token unter 100k USD Marktkapitalisierung anzeigen Token anzeigen - Krypto, Nachrichten und mehr + Krypto, Neuigkeiten und mehr Kein Ergebnis Netzwerk auswählen Wallet auswählen @@ -1010,7 +1017,7 @@ Position im Krypto-Rating zwischen allen Coins basierend auf der Marktkapitalisierung Marktposition Maximale Versorgung - Zirkulation und maximale Versorgung + Umlauf- und Maximalversorgung Die maximale Anzahl von Coins oder Tokens, die jemals für eine bestimmte Kryptowährung existieren können Maximale Versorgung Metriken @@ -1028,7 +1035,7 @@ Handelsvolumen (24h) 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 Handelsvolumen (24h) - %s insgesamt + %s in Summe Volumen Rufe dies auf oder tippe auf die Suchleiste, um Token direkt vom Markt hinzuzufügen Token hinzufügen @@ -1352,10 +1359,10 @@ Unbekannte Parameter Kreditkarte oder Bankkonto Teilen deine Adresse oder dein QR-Code - Sicherer Verkauf von Kryptowährungen + Kryptowährung sicher verkaufen Senden Sie mit Tausch an ein anderes Token An eine andere Wallet senden - Zwischen deinen Portfolios + Krypto gegen Krypto Andere Schnell aufladen Kein Memo erforderlich @@ -1917,6 +1924,10 @@ %d Karte %d Karten + Dies geschah aufgrund Ihres verdächtigen Verhaltens. Wenden Sie sich an den Support, um mehr zu erfahren. + Cashback deaktiviert + Wird eingezahlt am %1$s + %1$s Cashback in %2$s PIN-Code ändern Kehren Sie zur App zurück, falls Sie ihn vergessen. Karte @@ -1980,6 +1991,7 @@ KYC-Sperre ausblenden Leider konnten wir Ihre Identität nicht verifizieren. + Tarif auswählen Du kannst bis zu 3 Karten haben. Lösche eine, um eine neue Karte hinzuzufügen. Maximale Anzahl ausgegebener Karten Ja — für die Nutzung einer regulierten Visa-Karte ist eine Identitätsprüfung Pflicht. Das KYC wird von Sumsub abgewickelt, dem Compliance-Partner. @@ -2043,6 +2055,7 @@ Neue PIN einrichten PIN einstellen Konto geschlossen + Inaktiv Ersetzen deine Karte Karte oder Ring verwenden, um die Sitzung zu verlängern Karte oder Ring verwenden, um die Sitzung zu verlängern @@ -2058,6 +2071,8 @@ Laden Sie Ihr Konto mit einem beliebigen Token aus Ihrer Wallet auf Aus Ihrer Tangem Wallet USDC im Polygon + Bitte versuche es später erneut. Sollte das Problem weiterhin bestehen, kontaktiere bitte unseren Support. Wir helfen Dir gerne bei der Lösung. + Die Bankdaten konnten nicht geladen werden Visavorteile Gelder aus erstatteten Käufen werden nicht auf Ihr On-Chain-Guthaben zurückerstattet und stehen nicht für Abhebungen zur Verfügung, bleiben aber auf Ihrem Kartenguthaben für Einkäufe verfügbar Bitte beachten Sie @@ -2622,7 +2637,7 @@ Die Gebühr wird abgezogen und Dein Vermögen wird erneut verliehen. Um weiterhin Geld verdienen zu können, ist eine Genehmigung erforderlich. Genehmigung bestätigen - Durchschnittlicher Jahreszins %1$s%% + Aktueller effektiver Jahreszins %1$s%% Deine Gelder werden derzeit dem Aave-Protokoll bereitgestellt, Du kannst sie jedoch jederzeit verwalten. Deine%s ist in Aave hinterlegt Chart konnte nicht geladen werden... diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index d74f406f10..b841808ce4 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -709,6 +709,7 @@ Ocurrió un error Ocurrió un error. Código: %s Requiere memo + Obtener %1$s Esta transacción Al aprobar, permite que el contrato inteligente utilice sus tokens en transacciones futuras. Montante %s @@ -725,6 +726,8 @@ Generación de claves Todas las operaciones criptográficas ocurren dentro del chip seguro, certificado contra la clonación y la manipulación física. Seguridad a nivel de hardware + La actividad en la red es alta. Puede continuar ahora o intentarlo de nuevo más tarde, cuando las tarifas sean más bajas. + La tarifa de red es más alta de lo habitual. Agregar Billetera Existente Crear Nueva Billetera Pedir Tangem @@ -880,12 +883,14 @@ **Añadir a su portafolio** para empezar a comprar, intercambiar o recibir este activo En su portafolio Su portafolio + **Token no soportado**. Este token no está soportado actualmente en la billetera. Análisis del Mercado Acciones rápidas Borrar todo Buscar tokens - Recientes + Búsquedas recientes En su portafolio + Tokens recientes Resultado Ver tokens con marketcap inferior a 100.000$ Mostrar tokens @@ -1256,6 +1261,21 @@ Por saldo Organizar tokens Desagrupar + El reembolso elegible se distribuirá a: + Ya está inscrito en %1$s + Tokens elegibles + Inscribirse + Se ha inscrito correctamente en %1$s + Esta campaña ya no existe o ha caducado. + Campaña inactiva + 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. + Seleccione una cuenta de reembolso + Seleccionar token + Inscríbase en %1$s + Estoy de acuerdo con %1$s + Estoy de acuerdo con + %1$s Términos + 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. Soporte de %s Las notificaciones push están activadas, pero no funcionarán hasta que las autorice Permitir notificaciones @@ -1293,7 +1313,7 @@ Venda criptomonedas de forma segura Enviar con intercambio a otro token Enviar a otra billetera - Entre sus portafolios + Cambia una criptomoneda por otra Otro Recarga rápida No se requiere nota diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index bebe09f137..2c96087d42 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -83,6 +83,7 @@ Ajouter des fonds Échanger Transférer + Ajouter à votre portfolio Ajouter des jetons Choisissez le réseau Ajouter un jeton personnalisé @@ -268,6 +269,7 @@ Comptes Activer Ajouter + Ajouter fonds Ajouter au portfolio Ajouter un jeton Ajouter des jetons @@ -349,6 +351,7 @@ Du De %s Synchroniser les adresses + Obtenir du Commencer Obtenir un token Aller au fournisseur @@ -538,6 +541,7 @@ L’envoi d’actifs sur d’autres réseaux entraînera une perte définitive. %s réseau Envoyez des fonds en utilisant uniquement + Adresse dynamique Meilleures opportunités Effacer le filtre Tous les réseaux @@ -658,6 +662,7 @@ Une erreur s\'est produite Une erreur s\'est produite. Code:%s Un mémo est requis + Obtenir des %1$s Cette transaction Spécifiez la limite approuvée pour le jeton sélectionné Montant %s @@ -673,6 +678,8 @@ Génération de clés 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. Sécurité au niveau matériel + 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. + Les frais de réseau sont plus élevés que d\'habitude Ajouter un Portefeuille existant Créer un nouveau Portefeuille Commandez @@ -821,12 +828,19 @@ Les données de cette section proviennent des réseaux suivants : %s Impossible de charger les données… Aucune donnée + **Ajouter à votre portfolio** pour acheter, échanger ou recevoir cet asset + Dans votre portfolio + Votre portfolio + **Token non pris en charge**. Ce token n\'est actuellement pas pris en charge dans le portefeuille Rythme du marché Actions rapides Rechercher sur le marché + Recherches récentes + Tokens récents Résultat Voir les jetons de moins de 100 000 $ de capitalisation boursière Afficher les jetons + Crypto, actualités et plus Aucun résultat Sélectionnez un réseau Sélectionnez un portefeuille @@ -895,6 +909,7 @@ Position dans le classement des crypto-monnaies entre toutes les pièces en fonction de la capitalisation boursière Évaluation du marché Approvisionnement maximal + Offre en circulation et offre maximale Le nombre maximal de pièces ou de jetons pouvant exister pour une crypto-monnaie particulière Approvisionnement maximal Métriques @@ -910,6 +925,7 @@ Volume des échanges (24h) 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é Volume des échanges (24h) + %s au total Volume Tirez vers le haut ou appuyez sur la barre de recherche pour ajouter des jetons directement depuis le marché Ajouter des jetons @@ -1163,6 +1179,8 @@ Disponible auprès de Disponible jusqu\'à Vous obtenez + Ce token n\'est pas pris en charge. Veuillez choisir un autre token à acheter. + %s n\'est pas pris en charge Le service est fourni par un prestataire externe. Tangem n\'est pas responsable. Vous pouvez vérifier l\'état de la transaction à partir de la page du jeton Jusqu\'à @@ -1175,6 +1193,21 @@ Par solde Organiser les jetons Dégrouper + Le cashback éligible sera distribué à : + Vous êtes déjà inscrit(e) à %1$s + Tokens éligibles + S\'inscrire + Vous êtes bien inscrit(e) à %1$s + Cette campagne n\'existe plus ou a expiré + Campagne non active + 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. + Sélectionner le compte pour recevoir le cashback + Sélectionner le token + S\'inscrire à %1$s + J\'accepte les %1$s + J\'accepte les + Conditions de %1$s + 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. Assistance %s Les notifications push sont activées mais ne fonctionneront pas avant que vous les autorisiez Autoriser les notifications @@ -1200,7 +1233,12 @@ Aucun jeton pris en charge n\'a été trouvé Ce code QR contient des paramètres non reconnus : %s. Si vous continuez, certaines informations de paiement risquent d\'être perdues. Paramètres inconnus + Carte ou compte en banque + Partager votre adresse ou code QR + Vendre de la crypto en sécurité Envoyer en échangeant vers un autre token + Envoyer vers un autre portefeuille + Une crypto contre une autre Recharge rapide Aucun mémo requis %1$s (%2$s) sur le réseau %3$s @@ -1614,6 +1652,7 @@ Tangem Pay en version bêta Carte gelée Paiement par carte + Vous ne pouvez pas clôturer la dernière carte Il disparaîtra de l’application Clôturer la carte Retour @@ -1776,6 +1815,7 @@ Désolé, nous n\'avons pas pu vérifier votre identité. Vous pouvez avoir jusqu\'à 3 cartes. Supprimez une carte pour pouvoir en ajouter une nouvelle. + Nombre maximum de cartes émises Oui — pour utiliser une carte Visa réglementée, la vérification d’identité est obligatoire. Le KYC est géré par Sumsub, partenaire conformité. Dois-je fournir mes documents ? Non. Le KYC s’applique uniquement au compte Tangem Pay. Votre Tangem Wallet reste un environnement distinct, en self-custody et sans KYC. @@ -1820,6 +1860,9 @@ Déposez des USDC sur le compte de paiement pour couvrir les frais d’émission Impossible de couvrir les frais Réémettre votre carte ? + Retirer le compte + Tangem Pay sera retiré de l\'écran principal et ne réapparaîtra plus, même après une réinstallation de l\'application. + Retirer le compte? Nous réparons un problème technique. Veuillez réessayer plus tard. Service temporairement indisponible Les données ne peuvent pas être affichées pour le moment, mais les paiements par carte fonctionnent toujours. @@ -2358,6 +2401,7 @@ Les frais seront prélevés et vos actifs seront à nouveau prêtés. Pour continuer à percevoir des revenus, une autorisation est nécessaire. Confirmer l\'approbation + APY actuel %1$s%% Vos fonds sont actuellement affectés au protocole Aave, mais vous pouvez les gérer à tout moment. Vos %s sont déposés dans Aave. Impossible de charger le graphique... diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 5038b93bf1..34feb66471 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -717,6 +717,7 @@ エラーが発生しました エラーが発生しました。コード: %s 。 メモが必要 + %1$sを入手 取引 承認すると、このスマートコントラクトが今後の取引でトークンを利用できるようになります。 金額%s @@ -733,6 +734,8 @@ 鍵生成 すべての暗号処理は、複製や物理的な改ざんに対する認証を受けたセキュアチップ内で行われます。 ハードウェアレベルのセキュリティ + ネットワークが混雑しています。今すぐ続けることもできますが、後でお試しいただくと、手数料が安くなる場合があります。 + ネットワーク手数料が通常より高くなっています 既存のウォレットを追加 新しいウォレットを作成する Tangemを注文 @@ -887,12 +890,14 @@ **ポートフォリオに追加して**、この資産の買付・交換・受け取りを始めましょう ポートフォリオ内 ポートフォリオ + **トークンはサポートされていません**。このトークンは現在、このウォレットではサポートされていません マーケット動向 クイックアクション すべてクリア トークンを探す 最近の検索 ポートフォリオ内 + 最近表示したトークン 結果 時価総額10万ドル以下のトークンを見る トークンを表示 @@ -1238,6 +1243,8 @@ 利用可能: 利用可能金額 以下が手に入ります。 + このトークンはサポートされていません。別のトークンを選択して購入してください。 + %sはサポートされていません サービスは外部プロバイダーによって提供されます。 \nTangemは責任を負いません。 この画面を閉じて、トークンの詳細画面で取引状況を確認できます。 最大 @@ -1301,7 +1308,7 @@ 暗号資産を安全に売却 別のトークンにスワップして送る 別のウォレットに送信 - ポートフォリオ間で + 暗号資産を別の暗号資産に交換 その他 クイック入金 メモ不要 @@ -2511,7 +2518,7 @@ 手数料が差し引かれ、暗号資産が補充されます。 利息を継続的に生み出すには承認が必要です。 承認を確定する - 平均APY %1$s%% + 現在のAPY %1$s%% あなたの資金は現在Aaveプロトコルに預けられていますが、いつでも自由に管理できます。 %sはAaveに供給されています チャートを読み込めません・・ diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 7e2292db62..019a3eebb1 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -131,6 +131,7 @@ Ainda não há contatos. Os contatos que você adicionar aparecerão aqui. Remover endereço + Salvar endereço Salvar contato Salvar na carteira Este contato será vinculado à agenda de endereços desta carteira. @@ -638,6 +639,8 @@ Tempo de transação longo O valor da transação foi reembolsado em %1$s para sua carteira devido às regras do OKX ou da ponte. %2$s O valor foi reembolsado em %1$s (%2$s rede) + Seus fundos foram reembolsados ​​em %1$s para sua carteira no %2$s rede, de acordo com as regras da bolsa OKX. + Reembolsado em %s Visite o site do fornecedor para verificação. Verificação KYC exigida pelo fornecedor Compra concluída @@ -924,12 +927,14 @@ Em seu portfólio Seu portfólio **Token não suportado**. Este token não é suportado na carteira neste momento. + Outros tokens elegíveis Pulso do mercado Ações rápidas Limpar tudo Pesquisar tokens - Recentes + Pesquisas recentes Em seu portfólio + Tokens recentes Resultado Veja tokens com capitalização de mercado inferior a 100 mil dólares. Mostrar tokens @@ -1305,9 +1310,11 @@ Tokens elegíveis Inscreva-se Você se inscreveu com sucesso em %1$s - Esta campanha não existe mais ou expirou. + Esta campanha não existe ou expirou. Campanha inativa + 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. Selecione a conta de cashback + Selecionar token Inscreva-se em %1$s Concordo com %1$s Concordo com @@ -1350,7 +1357,7 @@ Venda criptomoedas com segurança Enviar com troca para outro token Enviar para outra carteira - Entre seus portfólios + Uma cripto por outra Outro Recarga rápida Não é necessário memorando @@ -1975,6 +1982,7 @@ Ocultar bloco KYC Desculpe, não foi possível verificar. Seu perfil. + Selecione o plano Você pode ter até 3 cartões. Exclua um para adicionar um novo. Número máximo de cartões emitidos Sim — para usar um cartão Visa regulado, a verificação de identidade é obrigatória. O KYC é feito pela Sumsub, parceira de compliance. diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index ae70e6b4a6..b01216084f 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -377,7 +377,7 @@ Из Из %s Синхронизировать адреса - Приобрести + Пополнить Начать Получить токен К провайдеру @@ -749,6 +749,7 @@ Произошла ошибка Произошла ошибка. Код: %s. Требуется memo + Пополнить %1$s Транзакция Укажите лимит доступа к выбранному токену Количество %s @@ -765,6 +766,8 @@ Генерация ключа Операции с криптографией выполняются внутри защищённого чипа, устойчивого к клонированию и физическому взлому. Аппаратная безопасность + Высокая активность в сети. Вы можете продолжить сейчас или попробовать позже, когда комиссии будут ниже. + Комиссия сети выше, чем обычно Добавить существующий кошелек Создать новый кошелек Купить @@ -922,12 +925,14 @@ **Добавьте в свой портфель**, чтобы покупать, обменивать или получать этот актив. В вашем портфеле Ваш портфель + **Токен не поддерживается**. В настоящее время этот токен не поддерживается в кошельке Пульс рынка Быстрые действия Очистить всё Поиск токенов - Недавние + Недавние запросы В вашем портфеле + Недавние токены Результат Токены с капитализацией меньше 100к USD Показать токены @@ -1306,6 +1311,8 @@ Доступно от Доступно до Вы получите + Этот токен не поддерживается. Пожалуйста, выберите другой токен для покупки. + %s не поддерживается Услуга предоставляется сторонним провайдером.\nTangem не несет ответственности. Вы можете закрыть этот экран и проверить статус транзакции на экране информации о токене. До @@ -1318,6 +1325,20 @@ По балансу Упорядочить токены Список + Начисленный кешбэк будет отправлен на: + Вы уже участвуете в %1$s + Доступные токены + Участвовать + Вы успешно стали участником в %1$s + Эта кампания больше не существует или срок ее действия истек + Кампания неактивна + Получайте 0.5% кешбэка за каждый обмен от $500 в любой паре, кроме стейблкоина на стейблкоин. Максимальная выплата: $50 за обмен.\n\nСовершите пять соответствующих условиям обменов и разблокируйте дополнительный бонус в $10.\n\nНаграды выплачиваются еженедельно в USDT или USDC на выбранный адрес. + Выберите счет для кешбэка + Выберите токен + Участвовать в %1$s + Я принимаю %1$s + %1$s Условия + Получайте кешбэк за каждый обмен от $10K до конца июля.\n\nСтавка растет вместе с суммой: 0.10% от $10K, 0.20% от $20K, 0.50% от $100K.\n\nМаксимальная выплата: $500 за обмен и $10,000 на кошелек для каждого направления обмена, пока длится кампания. Обмены стейблкоина на стейблкоин исключены.\n\nВыплата поступает еженедельно на выбранный вами адрес USDT или USDC. Поддержка %s Push-уведомления включены, но не будут работать без вашего разрешения Разрешить уведомления @@ -1350,12 +1371,12 @@ Поддерживаемые токены не найдены Этот QR-код содержит параметры, которые не распознаны: %s. Некоторые данные платежа могут быть утеряны, если вы продолжите. Неизвестные параметры - Банковская карта или банковский счет + Криптовалюту картой, переводом и не только Поделитесь своим адресом или QR-кодом Продавайте криптовалюту безопасно Отправить с обменом на другой токен Отправить на другой кошелек - Между вашими портфелями + Обменяйте одну криптовалюту на другую Другие Быстрое пополнение Memo не требуется diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 87fbf2490b..eac9ee3549 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -377,6 +377,7 @@ З З %s Синхронізувати адреси + Поповнити Почати Отримати токен Перейти до провайдера @@ -748,6 +749,7 @@ Виникла помилка Виникла помилка. Код: %s. Вимагається memo + Поповнити %1$s Транзакція Вкажіть ліміт доступу для обраного токена Кількість %s @@ -764,6 +766,8 @@ Генерація ключа Операції з криптографією відбуваються всередині захищеного чіпу, стійкого до клонування та фізичному взлому. Безпека на апаратному рівні + Висока активність у мережі. Ви можете продовжити зараз або спробувати пізніше, коли комісії будуть нижчими. + Комісія мережі вища, ніж зазвичай Додати існуючий гаманець Створити новий гаманець Купити @@ -921,12 +925,14 @@ **Додайте до свого портфеля**, щоб купувати, обмінювати або отримувати цей актив У вашому портфелі Ваш портфель + **Токен не підтримується**. Наразі цей токен не підтримується в гаманці Пульс ринку Швидкі дії Очистити все Шукати на маркеті - Нещодавні + Нещодавні запити У вашому портфелі + Нещодавні токени Результат Переглянути токени до 100к USD ринкової капіталізації Показати токени @@ -1305,6 +1311,8 @@ Доступно з Доступно до Ви отримаєте + Цей токен не підтримується. Будь ласка, виберіть інший токен для купівлі. + %s не підтримується Послуга надається зовнішнім провайдером. \nTangem не несе відповідальності. Ви можете перевірити статус транзакції на сторінці токена До @@ -1317,6 +1325,20 @@ За балансом Сортування токенів Список + Нарахований кешбек буде надіслано на: + Ви вже долучилися до %1$s + Доступні токени + Долучитися + Ви успішно долучилися до %1$s + Ця кампанія більше не існує або термін її дії закінчився + Кампанія неактивна + Отримуйте 0.5% кешбеку за кожний обмін від $500 у будь-якій парі, крім стейблкоїна на стейблкоїн. Максимальна виплата: $50 за обмін.\n\nЗдійсніть п\'ять відповідних умовам обмінів та розблокуйте додатковий бонус у $10.\n\nНагороди виплачуються щотижня в USDT або USDC на обрану адресу. + Оберіть рахунок для кешбеку + Оберіть токен + Долучитися до %1$s + Я приймаю %1$s + %1$s Умови + Отримуйте кешбек за кожний обмін від $10K до кінця липня.\n\nСтавка зростає разом із сумою: 0.10% від $10K, 0.20% від $20K, 0.50% від $100K.\n\nМаксимальна виплата: $500 за обмін та $10,000 на гаманець для кожного напрямку обмінів, поки триває кампанія. Обміни стейблкоїна на стейблкоїн виключено.\n\nВиплата надходить щотижня на обрану вами адресу USDT або USDC. Підтримка %s Push-сповіщення ввімкнені, але не працюватимуть без вашого дозволу Дозволити сповіщення @@ -1354,7 +1376,7 @@ Безпечно продавайте криптовалюту Надіслати з обміном на інший токен Надіслати на інший гаманець - Між вашими портфелями + Обміняйте одну криптовалюту на іншу Інші Швидке поповнення Memo не вимагається @@ -2010,6 +2032,7 @@ Поповніть платіжний рахунок в USDC, щоб покрити комісію за випуск Неможливо покрити комісію Перевипустити картку? + Видалити аккаунт Ми усуваємо технічну проблему. Будь ласка, спробуйте пізніше. Сервіс тимчасово недоступний Не можемо показати дані картки, але оплати продовжують працювати. @@ -2534,7 +2557,7 @@ Комісія буде знята, а ваші активи знову почнуть приносити дохід. Щоб продовжити заробляти, потрібне схвалення. Підтвердити дозвіл - Середній APY %1$s%% + Поточний APY %1$s%% Наразі ваші кошти розміщені у протоколі Aave, але ви можете керувати ними в будь-який час. Ваш %s внесений у Aave Неможливо завантажити графік diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index b2e532969f..4c000aebd7 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -713,6 +713,7 @@ 发生错误 发生错误。错误代码: %s。 需要备忘录 + 获取 %1$s 交易 批准后,您即允许智能合约在未来的交易中使用您的代币。 金额 %s @@ -729,6 +730,8 @@ 密钥生成 所有加密操作都在安全芯片内部进行,该芯片经过认证,可防止克隆和物理篡改。 硬件级安全 + 网络流量较大,您现在可以继续,也可以稍后再试,届时手续费可能会更低。 + 网络费用比平时高 添加现有钱包 创建新钱包 订购 Tangem @@ -887,8 +890,9 @@ 快速行动 全部清除 搜索代币 - 最近的 + 最近搜索 在您的投资组合中 + 近期代币 结果 查看市值低于 10 万美元的代币 显示代币 @@ -1234,6 +1238,8 @@ 可从 最高可提供 你得到 + 不支持该代币。请选择其他代币进行购买。 + %s 不支持 服务由外部供应商提供。\nTangem 不承担任何责任。 您可以关闭此屏幕,并在代币详情屏幕上查看交易状态。 至多 @@ -1940,6 +1946,9 @@ 将USDC存入支付账户以支付发行费用 无法支付费用 要重新发行您的卡片吗? + 删除账户 + Tangem Pay 将从主屏幕中移除,即使重新安装该应用,它也不会再次出现。 + 删除账号? 我们正在修复技术问题,请稍后再试。 服务暂时不可用 无法显示详细信息。但刷卡支付功能仍然可用。 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 41593daacd..1f4da59adb 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -93,7 +93,7 @@ Credit card or bank account Fund token Share your address or QR-code - Between your portfolios + Exchange one crypto for another You receive Add address Add address and select network @@ -726,6 +726,11 @@ Can\'t send a transaction Coin description error Review portfolio and explore earn opportunities + Earn opportunities + All your assets are at work, explore new opportunities + Get up to %1$s annually + Max potential rewards %1$s + %1$s/year Portfolio review For You Update now @@ -933,8 +938,9 @@ Quick actions Clear all Search tokens - Recent\'s + Recent searches In your portfolio + Recent tokens Result See tokens under 100k USD market cap Show tokens @@ -1352,12 +1358,12 @@ No supported tokens found This QR code contains parameters that are not recognized: %s. Some payment details may be lost if you continue. Unknown Parameters - Credit card or bank account + Get crypto by card, bank transfer & more Share your address or QR-code - Sell crypto securely - Send with swap to another token - Send to another wallet - Between your portfolios + Convert crypto to fiat currency + Exchange and send in one step + Transfer crypto to another wallet + Exchange one crypto for another Other Quick top up No memo required @@ -1986,6 +1992,7 @@ Hide KYC block Sorry, we couldn\'t verify your profile. + Select plan You can have up to 3 cards. Delete one to add a new card. Maximum Cards Issued Yes – to operate a regulated Visa card, identity verification is mandatory. KYC is handled by Sumsub (compliance partner). @@ -2019,8 +2026,6 @@ We\'ll set up a wallet Get your Tangem Pay Card in minutes Pay Support - Select plan - Inactive Payment account Session expired Invalid PIN: avoid sequences or repeats @@ -2051,6 +2056,7 @@ Set up new PIN Set PIN Account closed + Inactive Replacing your card Use your card or ring to renew session Use your card or ring to renew session @@ -2066,6 +2072,8 @@ Use crypto from your wallet to top up your payment account From your Tangem Wallet USDC on Polygon network + Please try again or contact support if the issue persists + Couldn\'t load banking details Visa Benefits Funds from refunded purchases won’t be returned your on-chain Polygon balance or be available for withdrawal, but will stay on your card balance for purchases Please note diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt index 1e0de1fd63..16871f2f13 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt @@ -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 diff --git a/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt b/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt index 0aeaa27839..85149b2fa1 100644 --- a/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt +++ b/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt @@ -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, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt index d3652b2d02..9add0105d2 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt @@ -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, ), ) } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt index 926aeecb47..234641740d 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt @@ -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, + ), } /** diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt index 50f79c7a7a..ad2a03c358 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt @@ -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 } } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt index 3c08434a2b..ac3f875a2e 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt @@ -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 = if (!shouldShowSingleCurrencyWallets) { + private val walletAwareMarketsStateFlow: Flow = if (!shouldShowSingleCurrencyWallets) { baseMarketsStateFlow } else { selectedWalletFlow @@ -85,6 +87,10 @@ internal class MarketBlockDelegate @AssistedInject constructor( .distinctUntilChanged() } + val marketsStateFlow: Flow = 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 { 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 diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt index 515cabd4d1..024433610f 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt @@ -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, @Assisted private val featureSettings: ChooseTokenBridge.Settings, @@ -48,6 +51,8 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( val portfolioList: SharedFlow> = buildDataFlow() .distinctUntilChanged() + .throttleLatest(windowMs = UM_UPDATES_THROTTLE_MS) + .flowOn(dispatchers.default) .shareIn(modelScope, SharingStarted.Eagerly, replay = 1) private fun buildDataFlow(): Flow> = 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 Flow.throttleLatest(windowMs: Long): Flow = conflate().transform { value -> + emit(value) + delay(windowMs) } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt index c9d9dad95c..ae03a82318 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt @@ -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( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsModel.kt index 94b88e2469..d287b7b093 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsModel.kt @@ -7,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( diff --git a/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegateTest.kt b/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegateTest.kt index c8f28b38d6..3dcfe70068 100644 --- a/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegateTest.kt +++ b/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegateTest.kt @@ -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? { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt index 1000954081..d8d9841e4f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt @@ -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, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt index 5245ef407a..2298ad430d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt @@ -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, diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt index f4d1a5f9f6..b4ae2bcbfd 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt @@ -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) {} diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt index a5a43d679f..68af29753f 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt @@ -76,6 +76,7 @@ internal class AllOffersModel @Inject constructor( analyticsEventHandler.send( event = OnrampAnalyticsEvent.OnPaymentMethodChosen(paymentMethod = method.methodConfig.method.name), ) + analyticsEventHandler.send(OnrampAnalyticsEvent.ProvidersScreenOpened()) } } diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt index c61fca07be..9a927d9d60 100644 --- a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt @@ -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) diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/DefaultPromoBannersBlockComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/DefaultPromoBannersBlockComponent.kt index fc2b86a4e4..662d8042da 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/DefaultPromoBannersBlockComponent.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/DefaultPromoBannersBlockComponent.kt @@ -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, diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt index 108b6fe3e5..9e251f128c 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt @@ -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 = ConcurrentHashMap.newKeySet() - private var isVisibleOnScreen: Boolean = params.isInitiallyVisibleOnScreen private var wasCarouselScrolled = false private val savedDisplayIdByWalletId: MutableMap = mutableMapOf() + private val prefetchedWalletIds: MutableSet = ConcurrentHashMap.newKeySet() + private val prefetchSemaphore = Semaphore(permits = PREFETCH_PARALLELISM) - val uiState: StateFlow - field = MutableStateFlow(getInitialState()) + /** Raw banners per wallet, filled from cache/prefetch/network. Source of truth for the UI. */ + private val rawBannersByWalletId = MutableStateFlow>>(emptyMap()) + + private val selectedWalletId = MutableStateFlow(null) + + private val screenVisible = MutableStateFlow(params.isInitiallyVisibleOnScreen) + + private val baseBannerStates: StateFlow> = + 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> = + 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 = + 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) { + rawBannersByWalletId.update { it + (walletId to banners) } + } + + private fun buildState( + walletId: String, + banners: List, + 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 } } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt index 0ab8bf3ee9..0f5fc3d743 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt @@ -52,6 +52,10 @@ internal class DefaultPromoBannersRepository( return banners } + override suspend fun getCachedBanners(walletId: String, placeholder: Placeholder): List? { + return cache.getSyncOrNull()?.get(BannersCacheKey(walletId, placeholder)) + } + override suspend fun dismissBanner(walletId: String, displayId: Int) { cache.update(default = emptyMap()) { current -> current.mapValues { (key, banners) -> diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt index 0fd96683b7..b00cae5a85 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt @@ -11,5 +11,7 @@ internal interface PromoBannersRepository { languageISOCode: String, ): List + suspend fun getCachedBanners(walletId: String, placeholder: Placeholder): List? + suspend fun dismissBanner(walletId: String, displayId: Int) } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandler.kt b/features/send/impl/src/main/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandler.kt index 9d23ebb812..dff5eb3a47 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandler.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandler.kt @@ -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, diff --git a/features/send/impl/src/test/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandlerTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandlerTest.kt index fd7a06c9a4..3180e45d7c 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandlerTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandlerTest.kt @@ -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()) } 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() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 4f15adb4cd..cddf6734fd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -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, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index 99490e6c34..0107ab0e21 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -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() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index f1a93abe4b..730448af16 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -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 -> diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index e0b657e86e..dfc1c02bb4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -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 } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt index 542935b215..e149150745 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt @@ -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( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNotificationsSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNotificationsSubscriber.kt index 57f51e06d1..fe23724ee9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNotificationsSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNotificationsSubscriber.kt @@ -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> { @@ -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 diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 3b467d4542..92b74ed718 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -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, 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, + ) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index 8c4b1cfede..7e40f611a4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -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, + listStates: ImmutableMap, 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() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt index 844acea245..4489e87a40 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt @@ -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, + ) } } diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNotificationsSubscriberTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNotificationsSubscriberTest.kt new file mode 100644 index 0000000000..e503f4691e --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNotificationsSubscriberTest.kt @@ -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(relaxed = true) + every { walletUM.walletsBalanceUM.id } returns WALLET_ID + val screenState = mockk(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 = persistentListOf( + WalletNotificationUM.YieldBoostPromo(onExploreClick = {}, onLaterClick = {}), + ) + + private companion object { + val WALLET_ID = UserWalletId("01") + } +} \ No newline at end of file