diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 498b0bcd0d..158fbd8808 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 498b0bcd0d871ed60c43b5d44f646548a4f11d37 +Subproject commit 158fbd8808d2db92ef82d3f9ed92c81340c707c5 diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index 4c03c5b927..fbadecf45f 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -111,6 +111,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("Send"), TxData data class Swap( @@ -118,6 +119,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("Swap"), TxData data class Staking( @@ -125,6 +127,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("Staking"), TxData data class Approve( @@ -132,6 +135,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, val permissionType: String, ) : TxSentFrom("Approve"), TxData @@ -140,6 +144,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType?, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("WalletConnect"), TxData data object Sell : TxSentFrom("Sell") @@ -149,6 +154,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("NFT"), TxData data class SendWithSwap( @@ -156,6 +162,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("Send&Swap"), TxData data class Earning( @@ -163,6 +170,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("Earning"), TxData } @@ -171,6 +179,12 @@ sealed class AnalyticsParam { val token: String val feeToken: String val feeType: FeeType? + val feeAssetType: FeeAssetType + } + + sealed class FeeAssetType(val value: String) { + data object Coin : FeeAssetType("Coin") + data object Token : FeeAssetType("Token") } sealed class FeeType(val value: String) { @@ -307,6 +321,8 @@ sealed class AnalyticsParam { const val REFERRAL_ID = "Referral_ID" const val SEARCHED = "Searched" const val RATE_TYPE = "Rate Type" + const val SCREEN_TYPE = "Screen Type" + const val FEE_ASSET_TYPE = "Fee Asset Type" const val SIGN_IN_TYPE = "Sign in type" const val WALLETS_COUNT = "Wallets Count" const val WALLET_TYPE = "Wallet Type" diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt index f370340d57..f23fbffdd3 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt @@ -77,6 +77,7 @@ sealed class Basic( put(AnalyticsParam.TOKEN_PARAM, sentFrom.token) sentFrom.feeType?.value?.let { put(AnalyticsParam.FEE_TYPE, it) } put(AnalyticsParam.FEE_TOKEN, sentFrom.feeToken) + put(AnalyticsParam.FEE_ASSET_TYPE, sentFrom.feeAssetType.value) } if (sentFrom is AnalyticsParam.TxSentFrom.Approve) { put(AnalyticsParam.PERMISSION_TYPE, sentFrom.permissionType) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt index 135c8bfaa8..f0f88d8c24 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.di import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore @@ -25,9 +26,10 @@ internal object TangemPayStoresModule { @Provides @Singleton - fun provideTangemPayReissueCardStore(): TangemPayReissueCardStore { + fun provideTangemPayReissueCardStore(prefs: AppPreferencesStore): TangemPayReissueCardStore { return DefaultTangemPayReissueCardStore( feeStore = RuntimeDataStore(), + prefs = prefs, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt index 666b4e790c..bb25810fba 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt @@ -1,11 +1,16 @@ package com.tangem.datasource.local.visa +import androidx.datastore.preferences.core.stringPreferencesKey import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.getSyncOrNull +import com.tangem.datasource.local.preferences.utils.store import com.tangem.domain.models.pay.TangemPayReissueCardFee import com.tangem.domain.models.wallet.UserWalletId internal class DefaultTangemPayReissueCardStore( private val feeStore: RuntimeDataStore, + private val prefs: AppPreferencesStore, ) : TangemPayReissueCardStore { override suspend fun storeReissueFee( @@ -20,11 +25,15 @@ internal class DefaultTangemPayReissueCardStore( } override suspend fun storeReissueOrderId(cardId: String, orderId: String) { - // TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs + prefs.store( + key = getReissueKey(cardId), + value = orderId, + ) } override suspend fun getOrderId(cardId: String): String? { - // TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs - return null + return prefs.getSyncOrNull(key = getReissueKey(cardId)) } + + private fun getReissueKey(cardId: String) = stringPreferencesKey("tangem_pay_reissue_card_$cardId") } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt index 3c87c7cfd2..f9047d1e43 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt @@ -45,6 +45,7 @@ sealed interface PaymentAccountStatusValueDM { @Json(name = "deposit_address") val depositAddress: String?, @Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM, @Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM, + @Json(name = "available_for_withdrawal") val availableForWithdrawal: BigDecimal, @Json(name = "cards") val cards: List, ) : PaymentAccountStatusValueDM @@ -85,5 +86,6 @@ sealed interface PaymentAccountStatusValueDM { @Json(name = "admin_daily_limit") val adminDailyLimit: SerializedBigDecimal?, @Json(name = "is_frozen") val isFrozen: Boolean, @Json(name = "last_digits") val lastDigits: String, + @Json(name = "is_reissuing") val isReissuing: Boolean, ) } \ No newline at end of file diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index f8b409fae1..786150a81b 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -80,6 +80,9 @@ Token hinzufügen Wähle den Token aus, den Du erhalten möchtest Wähle den Token, den Du tauschen möchtest + Guthaben hinzufügen + Tauschen + Übertragung Zum Portfolio hinzufügen Token hinzufügen Sortieren und Gruppieren @@ -87,6 +90,10 @@ Netzwerk wählen Token anlegen Token verwalten + Kreditkarte oder Bankkonto + Teile deine Adresse oder dein QR-Code + Zwische deinen Portfolios + Empfangen Sende nur %1$s ( %2$s ) vom %3$s -Netzwerk an diese Adresse. Die Verwendung anderer Token und Netzwerke kann zum Verlust von Geldern führen. Standard Altbestand @@ -433,6 +440,7 @@ Transaktionen Überweisung Übertragen + Bitte versuche es später noch einmal. Die Daten konnten nicht geladen werden… Ich verstehe Ich verstehe, fahre bitte fort. @@ -1063,6 +1071,7 @@ Andere Optionen Deine Schlüssel(private-keys) werden sicher im Inneren der Karte oder Ring generiert. Es gibt keine Seed-Phrase, d. h. niemand kann sie exportieren oder stehlen. Schlüssel anonym generieren + Indem Sie fortfahren, stimmen Sie den folgenden Bedingungen zu: Deine Karte oder Ring ist aktiviert und einsatzbereit Erfolgreich! Deine Wallet ist eingerichtet und einsatzbereit! @@ -1188,6 +1197,8 @@ über %s Du zahlst Gruppe erstellen + Netzwerke auswählen + Nach Kontostand sortieren Nach Guthaben Token organisieren Gruppe löschen @@ -1682,7 +1693,7 @@ PIN-Code erstellt CVC Daten konnten nicht geladen werden. Versuche es später noch einmal. - Ablaufdatum + Ablauf Karte einfrieren Details ausblenden Verstecken @@ -1746,8 +1757,15 @@ Holen Sie sich Ihre kostenlose virtuelle Tangem Visa-Karte Tangem Pay erhalten Zum Support + Es generiert einen neuen Satz Kartendaten. + Ausgabegebühr + Einzahlung von USDC auf das Zahlungskonto zur Deckung der Ausstellungsgebühr + Gebühr kann nicht gedeckt werden + Eine zusätzliche Karte ausstellen? + Karte ausstellen Es dauert in der Regel bis zu 15 Minuten. Einrichtung Ihrer Tangem-Karte + Ausstellung einer neuen digitalen Karte Ausstellung Deiner Karte Die Karte wird in der Regel innerhalb von 5 Minuten automatisch ausgestellt. In seltenen Fällen, wenn eine manuelle Überprüfung erforderlich ist, kann die Ausstellung bis zu 48 Stunden dauern. Tangem Pay @@ -1764,6 +1782,8 @@ KYC-Sperre ausblenden Leider konnten wir Ihre Identität nicht verifizieren. + Du kannst bis zu 3 Karten haben. Lösche eine, um eine neue Karte hinzuzufügen. + Maximale Anzahl ausgegebener Karten Holen Sie sich Ihre kostenlose virtuelle Tangem Visa-Karte Nutzen Sie USDC für alltägliche Zahlungen Karte erhalten @@ -1872,6 +1892,7 @@ Validierer: %s Benachrichtigungen sind aktiviert, funktionieren aber erst, wenn Du Benachrichtigungen in Deinen Geräteeinstellungen zulässt. Transaktionsbenachrichtigungen + Transfer läuft Minimum %s Der Mindesttransaktionsbetrag beträgt %1$s. Die Netzwerkgebühren für beliebte Token im Tron-Netzwerk können höher sein. Das Staking von TRX kann helfen, die Transaktionskosten zu senken. @@ -1903,7 +1924,9 @@ Genehmigung für Transaktionen anfordern Sei der Erste, der von neuen Aktionen erfährt Frühzeitiger Zugriff auf neue Funktionen und exklusive Angebote. + Preisbenachrichtigungen, Produktneuheiten und exklusive Angebote Updates zu Funktionen und Neuigkeiten + Angebote & Updates Möchtest du Push-Benachrichtigungen verwenden? Aktiviere Push-Benachrichtigungen und wir benachrichtigen Dich sofort, wenn Gelder eintreffen Verpasse keine Transaktion diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index b385282be7..eaea4ea109 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -646,6 +646,8 @@ Mantiene sus criptomonedas seguras y sin conexión. Tan delgadas como una tarjeta de crédito, más seguras que una bóveda bancaria. Si lo hace, tendrá que empezar de nuevo. Recuperar la billetera existente a través de una copia de seguridad de Google Drive + Estamos trabajando en la copia de seguridad de Google Drive para facilitar aún más la recuperación de la billetera. + Próximamente, copia de seguridad de Google Drive Copia de seguridad de Google Drive Cree una billetera segura y transfiera sus fondos para mayor protección. Crear nueva billetera @@ -985,6 +987,11 @@ Por favor repita la operación. La tarjeta/anillo se restablecerá a la configuración de fábrica. Error de activación Agregar tokens + + Su wallet contiene %d token. Para continuar, por favor sincronice sus direcciones. + Su wallet contiene %d tokens. Para continuar, por favor sincronice sus direcciones. + + Sincroniza tu wallet Ha añadido una tarjeta o anillo como copia de seguridad. Una vez finalizada la copia de seguridad, no puedes añadir más dispositivos. Si tiene una tarjeta o anillo más, añádalo ahora. ¿Quiere continuar? La copia de seguridad está parcialmente completa y no se puede salir ahora. Una frase de contraseña es una función de seguridad opcional que añade una palabra o frase a su frase de recuperación, creando un nuevo conjunto de direcciones de billetera para una mayor protección. @@ -1006,6 +1013,7 @@ Otras opciones Sus claves se generarán de forma segura dentro del chip. No hay seed phrase, lo que significa que nadie puede exportarla ni robarla. Generar claves de forma privada + Al continuar, acepta las \n%s Su tarjeta está activada y lista para usar ¡Éxito! ¡Su billetera está configurada y lista para usar! @@ -1510,13 +1518,21 @@ Siéntase seguro con una asistencia permanente que le ayudará con cualquier problema Siempre aquí Múltiples proveedores de confianza en un solo lugar: intercambie cualquier activo sin esfuerzo en su billetera + Intercambie criptos directamente en Tangem\nSin transferencias adicionales\nSin mover fondos a exchanges Intercambie con nosotros + Intercambia dentro de tu billetera Sin cuelgues, sin pérdidas, sin puntos ciegos - su transacción siempre está protegida + Los intercambios se realizan a través de proveedores de confianza. Sus llaves permanecen en su billetera Tangem en todo momento. Claro. Transparente. Autocustodiado. Defensa impenetrable + Ud mantiene el control Maximice su valor con tarifas de una amplia red de proveedores de confianza, eligiendo siempre la mejor + Tangem compara múltiples proveedores, tanto DEX como CEX. La mejor tarifa se selecciona automáticamente. ¿Prefiere otro proveedor? Puede elegirlo manualmente. Tarifas inmejorables + Mejor tarifa disponible Sencillo e intuitivo, permite cambiar tokens con solo unos cuantos toques + Intercambie entre las principales redes y miles de tokens 0% Comisión por intercambios entre stablecoins Simplemente cómodo + 90+ Blockchains\n16.000+ Activos Intercambio a través del proveedor Sus activos La cantidad incluye:\n• Tarifas del proveedor de servicios\n• Tarifas de red por enviar %s desde el intercambio a la dirección del usuario. @@ -1589,7 +1605,7 @@ Código PIN creado CVC Error al cargar datos. Inténtalo de nuevo más tarde. - Caducidad + Expira Congelar tarjeta Ocultar detalles Ocultar @@ -2030,6 +2046,10 @@ Use su tarjeta o anillo para obtener una dirección para la red %d Use su tarjeta o anillo para obtener direcciónes para las redes %d + + Sincronice direcciones para obtener una dirección de la red %d + Sincronice direcciones para obtener direcciones de las redes %d + Faltan algunas direcciones La red no está disponible actualmente. Por favor, inténtalo de nuevo más tarde. La red no está disponible diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 417c11eab4..e7e7f8b950 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -699,6 +699,9 @@ Posez pour scanner Tapez pour signer Posez la carte + Votre wallet est synchronisé et prêt.\nDes tokens sont manquants ? + Wallet importé avec succès + Restauration %d%% Vous avez mis à jour vos données biométriques, scannez votre carte ou bague pour entrer Votre solde doit être supérieur à la valeur des frais pour effectuer un transfert Solde insuffisant @@ -947,6 +950,7 @@ Veuillez répéter l\'opération. La carte sera réinitialisée aux paramètres d\'usine. Erreur d\'activation Ajouter des jetons + Synchronisez votre wallet Vous avez ajouté une carte de sauvegarde. Une fois le processus de sauvegarde terminé, vous ne pourrez plus ajouter de cartes de sauvegarde. Si vous avez une autre carte, ajoutez-la à la sauvegarde. Souhaitez-vous poursuivre le processus de sauvegarde ? Le processus de sauvegarde est partiellement terminé. Vous ne pouvez pas le quitter maintenant. La phrase secrète est une fonctionnalité de sécurité avancée utilisée par les portefeuilles cryptographiques. Il ajoute un mot ou une phrase supplémentaire de votre choix à votre phrase de récupération déjà existante pour débloquer un tout nouvel ensemble d\'adresses. @@ -1461,13 +1465,21 @@ Ayez confiance en notre assistance 24 heures sur 24 pour vous aider à résoudre tous vos problèmes Assistance 24 heures sur 24 Plusieurs fournisseurs de confiance en un seul endroit : échangez n\'importe quel actif facilement + Échangez des cryptos directement dans Tangem\nSans transferts supplémentaires\nSans envoyer vos fonds vers un échange Échangez Avec Nous + Échangez Dans Votre Portefeuille Une sécurité de haut niveau et des fournisseurs vérifiés garantissant que vos actifs sont protégés à chaque échange + Les échanges sont effectués par des prestataires de confiance. Vos clefs ne quittent jamais votre portefeuille Tangem. Clair. Transparent. Gestion autonome Plus Sûr Que Jamais + Vous Gardez le Contrôle Maximisez votre valeur avec des tarifs d\'un large réseau de fournisseurs de confiance + Tangem compare plusieurs fournisseurs, DEX et CEX. Le meilleur tarif est sélectionné automatiquement. Vous préférez un autre fournisseur? Vous pouvez le choisir manuellement. Meilleurs Tarifs + Meilleur Tarif Disponible Simple et intuitif, vous permettant d\'échanger des jetons en quelques clics + Échangez des milliers de jetons sur les principaux réseaux 0% de frais sur les échanges stablecoin à stablecoin Plus Simple Que Jamais + 90+ Blockchains\n16,000+ Actifs Échange via le fournisseur Vos actifs Le montant comprend :\n• les frais du fournisseur de services\n• les frais de réseau pour l\'envoi de %s depuis l\'échange vers l\'adresse de l\'utilisateur. diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 788f5e8064..62c071a8af 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -80,6 +80,9 @@ トークンの追加 受け取りたいトークンを選択します スワップしたいトークンを選択します + 資金を追加 + スワップ + 送金 ポートフォリオに追加 トークンを追加 並べ替え・グループ化 @@ -87,6 +90,10 @@ ネットワークを選択 カスタムトークンの追加 トークンの管理 + クレジットカードまたは銀行口座 + アドレスまたはQRコードを共有 + 自分のポートフォリオ間で + 受け取る このアドレスには%3$s ネットワークから%1$s (%2$s) のみを送信してください。他のトークンやネットワークを使用すると、資金を失う可能性があります。 デフォルト レガシー @@ -1046,6 +1053,7 @@ その他のオプション 秘密鍵はチップ内で安全に生成されます。シードフレーズは存在しないので、誰もエクスポートしたり盗んだりすることはできません。 秘密鍵を非公開で生成する + 続行すると、以下に同意したものとみなされます。 カードは有効化され、使用可能になりました 成功! ウォレットの設定が完了し、使用できるようになりました。 @@ -1564,13 +1572,13 @@ 24時間体制のサポートであらゆる問題に対応します。 いつもここに 複数の信頼できるプロバイダーが一箇所に集結。ウォレット内で様々な暗号資産を簡単に交換できます。 - Tangem内で暗号資産を直接交換できます\n追加の送金は不要です\n取引所に資金を移す必要もありません + Tangem内で暗号資産を直接交換できます\n追加の送金は不要\n取引所に資金を移す必要もありません ぜひスワップしてください ウォレット内でスワップ 失敗も死角もありません。取引は常に保護されます。 スワップは信頼できるプロバイダーを通じて実行されます。秘密鍵は常にTangemウォレット内に保持されます。明確で、透明性が高く、自己管理型です。 難攻不落の防御 - 主導権はあなたの手にあります + 主導権はあなたの手に 幅広いネットワークの中から、常に最適なプロバイダーと料金レートを選択します TangemはDEXとCEXを含む複数のプロバイダーを比較し、最良レートを自動で選択します。別のプロバイダーを希望する場合は、手動で選択できます。 破格のレート @@ -1860,6 +1868,7 @@ バリデーター: %s 通知は有効になっていますが、デバイスの設定で通知を許可するまでは機能しません。 取引通知 + 送金処理中 最小%s 最小取引金額は%1$sです。 Tronネットワークの人気トークンの手数料は高い可能性があります。TRXをステーキングすると、取引コストを削減できる可能性があります。 @@ -1891,7 +1900,9 @@ 受信取引の通知を受け取る 新しいプロモーション情報をいち早く入手しましょう 新しい機能や限定オファーへの早期アクセス。 + 価格変動アラート、製品ニュース、限定オファー 機能およびニュースのアップデート + オファー・最新情報 プッシュ通知を使用しますか? プッシュ通知を有効にすると、ウォレットに着金したときにアラートを受信できます。 取引を見逃さない 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 9d7f86e8d9..64d51c812e 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -80,6 +80,9 @@ Adicionar tokens Escolha o token que deseja receber. Escolha o token que deseja trocar. + Adicionar fundos + Trocar + Transferir Adicione ao seu portfólio Adicionar tokens Classificação e agrupamento @@ -87,6 +90,10 @@ Escolha a rede Adicionar token personalizado Gerenciar tokens + Cartão de crédito ou conta bancária + Compartilhe seu endereço ou código QR. + Entre seus portfólios + Você recebe Enviar somente %1$s (%2$s) de %3$s A rede não está vinculada a este endereço. O uso de outros tokens e redes pode resultar na perda de fundos. Padrão Legado @@ -217,6 +224,7 @@ Acesso negado Conta Contas + %s fracassado Ativar Adicionar Adicionar fundos @@ -233,6 +241,8 @@ Aplicar Aprovação Aprovar + Aprovado + Aprovando Atenção Redes disponíveis Backup @@ -315,6 +325,10 @@ Esconder Mantenha-se em %s hora + + %d hora + %d horas + UM OUTRO @@ -365,6 +379,8 @@ %1$s — %2$s Leia mais Receber + Recebido + Recebendo Recomendado Rejeitar Recarregar @@ -383,6 +399,8 @@ Enviar Enviar: Falha ao enviar a transação + Enviando + Enviado O servidor não está disponível. Tente novamente mais tarde. Compartilhar Compartilhar link @@ -393,6 +411,7 @@ Pular Algo deu errado Stake + Estacado Apostas Começar Enviar @@ -400,6 +419,8 @@ Suporte Redes suportadas Trocar + Trocado + Troca Tangem Carteira Tangem Toque e segure @@ -418,6 +439,8 @@ Status da transação Transações Transferir + Transferido + Por favor, tente novamente mais tarde. Não foi possível carregar os dados… Eu entendo Entendi, continue. @@ -427,10 +450,12 @@ Unstake Devido a %1$s limitações apenas %2$d Os UTXOs podem caber em uma única transação. Isso significa que você só pode enviar %3$s ou menos. Você precisa reduzir a quantidade. Valor copiado + Votação Carteiras Aviso semana com + Retirada Sim Modo de rendimento Endereço do contrato copiado! @@ -592,6 +617,7 @@ Melhor tarifa Lista de advertências da FCA A tarifa fixa não está disponível. + Fornecedor para troca Tarifa competitiva Fornecedor na lista de advertências da FCA Disponível até %s @@ -673,6 +699,8 @@ Mantém suas criptomoedas seguras e offline. Fino como um cartão de crédito, mais seguro que um cofre de banco. Se fizer isso, terá que começar tudo de novo. Recupere sua carteira existente por meio do backup do Google Drive. + Estamos trabalhando no backup do Google Drive para facilitar ainda mais a recuperação da carteira. + O backup do Google Drive estará disponível em breve. Backup do Google Drive Crie uma carteira segura e transfira seus fundos para obter proteção adicional. Criar nova carteira @@ -1043,6 +1071,7 @@ Outras opções Suas chaves serão geradas com segurança dentro do chip. Não há frase mnemônica, o que significa que ninguém pode exportá-las ou roubá-las. Gere chaves de forma privada + Ao continuar, você concorda com os termos. %1$s Seu cartão está ativado e pronto para uso. Sucesso! Sua carteira está configurada e pronta para uso! @@ -1168,6 +1197,8 @@ via %s Você pagará Grupo + Agrupar por redes + Ordenar por saldo Por equilíbrio Organizar tokens Desagrupar @@ -1193,6 +1224,8 @@ Parâmetros desconhecidos Cartão de crédito ou conta bancária Compartilhe seu endereço ou código QR. + Venda criptomoedas com segurança + Enviar para outra carteira Entre seus portfólios Outro Recarga rápida @@ -1442,6 +1475,7 @@ O período que você deve aguardar após solicitar o saque de fundos do staking para que os tokens fiquem disponíveis. Período de aquecimento O período alocado para ativar a participação no staking. + Staking ativado Não há validadores disponíveis no momento. Tente novamente mais tarde. Staking indisponível A rede cobrará uma taxa de aprovação de token para verificar se você está autorizando o uso do seu token para staking. @@ -1552,6 +1586,7 @@ Uma transação de entrada de pelo menos %1$s é necessário prosseguir Fundos insuficientes Ao aprovar, você permite que o contrato inteligente utilize seus tokens em transações futuras. + Modo detalhado Taxa fixa A rede cobrará uma taxa de aprovação de token para verificar se você está autorizando o uso do seu token para a troca. Troca em andamento @@ -1560,12 +1595,13 @@ Procurando algo diferente?\n Experimente pesquisar ou explore outra criptomoeda! Pesquise qualquer token, mesmo que ele ainda não esteja na sua lista. Use a busca para encontrar o que você precisa. + Modo simples Sinta-se seguro com o suporte disponível 24 horas por dia, 7 dias por semana, para ajudá-lo com qualquer problema. Sempre aqui Diversos provedores confiáveis ​​em um só lugar — troque qualquer ativo facilmente em sua carteira. Troque criptomoedas diretamente na Tangem\nSem transferências adicionais\nSem movimentação de fundos para corretoras Troque conosco - Troque o conteúdo da sua carteira. + Troque dentro de sua carteira Sem erros, sem perdas de posse, sem pontos cegos — sua transação está sempre protegida. As trocas são executadas por meio de provedores confiáveis. Suas chaves permanecem em sua carteira Tangem o tempo todo. Clara. Transparente. Autocustódia. Defesa Impenetrável @@ -1577,7 +1613,7 @@ Sem complicações e intuitivo, permitindo que você troque tokens com apenas alguns toques Troca entre as principais redes e milhares de tokens 0% Taxa em trocas de stablecoins Simplesmente conveniente - Mais de 90 blockchains | Mais de 16.000 ativos + mais de 90 blockchains\n16.000+ ativos Trocar através do provedor Seus ativos O valor inclui:\n• taxa do provedor de serviços\n• taxa de rede para envio %s da central de distribuição de volta para o endereço do usuário. @@ -1605,6 +1641,8 @@ não disponível Liquidez insuficiente para esta transação. \n o valor ou escolha outro provedor. Negociação em grande escala + Transferir + Transferir... Teremos todo o prazer em receber seu feedback. O Tangem Pay agora está em versão beta. Não foi possível renomear o cartão. @@ -1681,6 +1719,7 @@ Substituir cartão Somente letras e números são permitidos. Caracteres inválidos + Nome do cartão Revelar Mostrar detalhes Troque qualquer ativo da sua carteira por um cartão. @@ -1693,6 +1732,7 @@ Retirada indisponível agora Você não pode iniciar uma troca ou um novo saque até que o atual seja concluído. Retirada em andamento + Nome do cartão Definir um limite a partir de %s Não foi possível definir o limite. Tente novamente. Mudar @@ -1703,6 +1743,10 @@ O limite diário está definido. Limite diário Configurações do cartão + + %d cartão + %d cartões + Alterar código PIN Volte ao aplicativo se você se esquecer. Defina um limite a partir de %s para %s @@ -1717,8 +1761,15 @@ Obtenha seu cartão virtual Visa Tangem grátis. Obtenha o Tangem Pay Acesse o Suporte + Isso gera um novo conjunto de detalhes do cartão. + Taxa de emissão + Deposite USDC na conta de pagamento para cobrir a taxa de emissão. + Não foi possível cobrir a taxa. + Emitir um cartão adicional? + Emitir cartão Geralmente leva até 15 minutos. Configurando seu cartão Tangem + Emissão de um novo cartão digital Emissão do seu cartão O cartão geralmente é emitido automaticamente em até 5 minutos. Em casos raros, se for necessária uma análise manual, o processo pode levar até 48 horas. Tangem Pay @@ -1735,6 +1786,8 @@ Ocultar bloco KYC Desculpe, não foi possível verificar. Seu perfil. + Você pode ter até 3 cartões. Exclua um para adicionar um novo. + Número máximo de cartões emitidos Obtenha seu cartão virtual Visa Tangem grátis. Use USDC para pagamentos do dia a dia. Obter cartão @@ -1744,6 +1797,8 @@ Pague exatamente o que você vê. Uma conta de pagamento separada será criada sem divulgar seus endereços e bens. Privacidade incomparável + E vincule um cartão de pagamento a ele. + Vamos configurar uma carteira. Obtenha seu cartão Tangem Pay gratuito em minutos. Suporte de Pay Conta de pagamento @@ -1764,6 +1819,7 @@ Não foi possível exibir os detalhes. No entanto, os pagamentos com cartão ainda estão funcionando. Defina o código PIN. Cartão desativado + Substituindo seu cartão Sessão expirada Restaurar acesso Use USDC para pagamentos do dia a dia. @@ -1819,18 +1875,28 @@ Não disponível para venda Não disponível para troca a partir de %s Indisponível para troca + Reivindicar recompensa contrato: %s + Desativar o modo Yield + Ganhos com participação Você ainda não possui nenhuma transação. Falha ao carregar o histórico de transações. Clique no botão \n para atualizar as informações. + de: %%image%% %s Vários endereços O histórico de transações ainda não é suportado nesta blockchain. Mas não se preocupe, estamos trabalhando nisso! Enquanto isso, você pode conferir no explorador. Operação + Pendente + Recompensas redefinidas + Recompensas por reconfiguração + Recompensa de staking + para: %%image%% %s para: %s de: %s para: %s validador: %s As notificações estão ativadas, mas não funcionarão até que você as permita nas configurações do seu dispositivo. Notificações de transação + Transferência em andamento Mínimo %s O valor mínimo da transação é %1$s. As taxas da rede Tron para tokens populares podem ser mais altas. Fazer staking de TRX pode ajudar a reduzir os custos de transação. @@ -1862,7 +1928,9 @@ Receba notificações de transações recebidas. Seja o primeiro a saber sobre novas promoções. Acesso antecipado a novas funcionalidades e ofertas exclusivas. + Alertas de alteração de preços, novidades sobre produtos e ofertas exclusivas. Atualizações de notícias e recursos + Ofertas e atualizações Você gostaria de usar notificações push? Ative as notificações push para receber alertas quando os fundos chegarem à sua carteira. Não perca nenhuma transação. @@ -2104,6 +2172,10 @@ Use seu Cartão ou Anel para obter o endereço de uma rede %d. Use seu Cartão ou Anel para obter endereços de redes %d. + + Sincronizar endereços para obter um endereço de %d rede + Sincronizar endereços para obter endereços para %d rede + Alguns endereços estão faltando. A rede está inacessível no momento. Tente novamente mais tarde. A rede está inacessível. diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 1be1c77fac..bfd2cb942b 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -80,6 +80,9 @@ Добавьте токены Выберите токен для получения Выберите токен для обмена + Пополнить + Обмен + Перевод Добавить в портфель Добавить токен Сортировка и группировка @@ -87,6 +90,9 @@ Выберите сеть Добавить токен Валюты + Поделитесь своим адресом или QR-кодом. + Между вашими портфелями + Вы получите Отправляйте только %1$s (%2$s) в сети %3$s на этот адрес. Использование другой сети может привести к утрате средств. По умолчанию Устаревший @@ -512,7 +518,7 @@ Подписано Отправить отзыв Подробности - У вас может быть только один мобильный кошелек. Вы можете апгрейдить его до аппаратного кошелька Tangem или добавить новый аппаратный кошелек. + Мобильный кошелек может быть только один. Перенесите его на аппаратный кошелек Tangem или добавьте новый аппаратный кошелек. Проверьте подключение с интернетом или переключитесь на другую сеть Условия использования Основной адрес @@ -1046,6 +1052,13 @@ Необходимо повторить операцию, при этом карта будет сброшена к заводским настройкам Ошибка активации Добавление токенов + + В вашем кошельке %d токен. Синхронизируйте адреса, чтобы продолжить. + В вашем кошельке %d токена. Синхронизируйте адреса, чтобы продолжить. + В вашем кошельке %d токенов. Синхронизируйте адреса, чтобы продолжить. + В вашем кошельке %d токенов. Синхронизируйте адреса, чтобы продолжить. + + Синхронизируйте ваш кошелек Вы добавили одну резервную карту или кольцо. После того, как процесс будет завершен, Вы больше не сможете добавить еще. Если у Вас есть еще одна карта или кольцо, добавьте ее в резервную копию. Хотите продолжить? Процесс резервного копирования почти завершен. Вы не можете выйти из него сейчас. Парольная фраза — это дополнительная функция безопасности, которая добавляет слово или фразу к вашей фразе восстановления, создавая новый набор адресов кошелька для дополнительной защиты. @@ -1067,6 +1080,7 @@ Другие опции Ваши ключи будут надежно сгенерированы внутри чипа. Никакой seed-фразы, а это значит, что никто не может экспортировать или украсть ее. Cоздавайте ключи приватно + Продолжая, вы соглашаетесь с \n%s Ваша карта активирована и готова к использованию Успешно! Ваш кошелек настроен и готов к использованию! @@ -1188,6 +1202,8 @@ через %s Вы заплатите Группы + Сгруппировать по сети + Сортировать по балансу По балансу Упорядочить токены Список @@ -1581,13 +1597,21 @@ Чувствуйте уверенность с круглосуточной поддержкой, готовой помочь в любой ситуации! Круглосуточная поддержка Надежные провайдеры в одном месте — обменивайте любые активы легко и быстро прямо в своем кошельке! + Обменивайте крипту прямо в Tangem\nНикаких лишних переводов\nНе нужно отправлять средства на биржи Обменивайте с нами + Обмен прямо в кошельке Высший уровень безопасности и проверенные партнёры защищают каждую транзакцию + Обмен осуществляется через надежных провайдеров. Ваши ключи всегда остаются в кошельке Tangem. Понятно. Прозрачно. Некастодиально. Полная защита + Всё Под Вашим Контролем Автоматически находим самые выгодные предложения среди проверенных провайдеров + Tangem сравнивает несколько провайдеров, как DEX, так и CEX. Лучший курс выбирается автоматически. Предпочитаете другого провайдера? Вы можете выбрать его вручную. Лучшие курсы + Лучший Доступный Курс Интуитивный обмен в пару касаний — без сложностей и ожидания + Обменивайте тысячи токенов между любыми сетями. Комиссия 0% на обмен стейблкоинов Проще простого + 90+ Блокчейнов\n16 000+ Активов Обмен через провайдера Ваши средства В сумму включено: \n• комиссия провайдера сервиса\n• комиссия сети за отправку %s от биржи обратно на адрес пользователя. @@ -1833,6 +1857,7 @@ валидатор: %s Уведомления включены, но не будут работать, пока вы не разрешите их в настройках устройства. Уведомления о транзакциях + Перевод в процессе Минимум %s Минимальная сумма транзакции равна %1$s. Комиссии сети Tron для популярных токенов могут быть выше. Стейкинг TRX может помочь снизить расходы на транзакции. @@ -1975,7 +2000,7 @@ Установить Код Доступа Настройки кошелька Tangem - Используйте %s или отсканируйте карту/кольцо, чтобы разблокировать доступ к вашему кошельку + Воспользуйтесь %s или отсканируйте карту/кольцо, чтобы разблокировать доступ к вашему кошельку Процесс выдачи разрешения уже в работе и скоро будет завершен Выдача разрешения Похоже, что процесс активации карт или кольца не был завершен корректно. Это могло быть вызвано проблемой взаимодействия с модулем NFC либо некорректным прикладыванием карты или кольца к телефону. Пожалуйста, обратитесь в нашу службу поддержки для уточнения деталей. @@ -2049,6 +2074,12 @@ Используйте вашу карту или кольцо, чтобы получить адреса для %d сетей Используйте вашу карту или кольцо, чтобы получить адреса для %d сетей + + Синхронизируйте адреса, чтобы получить адрес для %d сети + Синхронизируйте адреса, чтобы получить адреса для %d сетей + Синхронизируйте адреса, чтобы получить адреса для %d сетей + Синхронизируйте адреса, чтобы получить адреса для %d сетей + Некоторые адреса отсутствуют В данный момент сеть недоступна. Пожалуйста, попробуйте позже. Сеть недоступна 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 bdee745543..e246aefdb0 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -719,6 +719,9 @@ Прикладіть, щоб відсканувати Прикладіть, щоб підписати Прикладіть картку або кільце + Ваш гаманець синхронізовано й готово до використання.\nНе бачите деякі токени? + Гаманець успішно імпортовано + Відновлення %d%% Ви оновили дані біометрії, відскануйте свою картку або кільце для входу Ваш баланс повинен перевищувати суму комісії для здійснення переказу Недостатньо балансу @@ -981,6 +984,13 @@ Необхідно повторити операцію, при цьому картка буде скинута до заводських налаштувань. Помилка активації Додати токени + + У вашому гаманці %d токен. Синхронізуйте адреси, щоб продовжити. + У вашому гаманці %d токени. Синхронізуйте адреси, щоб продовжити. + У вашому гаманці %d токенів. Синхронізуйте адреси, щоб продовжити. + У вашому гаманці %d токенів. Синхронізуйте адреси, щоб продовжити. + + Синхронізуйте свій гаманець Ви додали одну резервну картку або кільце. Після завершення процесу резервного копіювання ви не зможете додати їх більше. Якщо у вас є ще одна картка або кільце, додайте її до резервної копії. Бажаєте продовжити? Процес резервного копіювання частково завершено. Ви не можете вийти з нього зараз. Парольна фраза – це розширена функція безпеки, яку використовують криптогаманці. Вона додає додаткове слово або фразу на ваш вибір до вже існуючої seed - фрази, щоб розблокувати абсолютно новий набір адрес. @@ -1002,6 +1012,7 @@ Інші опції Ваші ключі будуть надійно згенеровані всередині чіпу. Ніякої seed-фрази, а це означає, що ніхто не зможе її експортувати або вкрасти. Генеруйте ключі приватно + Продовжуючи, ви погоджуєтеся з \n%s Ваша картка активована та готова до використання Успішно! Ваш гаманець налаштований і готовий до використання! @@ -1508,13 +1519,21 @@ Надійна підтримка 24/7, щоб ваші фінансові операції були швидкими та надійними! Цілодобова підтримка Надійні провайдери дозволяють легко обмінювати активи, забезпечуючи повну безпеку у вашому гаманці + Обмінюйте крипту прямо в Tangem\nНіяких зайвих переказів\nНе потрібно відправляти кошти на біржі Обмінюйте з нами + Обмін прямо в гаманці Найвищий рівень безпеки та перевірені провайдери гарантують захист ваших активів при кожному обміні. + Обмін здійснюється через надійних провайдерів. Ваші ключі завжди залишаються в гаманці Tangem. Зрозуміло. Прозоро. Некастодіально. Безпечніше, ніж будь-коли + Усе Під Вашим Контролем Отримуйте максимальну вигоду з кращими курсами від надійних провайдерів + Tangem порівнює кілька провайдерів, як DEX, так і CEX. Найкращий курс обирається автоматично. Віддаєте перевагу іншому провайдеру? Ви можете обрати його вручну. Найкращі тарифи + Найкращий Доступний Курс Просто та зручно — обмін токенів в декілька дотиків + Обмінюйте тисячі токенів між будь-якими мережами. Комісія 0% на обмін стейблкоїнів Легше, ніж будь-коли + 90+ Блокчейнів\n16 000+ Активів Обмін через провайдера Ваші активи Сума включає: \n• комісію постачальника послуг\n• комісію мережі за відправлення %s з біржі назад на адресу користувача. @@ -1979,6 +1998,12 @@ Використовуйте вашу картку або кільце, щоб отримати адреси для %d мереж Використовуйте вашу картку або кільце, щоб отримати адреси для %d мереж + + Синхронізуйте адреси, щоб отримати адресу для %d мережі + Синхронізуйте адреси, щоб отримати адреси для %d мереж + Синхронізуйте адреси, щоб отримати адреси для %d мереж + Синхронізуйте адреси, щоб отримати адреси для %d мереж + Деякі адреси відсутні Мережа наразі недоступна. Будь ласка, спробуйте пізніше. Мережа недоступна 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 ca880157f5..1369fb0631 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -80,6 +80,9 @@ 添加代币 选择您想要接收的代币 选择您要兑换的代币 + 增加资金 + 兑换 + 转账 添加到您的投资组合 添加代币 排序和分组 @@ -87,6 +90,10 @@ 选择网络 添加自定义代币 管理代币 + 信用卡或银行账户 + 分享您的地址或二维码 + 在您的投资组合之间 + 您将收到 只能从 %3$s 网络发送 %1$s (%2$s) 到此地址。使用其他代币和网络可能会导致资金损失。 默认 传统 @@ -422,6 +429,7 @@ 交易 转让 已转账 + 请稍后再试。 无法加载数据…… 我明白 我明白,请继续 @@ -1045,6 +1053,7 @@ 其他选项 您的密钥将在芯片内部安全生成,没有助记词,这意味着任何人都无法导出或窃取它。 私下生成密钥 + 如继续,即表示您同意 您的卡已激活,可以使用了。 成功! 您的钱包已设置完毕,可以使用了! @@ -1166,6 +1175,8 @@ 通过 %s 您将支付 分组 + 按网络分组 + 按余额排序 按余额 整理代币 取消分组 @@ -1439,6 +1450,7 @@ 从质押账户提取资金后,您必须等待一段时间才能获得代币。 热身期 激活参与质押的指定时间。 + 已启用质押 目前没有可用的验证节点。请稍后再试。 质押功能不可用 网络将收取代币批准费,以验证您是否授权使用您的代币进行质押。 @@ -1682,6 +1694,7 @@ 重新发行卡片 只允许输入字母和数字。 无效字符 + 卡片名称 显示 显示详情 将您投资组合中的任何资产互换到卡片 @@ -1705,6 +1718,9 @@ 每日限额已设定 每日限额 卡片设置 + + %d卡片 + 更改PIN码 如果忘记了,请返回应用程序。 设置限额从 %s 到 %s @@ -1719,8 +1735,15 @@ 免费领取您的 Tangem Visa 虚拟卡 获取 Tangem Pay 前往支持页面 + 它会生成一组新的卡片详细信息。 + 发行费 + 将USDC存入支付账户以支付发行费用 + 无法支付费用 + 是否再发一张卡? + 发卡 通常需要最多15分钟 设置您的 Tangem 卡 + 发行新的数字卡 正在发放您的卡片 该卡通常会在 5 分钟内自动发放。极少数情况下,如果需要人工审核,则可能需要长达 48 小时。 Tangem Pay @@ -1737,6 +1760,8 @@ 隐藏 KYC 块 抱歉,我们无法验证 您的个人资料。 + 您最多可以添加 3 张卡片。删除一张即可添加新卡片。 + 最大发卡量 免费领取您的 Tangem Visa 虚拟卡 使用 USDC 进行日常支付 获取卡片 @@ -1768,6 +1793,7 @@ 无法显示详细信息。但刷卡支付功能仍然可用。 设置 PIN 码 卡片已停用 + 更换您的卡片 会话已过期 恢复访问权限 使用 USDC 进行日常支付 @@ -1829,7 +1855,7 @@ 质押收益 您目前还没有任何交易记录。 加载交易历史记录失败。\n点击刷新按钮更新信息。 - %image%%s + %%image%%%s 多个地址 本区块链目前不支持交易历史记录。不过不用担心,我们正在努力!在此期间,您可以在资源管理器中查看。 操作 @@ -1837,13 +1863,14 @@ 奖励已再质押 奖励再质押 质押奖励 - %image%%s + %%image%%%s 为 %s 来自 %s 到: %s 验证节点: %s 通知功能已启用,但需要您在设备设置中允许通知才能生效。 交易通知 + 转账中 最少 %s 最低交易金额为 %1$s。 热门代币在波场网络上的交易费用可能较高。质押 TRX 或许有助于降低交易成本。 @@ -1875,7 +1902,9 @@ 获取新交易通知 抢先了解最新促销活动 抢先体验最新功能和专属优惠。 + 价格变动提醒、产品资讯和独家优惠 专题报道和新闻更新 + 优惠与更新 您想使用推送通知吗? 启用推送通知,即可在资金到账时收到提醒。 不要错过任何一笔交易 @@ -2116,7 +2145,7 @@ 使用您的卡片或指环获取%d网络地址 - Adressen synchronisieren, um eine Adresse für das Netzwerk %d zu erhalten + 同步地址以获取%d网络地址 部分地址缺失 目前网络无法连接,请稍后再试。 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 73b4919126..3871825936 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -80,6 +80,9 @@ Add tokens Choose the token you want to receive Choose the token you want to swap + Add funds + Swap + Transfer Add to your portfolio Add tokens Sorting and grouping @@ -87,6 +90,10 @@ Choose network Add custom token Manage tokens + Credit card or bank account + Share your address or QR-code + Between your portfolios + You receive Send only %1$s (%2$s) from %3$s network to this address. Using other tokens and networks may result in loss of funds. Default Legacy @@ -1065,6 +1072,7 @@ Other options Your keys will be securely generated inside the chip. There is no seed phrase, which means nobody can export or steal it. Generate keys privately + By continuing, you agree to the \n%s Your card is activated and ready to be used Success! Your wallet is set up and ready to use! @@ -1154,12 +1162,16 @@ Service is provided by an external provider.\nTangem is not responsible. The purchase amount should be no more than %s The amount to buy must be at least %s + Cumulative transaction amount over %1s may require identity verification with %2s + By clicking Pay, you agree to %1s\'s %2s and %3s. No available providers for this currency Quickest processing Pay with Payment method Available up to %s Available from %s + US and UK issued cards can\'t be processed via this method. Provider may require additional identity verification + Provider requirements %d provider %d providers @@ -1190,10 +1202,19 @@ via %s You will pay Group + Group by networks + Sort by balance By balance Organize tokens Ungroup %s support + Product news, exclusive offers, and activity reminders. + Offers & Updates + Get notified about price changes for top market coins. + Price Alerts + Notification Settings + Real-time alerts for transactions, exchanges, and critical updates. + Transaction Alerts More info You can enable Notifications for Tangem in Settings. Enable Later @@ -1602,7 +1623,7 @@ Unbeatable Rates Best Available Rate Hassle-free and intuitive, allowing you to swap tokens in just a few taps - Swap across major networks
and thousands of tokens 0% fee on stablecoin-to-stablecoin swaps + Swap across major networks
and thousands of tokens 0% fee on stablecoin-to-stablecoin swaps Simply Convenient 90+ Blockchains\n16,000+ Assets Swap via provider @@ -1685,7 +1706,7 @@ CVC Failed to load data. Try again later. Expiry - Freeze Card + Freeze card Hide details Hide Open Google Wallet @@ -1747,7 +1768,7 @@ Failed to issue card A technical error has occurred, please try again by clicking the button below. A technical error has occurred, please contact support. - Feature will be availible soon + Feature will be available soon You will be available to issue additional cards for your payment account Get your free Tangem Visa virtual card Get Tangem Pay @@ -1888,6 +1909,7 @@ validator: %s Notifications are enabled but won\'t work until you allow notifications in your device settings. Transaction Notifications + Transfer in progress Minimum %s The minimum transaction amount is %1$s. Tron network fees for popular tokens can be higher. Staking TRX may help reduce transaction costs. @@ -2325,6 +2347,14 @@ No, send all Reduce by %s XTZ To avoid paying an increased commission the next time you top up your wallet, reduce the amount by %s XTZ + Activate Yield Mode for the first time and get up to 3x yield for your first 30 days + First month APR bonus + You get market yield + Bonus. Bonus is paid once in USDT or USDC within 14 days after the 30-day period ends. Available while promo budget lasts. Terms and conditions apply + Summary + Keep funds in Yield Mode for 30 consecutive days. Bonus is based on the yield you actually earn during that period + How to qualify + 3 × market yield for first 30 days\nMinimum eligibility: $1 of market yield accumulated for 30 days\nMaximum bonus: $50 + How much you get When Yield Mode is active, all future top-ups to this address will be supplied to Aave. You can still manage your funds freely. Your %s is supplied to Aave Supplying %1$s %2$s to Aave diff --git a/core/ui/src/main/res/drawable/img_visa_card_48x32.webp b/core/ui/src/main/res/drawable/img_visa_card_48_32.webp similarity index 100% rename from core/ui/src/main/res/drawable/img_visa_card_48x32.webp rename to core/ui/src/main/res/drawable/img_visa_card_48_32.webp diff --git a/core/ui/src/main/res/drawable/img_visa_card_inactive_48_32.xml b/core/ui/src/main/res/drawable/img_visa_card_inactive_48_32.xml new file mode 100644 index 0000000000..70bce64243 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_visa_card_inactive_48_32.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt index fcad8c9615..2b5ebd7563 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt @@ -13,6 +13,7 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.wallet.UserWallet import com.tangem.hot.sdk.model.HotWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -41,13 +42,22 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor( override val fallback: Option = none() + private val logger = TangemLogger.withTag(TAG) + @OptIn(ExperimentalCoroutinesApi::class) override fun produce(): Flow { - return walletAccountListFlowFactory.create(params.userWalletId) + val walletId = params.userWalletId + logger.i("produce() called for $walletId") + return walletAccountListFlowFactory.create(walletId) .map { accountList -> - val userWallet = userWalletsListRepository.getSyncStrict(id = params.userWalletId) - if (userWallet.isPaymentAccountSupported()) { - accountList.plus(Account.Payment(params.userWalletId)).getOrElse { throwable -> + val userWallet = userWalletsListRepository.getSyncStrict(id = walletId) + val isPaymentSupported = userWallet.isPaymentAccountSupported() + logger.i( + "produce()[$walletId]: userWallet resolved (type=${userWallet::class.simpleName}), " + + "isPaymentAccountSupported=$isPaymentSupported", + ) + if (isPaymentSupported) { + accountList.plus(Account.Payment(walletId)).getOrElse { throwable -> error("Can not combine account list and payment account status: $throwable") } } else { @@ -66,4 +76,8 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor( interface Factory : SingleAccountListProducer.Factory { override fun create(params: SingleAccountListProducer.Params): DefaultSingleAccountListProducer } + + private companion object { + const val TAG = "SingleAccountListProducer" + } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt index 49d1b90eec..33749350a7 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt @@ -12,6 +12,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.models.wallet.requireColdWallet +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.* import javax.inject.Inject @@ -47,7 +48,15 @@ internal class WalletAccountListFlowFactory @Inject constructor( return accountsResponseStoreFactory.create(userWallet.walletId).data .filterNotNull() - .filter { it.accounts.isNotEmpty() } + .filter { response -> + val hasAccounts = response.accounts.isNotEmpty() + + if (!hasAccounts) { + TangemLogger.e("Account list is empty for ${userWallet.walletId}") + } + + hasAccounts + } .distinctUntilChanged() .map { converter.convert(it) } } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt index c3a4e59b8d..156e1d8a9e 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt @@ -135,10 +135,9 @@ internal class DefaultCardCryptoCurrencyFactory( userWallet: UserWallet, networks: Set, ): Map> { - val response = walletAccountsFetcher.getSaved(userWallet.walletId) - ?: return emptyMap() + val response = walletAccountsFetcher.getSaved(userWallet.walletId) ?: return emptyMap() - val existingNetworkWithCurrencies = response.accounts.flatMapTo(hashSetOf()) { accountDTO -> + val currenciesByNetworkId = response.accounts.flatMapTo(hashSetOf()) { accountDTO -> val accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull() ?: return@flatMapTo emptySet() @@ -152,9 +151,11 @@ internal class DefaultCardCryptoCurrencyFactory( accountIndex = accountIndex, ) } - .groupBy(CryptoCurrency::network) + .groupBy { it.network.id } - return networks.associateWith { emptyList() } + existingNetworkWithCurrencies + return networks.associateWith { network -> + currenciesByNetworkId[network.id].orEmpty() + } } private suspend fun getMultiWalletCurrenciesByRawId( diff --git a/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt index 17293dccca..b7391a4c19 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.fetcher import arrow.core.Either +import arrow.core.right import com.tangem.data.networks.store.NetworksStatusesStore import com.tangem.data.networks.store.setSourceAsOnlyCache import com.tangem.data.networks.store.storeStatus @@ -45,6 +46,13 @@ internal class CommonNetworkStatusFetcher @Inject constructor( networkCurrencies: Set, xpub: String? = null, ): Either { + // Guard: empty networkCurrencies would result in NetworkStatus.Verified(amounts=emptyMap()), + // which overwrites any valid cached status and leaves all currencies in this network as Loading. + if (networkCurrencies.isEmpty()) { + TangemLogger.w("Skipping fetch for $userWalletId [${network.rawId}]: networkCurrencies is empty") + return Unit.right() + } + return Either.catchOn(dispatchers.default) { val result = withContext(dispatchers.io) { walletManagersFacade.update( diff --git a/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt index a36840920e..7fa3aab0b7 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt @@ -73,6 +73,32 @@ internal class CommonNetworkStatusFetcherTest { } } + @Test + fun `fetch skips wallet manager update when networkCurrencies is empty`() = runTest { + // Arrange + val userWalletId = UserWalletId("011") + val network = cryptoCurrencyFactory.ethereum.network + + // Act + val actual = fetcher.fetch( + userWalletId = userWalletId, + network = network, + networkCurrencies = emptySet(), + ) + + // Assert: returns success without touching the wallet manager facade — guard prevents storing + // a NetworkStatus.Verified with empty `amounts` over an existing valid cached status. + Truth.assertThat(actual).isEqualTo(Either.Right(Unit)) + + coVerify(inverse = true) { + walletManagersFacade.update( + userWalletId = any(), + network = any(), + extraTokens = any(), + ) + } + } + @ParameterizedTest @ProvideTestModels fun `fetch successfully for any result of walletManagersFacade`(model: SuccessTestModel) = runTest { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index 6ea2c55ec8..9eae098385 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -41,6 +41,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( depositAddress = value.depositAddress, fiatBalance = value.fiatBalance.toDM(), cryptoBalance = value.cryptoBalance.toDM(), + availableForWithdrawal = value.availableForWithdrawal, cards = value.cards.map { card -> PaymentAccountStatusValueDM.TangemPayCard( id = card.id, @@ -50,6 +51,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( adminDailyLimit = card.limit?.adminCardLimit?.amount, isFrozen = card.isFrozen, lastDigits = card.lastDigits, + isReissuing = card.isReissuing, ) }, ) @@ -88,6 +90,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( depositAddress = value.depositAddress, fiatBalance = value.fiatBalance.toDomain(), cryptoBalance = value.cryptoBalance.toDomain(), + availableForWithdrawal = value.availableForWithdrawal, cryptoCurrency = cryptoCurrency, cards = value.cards.map { card -> TangemPayCard( @@ -104,6 +107,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( ), isFrozen = card.isFrozen, lastDigits = card.lastDigits, + isReissuing = card.isReissuing, ) }, ) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 5a865fd24b..0c6f02473e 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -26,6 +26,13 @@ import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusProducer import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.* +import com.tangem.domain.pay.usecase.ChangeCardFrozenStateUseCase +import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase +import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase +import com.tangem.domain.pay.usecase.ReissueTangemPayCardUseCase +import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase +import com.tangem.domain.pay.usecase.StartTangemPayOrderPollingUseCase +import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase import com.tangem.domain.pay.usecase.* import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase @@ -169,5 +176,42 @@ internal interface TangemPayDataModule { ): ProduceTangemPayInitialDataUseCase { return ProduceTangemPayInitialDataUseCase(repository = repository) } + + @Provides + fun provideChangeCardFrozenStateUseCase( + cardDetailsRepository: TangemPayCardDetailsRepository, + startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, + appCoroutineScope: AppCoroutineScope, + ): ChangeCardFrozenStateUseCase { + return ChangeCardFrozenStateUseCase( + cardDetailsRepository = cardDetailsRepository, + startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase, + appCoroutineScope = appCoroutineScope, + ) + } + + @Provides + @Singleton + fun provideStartTangemPayPollingUseCase( + cardDetailsRepository: TangemPayCardDetailsRepository, + paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + ): StartTangemPayOrderPollingUseCase { + return StartTangemPayOrderPollingUseCase(cardDetailsRepository, paymentAccountStatusFetcher) + } + + @Provides + fun provideReissueTangemPayCardUseCase( + reissueCardRepository: TangemPayReissueCardRepository, + startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, + appCoroutineScope: AppCoroutineScope, + paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + ): ReissueTangemPayCardUseCase { + return ReissueTangemPayCardUseCase( + reissueCardRepository = reissueCardRepository, + startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase, + appCoroutineScope = appCoroutineScope, + paymentAccountStatusFetcher = paymentAccountStatusFetcher, + ) + } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index a337a6acb8..81159ea6a3 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -20,6 +20,7 @@ import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.pay.repository.CustomerOrderRepository import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.pay.repository.TangemPayReissueCardRepository import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.security.DeviceSecurityInfoProvider @@ -43,6 +44,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, private val eligibilityManager: TangemPayEligibilityManager, + private val reissueCardRepository: TangemPayReissueCardRepository, ) : PaymentAccountStatusFetcher { private val logger = TangemLogger.withTag(TAG) @@ -50,7 +52,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( override suspend fun invoke(params: PaymentAccountStatusFetcher.Params): Either = Either.catchOn(dispatchers.default) { val account = Account.Payment(userWalletId = params.userWalletId) - logger.i("fetch: ${params.userWalletId.stringValue}") + logger.i("invoke() start: ${params.userWalletId.stringValue}") if (deviceSecurity.isSecurityExposed()) { logger.i("fetch security info: rooted: ${deviceSecurity.isRooted}") @@ -84,11 +86,14 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( userWalletId = params.userWalletId, status = AccountStatus.Payment(account = account, value = status), ) - }.onLeft { + }.onLeft { throwable -> + logger.e("invoke() ${params.userWalletId} threw, falling back to ONLY_CACHE", throwable) paymentAccountStatusesStore.updateStatusSource( userWalletId = params.userWalletId, source = StatusSource.ONLY_CACHE, ) + }.also { result -> + logger.i("invoke() end ${params.userWalletId}: isRight=${result.isRight()}") } private suspend fun proceedHasTangemPayResult( @@ -105,7 +110,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private suspend fun fetchTangemPayAccountStatus(account: Account.Payment): PaymentAccountStatusValue { val prevResult = paymentAccountStatusesStore.getSyncOrNull(account.userWalletId) + logger.i( + "fetchTangemPayAccountStatus ${account.userWalletId}: " + + "prevResultType=${prevResult?.value?.let { it::class.simpleName } ?: "null"}", + ) if (prevResult == null || prevResult.value is PaymentAccountStatusValue.Error.Unavailable) { + logger.i("fetchTangemPayAccountStatus ${account.userWalletId}: writing Loading placeholder to store") paymentAccountStatusesStore.store( userWalletId = account.userWalletId, status = AccountStatus.Payment(account = account, value = PaymentAccountStatusValue.Loading), @@ -116,10 +126,13 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( } private suspend fun proceedWithOrderId(account: Account.Payment): PaymentAccountStatusValue { - return if (!onboardingRepository.isTangemPayInitialDataProduced(account.userWalletId)) { + val isInitial = onboardingRepository.isTangemPayInitialDataProduced(account.userWalletId) + logger.i("proceedWithOrderId ${account.userWalletId}: isTangemPayInitialDataProduced=$isInitial") + return if (!isInitial) { PaymentAccountStatusValue.Error.NotSynced } else { val orderId = onboardingRepository.getOrderId(account.userWalletId) + logger.i("proceedWithOrderId ${account.userWalletId}: orderIdPresent=${orderId != null}") if (orderId != null) { proceedWithOrderId(account = account, orderId = orderId) } else { @@ -243,7 +256,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( ) } - private fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue { + private suspend fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue { val cardInfo = this.cardInfo val productInstance = this.productInstance @@ -278,12 +291,21 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( } } - private fun convertToContentState( + private suspend fun convertToContentState( userWalletId: UserWalletId, productInstance: CustomerInfo.ProductInstance, cardInfo: CustomerInfo.CardInfo, customerId: String, ): PaymentAccountStatusValue { + val reissueOrder = reissueCardRepository.getReissueOrderInfo( + userWalletId = userWalletId, + cardId = productInstance.cardId, + ).getOrNull() + + val isReissuing = reissueOrder != null && + reissueOrder.orderStatus != OrderStatus.CANCELED && + reissueOrder.orderStatus != OrderStatus.COMPLETED + val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId) return PaymentAccountStatusValue.Loaded( source = StatusSource.ACTUAL, @@ -292,6 +314,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( depositAddress = cardInfo.depositAddress, fiatBalance = cardInfo.fiatBalance, cryptoBalance = cardInfo.cryptoBalance, + availableForWithdrawal = cardInfo.availableForWithdrawal, cryptoCurrency = cryptoCurrency, cards = listOf( TangemPayCard( @@ -304,6 +327,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( ), isFrozen = productInstance.frozenState is TangemPayCardFrozenState.Frozen, lastDigits = cardInfo.lastFourDigits, + isReissuing = isReissuing, ), ), ) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusProducer.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusProducer.kt index c2c53ae7cd..153f0d327d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusProducer.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusProducer.kt @@ -9,12 +9,17 @@ import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.pay.flow.PaymentAccountStatusProducer import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEmpty +import kotlinx.coroutines.flow.onStart + +private const val TAG = "PaymentAccountStatusProducer" internal class DefaultPaymentAccountStatusProducer @AssistedInject constructor( @Assisted private val params: PaymentAccountStatusProducer.Params, @@ -24,13 +29,20 @@ internal class DefaultPaymentAccountStatusProducer @AssistedInject constructor( ) : PaymentAccountStatusProducer { private val account = Account.Payment(userWalletId = params.userWalletId) + private val logger = TangemLogger.withTag(TAG) override val fallback: Option get() = AccountStatus.Payment(account = account, value = PaymentAccountStatusValue.Error.Unavailable).some() override fun produce(): Flow { + logger.i("[${params.userWalletId}] produce() called") return paymentAccountStatusesStore.get(userWalletId = params.userWalletId) - .onEmpty { emit(value = AccountStatus.Payment(account, PaymentAccountStatusValue.NotCreated)) } + .onStart { logger.i("[${params.userWalletId}] flow subscribed to store") } + .onEach { logger.i("[${params.userWalletId}] flow emits statusType=${it.value::class.simpleName}") } + .onEmpty { + logger.i("[${params.userWalletId}] onEmpty triggered: emitting NotCreated fallback") + emit(value = AccountStatus.Payment(account, PaymentAccountStatusValue.NotCreated)) + } .flowOn(dispatchers.default) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt index 0e85fa2f63..a2295443b3 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt @@ -7,12 +7,11 @@ import com.tangem.core.error.UniversalError import com.tangem.data.pay.util.OrderStatusConverter import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.ReissueCardRequest -import com.tangem.datasource.api.pay.models.response.OrderResponse import com.tangem.datasource.local.visa.TangemPayReissueCardStore import com.tangem.domain.models.pay.TangemPayReissueCardFee import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.model.OrderStatus -import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayReissueCardRepository import com.tangem.domain.visa.error.VisaApiError import com.tangem.utils.coroutines.runSuspendCatching @@ -22,6 +21,7 @@ internal class DefaultReissueCardRepository @Inject constructor( private val tangemPayApi: TangemPayApi, private val requestHelper: TangemPayRequestPerformer, private val tangemPayReissueCardStore: TangemPayReissueCardStore, + private val cardDetailsRepository: TangemPayCardDetailsRepository, ) : TangemPayReissueCardRepository { override suspend fun getReissueCardFee(userWalletId: UserWalletId): Either = @@ -53,14 +53,14 @@ internal class DefaultReissueCardRepository @Inject constructor( override suspend fun reissueCard( userWalletId: UserWalletId, cardId: String, - ): Either = either { + ): Either = either { val response = requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.reissueCard( authHeader = authHeader, body = ReissueCardRequest(cardId = cardId), ) }.bind() - TangemPayReissueOrderInfo( + TangemPayOrderInfo( orderId = response.result.orderId, orderStatus = OrderStatusConverter.convert(response.result.status), ) @@ -77,28 +77,14 @@ internal class DefaultReissueCardRepository @Inject constructor( override suspend fun getReissueOrderInfo( userWalletId: UserWalletId, cardId: String, - ): Either = either { + ): Either = either { val orderId = runSuspendCatching { tangemPayReissueCardStore.getOrderId(cardId) }.getOrNull() if (orderId == null) { return null.right() } - val order = requestHelper.performRequest(userWalletId) { authHeader -> - tangemPayApi.getOrder(authHeader, orderId) - }.bind() - - val result = order.result ?: raise(VisaApiError.Unspecified) - - TangemPayReissueOrderInfo( - orderId = result.id, - orderStatus = when (result.status) { - OrderResponse.Result.Status.NEW -> OrderStatus.NEW - OrderResponse.Result.Status.PROCESSING -> OrderStatus.PROCESSING - OrderResponse.Result.Status.COMPLETED -> OrderStatus.COMPLETED - OrderResponse.Result.Status.CANCELED -> OrderStatus.CANCELED - }, - ) + cardDetailsRepository.getOrderInfo(userWalletId, orderId).bind() } private companion object { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt index 97657f4c0d..cb621a3e3d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt @@ -3,6 +3,7 @@ package com.tangem.data.pay.repository import arrow.core.Either import arrow.core.left import arrow.core.raise.catch +import arrow.core.raise.either import arrow.core.right import com.tangem.core.error.UniversalError import com.tangem.data.pay.util.RainCryptoUtil @@ -22,22 +23,17 @@ import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayCardDetails +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.model.TangemPayCardFrozenState -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds private const val TAG = "TangemPay: CardDetailsRepository" @@ -51,12 +47,8 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( private val storage: TangemPayStorage, private val cardFrozenStateStore: TangemPayCardFrozenStateStore, private val errorConverter: TangemPayErrorConverter, - private val pollingScope: AppCoroutineScope, ) : TangemPayCardDetailsRepository { - private val pollingJobs = mutableMapOf() - private val storePollingMutex = Mutex() - override suspend fun getCardBalance(userWalletId: UserWalletId): Either { return catch( block = { @@ -201,123 +193,43 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( override suspend fun freezeCard( userWalletId: UserWalletId, cardId: String, - ): Either { - cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending) - return requestHelper.performRequest(userWalletId) { + ): Either = either { + val response = requestHelper.performRequest(userWalletId) { tangemPayApi.freezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId)) - }.onLeft { - cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Unfrozen) - }.map { response -> - val state = when (response.result?.status) { - FreezeUnfreezeCardResponse.Status.COMPLETED -> TangemPayCardFrozenState.Frozen - FreezeUnfreezeCardResponse.Status.NEW, - FreezeUnfreezeCardResponse.Status.PROCESSING, - -> TangemPayCardFrozenState.Pending - FreezeUnfreezeCardResponse.Status.CANCELED, - null, - -> TangemPayCardFrozenState.Unfrozen - } - if (state == TangemPayCardFrozenState.Pending) { - startOrderIdPolling( - userWalletId = userWalletId, - cardId = cardId, - orderId = response.result?.orderId, - isFreeze = true, - ) - } - cardFrozenStateStore.store(cardId, state) + }.bind() - state - } + val result = response.result ?: raise(VisaApiError.Unspecified) + + TangemPayOrderInfo( + orderId = result.orderId, + orderStatus = when (result.status) { + FreezeUnfreezeCardResponse.Status.NEW -> OrderStatus.NEW + FreezeUnfreezeCardResponse.Status.PROCESSING -> OrderStatus.PROCESSING + FreezeUnfreezeCardResponse.Status.COMPLETED -> OrderStatus.COMPLETED + FreezeUnfreezeCardResponse.Status.CANCELED -> OrderStatus.CANCELED + }, + ) } override suspend fun unfreezeCard( userWalletId: UserWalletId, cardId: String, - ): Either { - cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending) - return requestHelper.performRequest(userWalletId) { + ): Either = either { + val response = requestHelper.performRequest(userWalletId) { tangemPayApi.unfreezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId)) - }.onLeft { - cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Frozen) - }.map { response -> - val state = when (response.result?.status) { - FreezeUnfreezeCardResponse.Status.COMPLETED -> TangemPayCardFrozenState.Unfrozen - FreezeUnfreezeCardResponse.Status.NEW, - FreezeUnfreezeCardResponse.Status.PROCESSING, - -> TangemPayCardFrozenState.Pending - FreezeUnfreezeCardResponse.Status.CANCELED, - null, - -> TangemPayCardFrozenState.Frozen - } - if (state == TangemPayCardFrozenState.Pending) { - startOrderIdPolling( - userWalletId = userWalletId, - cardId = cardId, - orderId = response.result?.orderId, - isFreeze = false, - ) - } - cardFrozenStateStore.store(cardId, state) + }.bind() - state - } - } + val result = response.result ?: raise(VisaApiError.Unspecified) - private suspend fun startOrderIdPolling( - userWalletId: UserWalletId, - cardId: String, - orderId: String?, - isFreeze: Boolean, - ) { - if (orderId.isNullOrEmpty()) return - storePollingMutex.withLock { - if (pollingJobs.containsKey(orderId)) return - val pollingJob = pollingScope.launch { - try { - var retryCount = 0 - while (isActive && pollingJobs.containsKey(orderId)) { - delay(duration = 5.seconds) - - val orderStatus = requestHelper.performRequest(userWalletId) { authHeader -> - tangemPayApi.getOrder(authHeader, orderId) - } - - orderStatus.onRight { response -> - val status = response.result?.status - if (status == Status.COMPLETED || status == Status.CANCELED) { - // Remove from jobs - pollingJobs.remove(key = orderId) - - // Final card state - val finalState = when { - status == Status.COMPLETED && isFreeze - -> TangemPayCardFrozenState.Frozen - status == Status.COMPLETED && !isFreeze - -> TangemPayCardFrozenState.Unfrozen - else -> return@launch - } - - cardFrozenStateStore.store(cardId, finalState) - } - }.onLeft { error -> - TangemLogger.e("error ${error.errorCode}") - // stop retrying after 3 errors - if (retryCount > MAX_POLLING_RETRIES) { - pollingJobs.remove(key = orderId) - } - } - retryCount++ - } - } catch (e: Exception) { - TangemLogger.e("Error", e) - storePollingMutex.withLock { - pollingJobs.remove(orderId) - } - } - } - pollingJobs[orderId] = pollingJob - } + TangemPayOrderInfo( + orderId = result.orderId, + orderStatus = when (result.status) { + FreezeUnfreezeCardResponse.Status.NEW -> OrderStatus.NEW + FreezeUnfreezeCardResponse.Status.PROCESSING -> OrderStatus.PROCESSING + FreezeUnfreezeCardResponse.Status.COMPLETED -> OrderStatus.COMPLETED + FreezeUnfreezeCardResponse.Status.CANCELED -> OrderStatus.CANCELED + }, + ) } override suspend fun updateCardDisplayName( @@ -376,6 +288,31 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( return cardFrozenStateStore.getSyncOrNull(cardId) } + override suspend fun setCardFrozenState(cardId: String, state: TangemPayCardFrozenState) { + cardFrozenStateStore.store(cardId, state) + } + + override suspend fun getOrderInfo( + userWalletId: UserWalletId, + orderId: String, + ): Either = either { + val order = requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.getOrder(authHeader, orderId) + }.bind() + + val result = order.result ?: raise(VisaApiError.Unspecified) + + TangemPayOrderInfo( + orderId = result.id, + orderStatus = when (result.status) { + Status.NEW -> OrderStatus.NEW + Status.PROCESSING -> OrderStatus.PROCESSING + Status.COMPLETED -> OrderStatus.COMPLETED + Status.CANCELED -> OrderStatus.CANCELED + }, + ) + } + private suspend fun getPublicKeyBase64(): String { val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment return when (env) { @@ -395,8 +332,4 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( TangemLogger.withTag(TAG).e("Error", throwable) return errorConverter.convert(throwable).left() } - - private companion object { - const val MAX_POLLING_RETRIES = 3 - } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt index c39190f6d8..fd60f231b4 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt @@ -16,11 +16,15 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch internal typealias WalletIdWithPaymentStatus = Map internal typealias WalletIdWithPaymentStatusDM = Map +private const val TAG = "PaymentAccountStatusesStore" + /** * Store for payment account statuses with dual storage (runtime + persistence). * @@ -34,10 +38,18 @@ internal class PaymentAccountStatusesStore( scope: AppCoroutineScope, ) { + private val logger = TangemLogger.withTag(TAG) + init { scope.launch { + logger.i("init: loading cached payment statuses from persistence") try { - val cachedStatuses = persistenceDataStore.data.firstOrNull() ?: return@launch + val cachedStatuses = persistenceDataStore.data.firstOrNull() + if (cachedStatuses == null) { + logger.i("init: persistence empty (firstOrNull == null), runtimeStore stays empty") + return@launch + } + logger.i("init: loaded ${cachedStatuses.size} cached entries; populating runtimeStore") runtimeStore.store( value = cachedStatuses.mapValues { (rawUserWalletId, statusDM) -> val account = Account.Payment(userWalletId = UserWalletId(rawUserWalletId)) @@ -45,19 +57,33 @@ internal class PaymentAccountStatusesStore( AccountStatus.Payment(account = account, value = statusValue) }, ) + logger.i("init: runtimeStore populated with ${cachedStatuses.size} entries") } catch (e: Exception) { runSuspendCatching { persistenceDataStore.updateData { emptyMap() } } - TangemLogger.e("Error while loading cached payment account statuses", e) + logger.e("Error while loading cached payment account statuses", e) } } } fun get(userWalletId: UserWalletId): Flow { - return runtimeStore.get().mapNotNull { it[userWalletId.stringValue] } + return runtimeStore.get() + .onStart { logger.i("get($userWalletId): subscribed to runtimeStore") } + .onEach { map -> + logger.i( + "get($userWalletId): runtimeStore emitted map size=${map.size}, " + + "hasEntry=${map.containsKey(userWalletId.stringValue)}", + ) + } + .mapNotNull { it[userWalletId.stringValue] } + .onEach { status -> + logger.i("get($userWalletId): emitting statusType=${status.value::class.simpleName}") + } } suspend fun getSyncOrNull(userWalletId: UserWalletId): AccountStatus.Payment? { - return runtimeStore.getSyncOrNull()?.get(userWalletId.stringValue) + val result = runtimeStore.getSyncOrNull()?.get(userWalletId.stringValue) + logger.i("getSyncOrNull($userWalletId) valueType=${result?.value?.let { it::class.simpleName } ?: "null"}") + return result } suspend fun updateStatusSource(userWalletId: UserWalletId, source: StatusSource) { @@ -71,6 +97,7 @@ internal class PaymentAccountStatusesStore( } suspend fun store(userWalletId: UserWalletId, status: AccountStatus.Payment) { + logger.i("store($userWalletId): valueType=${status.value::class.simpleName}") coroutineScope { launch { storeInRuntime(userWalletId = userWalletId, status = status) } launch { storeInPersistence(userWalletId = userWalletId, status = status.value) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt index cc54b78c63..9787d15b47 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt @@ -15,6 +15,7 @@ import com.tangem.domain.pay.model.CustomerInfo.ProductInstance import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.Status import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero internal object CustomerInfoConverter : Converter { @Suppress("ComplexCondition") @@ -33,6 +34,7 @@ internal object CustomerInfoConverter : Converter = real.freezeCard(userWalletId, cardId) + ): Either = real.freezeCard(userWalletId, cardId) override suspend fun unfreezeCard( userWalletId: UserWalletId, cardId: String, - ): Either = real.unfreezeCard(userWalletId, cardId) + ): Either = real.unfreezeCard(userWalletId, cardId) override fun cardFrozenState(cardId: String): Flow = real.cardFrozenState(cardId) @@ -78,6 +79,14 @@ internal class MockAwareTangemPayCardDetailsRepository @Inject constructor( override suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? = real.cardFrozenStateSync(cardId) + override suspend fun setCardFrozenState(cardId: String, state: TangemPayCardFrozenState) = + real.setCardFrozenState(cardId, state) + + override suspend fun getOrderInfo( + userWalletId: UserWalletId, + orderId: String, + ): Either = real.getOrderInfo(userWalletId, orderId) + override suspend fun updateCardDisplayName( cardId: String, userWalletId: UserWalletId, diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt index 0bee94b52c..3f24678ad7 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt @@ -7,6 +7,7 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.extensions.formatHex import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.TxSentFrom import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.Basic.TransactionSent.MemoType @@ -91,6 +92,7 @@ internal class WcEthSendTransactionUseCase @AssistedInject constructor( token = network.currencySymbol, feeType = null, feeToken = network.currencySymbol, + feeAssetType = AnalyticsParam.FeeAssetType.Coin, ), memoType = MemoType.Null, ), diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt index a2bf732b26..1da0c170d6 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt @@ -46,6 +46,7 @@ import com.tangem.domain.tokens.operations.TokenListFactory import com.tangem.domain.tokens.operations.TotalFiatBalanceCalculator import com.tangem.hot.sdk.model.HotWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -73,7 +74,7 @@ import java.math.BigDecimal [REDACTED_AUTHOR] */ // TODO: Move to :data:account:status [REDACTED_JIRA] -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @OptIn(ExperimentalCoroutinesApi::class) internal class DefaultSingleAccountStatusListProducer @AssistedInject constructor( @Assisted private val params: SingleAccountStatusListProducer.Params, @@ -90,17 +91,29 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo private val analyticsExceptionHandler: AnalyticsExceptionHandler, ) : SingleAccountStatusListProducer { + private val logger = TangemLogger.withTag(TAG) + override val fallback: Option = none() override fun produce(): Flow { + logger.i("produce() called for ${params.userWalletId}") return flattenFlow() + .onEach { list -> + logger.i( + "produce()[${params.userWalletId}] emit: accounts=${list.accountStatuses.size}, " + + "currencies=${list.flattenCurrencies().size}, " + + "totalFiatType=${list.totalFiatBalance::class.simpleName}", + ) + } .flowOn(dispatchers.default) } @Suppress("LongMethod") private fun flattenFlow(): Flow = channelFlow { val walletId = params.userWalletId + logger.i("flattenFlow[$walletId]: start") val userWallet = userWalletsListRepository.getSyncStrict(id = params.userWalletId) + logger.i("flattenFlow[$walletId]: userWallet resolved (type=${userWallet::class.simpleName})") val flattenCurrency: MutableSharedFlow> = MutableSharedFlow( replay = 1, @@ -108,11 +121,20 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo ) val accountListFlow: StateFlow = singleAccountListSupplier(walletId) - .onEach { accountList -> flattenCurrency.tryEmit(accountList.flattenMapCurrencies()) } + .onEach { accountList -> + logger.i( + "flattenFlow[$walletId]: accountList emitted accounts=${accountList.accounts.size}, " + + "currencies=${accountList.flattenMapCurrencies().size}", + ) + flattenCurrency.tryEmit(accountList.flattenMapCurrencies()) + } .stateIn(this) val hasCachedNetworks = networksRepository.hasCachedStatuses(walletId) + logger.i("flattenFlow[$walletId]: hasCachedNetworks=$hasCachedNetworks") if (!hasCachedNetworks) { + val initialAccounts = accountListFlow.value.accounts.size + logger.i("flattenFlow[$walletId]: sending Loading placeholder (accounts=$initialAccounts)") send(createLoadingAccountStatusList(accountListFlow.value)) } @@ -121,11 +143,19 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo flattenCurrency = flattenCurrency, ) - if (userWallet.isPaymentAccountSupported()) { + val isPaymentSupported = userWallet.isPaymentAccountSupported() + logger.i("flattenFlow[$walletId]: isPaymentAccountSupported=$isPaymentSupported") + if (isPaymentSupported) { combineWithPaymentAccount( accountListFlow = accountListFlow, cryptoCurrencyStatusFlow = cryptoCurrencyStatusFlow, - paymentAccountStatusFlow = paymentAccountStatusSupplier.invoke(userWalletId = params.userWalletId), + paymentAccountStatusFlow = paymentAccountStatusSupplier.invoke(userWalletId = params.userWalletId) + .onEach { paymentStatus -> + logger.i( + "flattenFlow[$walletId]: paymentAccountStatus emitted " + + "valueType=${paymentStatus.value::class.simpleName}", + ) + }, ) } else { combineWithoutPaymentAccount( @@ -146,6 +176,12 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo flow2 = cryptoCurrencyStatusFlow, flow3 = paymentAccountStatusFlow, transform = { accountList, currencyStatusMap, paymentAccountStatus -> + logger.i( + "combineWithPayment[${params.userWalletId}] transform: " + + "accounts=${accountList.accounts.size}, " + + "currencyStatusMap=${currencyStatusMap.size}, " + + "paymentType=${paymentAccountStatus.value::class.simpleName}", + ) val accountStatuses = accountList.accounts.map { account -> when (account) { is Account.Payment -> paymentAccountStatus @@ -192,6 +228,11 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo flow = accountListFlow, flow2 = cryptoCurrencyStatusFlow, transform = { accountList, currencyStatusMap -> + logger.i( + "combineWithoutPayment[${params.userWalletId}] transform: " + + "accounts=${accountList.accounts.size}, " + + "currencyStatusMap=${currencyStatusMap.size}", + ) val accountStatuses = accountList.accounts .filterIsInstance() .map { account -> @@ -242,14 +283,18 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo ): Flow> { val walletId = userWallet.walletId val networkStatusFlow: SharedFlow> = networkStatusFlow(walletId) + .onEach { logger.i("flattenCurrencyStatusFlow[$walletId]: networkStatuses emitted size=${it.size}") } .shareIn(this, started = SharingStarted.Eagerly, replay = 1) val stakingBalanceFlow: SharedFlow>> = stakingFlow(userWallet) + .onEach { logger.i("flattenCurrencyStatusFlow[$walletId]: stakingBalances emitted stakingIds=${it.size}") } .shareIn(this, started = SharingStarted.Eagerly, replay = 1) val quoteStatusFlow: SharedFlow> = quoteStatusFlow() + .onEach { logger.i("flattenCurrencyStatusFlow[$walletId]: quoteStatuses emitted size=${it.size}") } .shareIn(this, started = SharingStarted.Eagerly, replay = 1) return flattenCurrency .distinctUntilChanged() + .onEach { logger.i("flattenCurrencyStatusFlow[$walletId]: flattenCurrency emitted size=${it.size}") } .flatMapLatest { a -> combine( flow = networkStatusFlow, @@ -264,6 +309,15 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo ) } .distinctUntilChanged() + .onEach { box -> + logger.i( + "flattenCurrencyStatusFlow[$walletId]: box emitted " + + "currencies=${box.flattenCurrencyMap.size}, " + + "networks=${box.networkStatusMap.size}, " + + "stakings=${box.stakingBalanceMap.size}, " + + "quotes=${box.quoteStatusMap.size}", + ) + } .map { box -> val flattenCurrencyMap: Map = box.flattenCurrencyMap val networkStatusMap: Map = box.networkStatusMap @@ -402,4 +456,8 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo interface Factory : SingleAccountStatusListProducer.Factory { override fun create(params: SingleAccountStatusListProducer.Params): DefaultSingleAccountStatusListProducer } + + private companion object { + const val TAG = "SingleAccountStatusListProducer" + } } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt index a05eb27cd4..fc345758ff 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.pay.flow.PaymentAccountStatusProducer import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* @@ -27,27 +28,51 @@ class IsAccountsModeEnabledUseCase( @OptIn(ExperimentalCoroutinesApi::class) operator fun invoke(): Flow { + TangemLogger.i("$TAG: invoke() started") + val cryptoMode = multiAccountListSupplier.invoke() - .map { lists -> lists.any { it.hasMultipleCryptoPortfolios() } } + .onEach { lists -> + TangemLogger.i("$TAG: multiAccountListSupplier emitted ${lists.size} lists (cryptoMode branch)") + } + .map { lists -> + val isCryptoMode = lists.any { it.hasMultipleCryptoPortfolios() } + TangemLogger.i("$TAG: cryptoMode=$isCryptoMode") + isCryptoMode + } val paymentMode = multiAccountListSupplier.invoke().flatMapLatest { lists -> val walletIdsWithPayment = lists.mapNotNull { list -> if (list.accounts.any { it is Account.Payment }) list.userWalletId else null } + TangemLogger.i("$TAG: walletIdsWithPayment=${walletIdsWithPayment.size}") if (walletIdsWithPayment.isEmpty()) { flowOf(false) } else { val flows = walletIdsWithPayment.map { walletId -> paymentAccountStatusSupplier.invoke(walletId) + .onEach { status -> + TangemLogger.i("$TAG: paymentStatus for $walletId = ${status.value::class.simpleName}") + } .map { it.value.isActivePayment() } - .onStart { emit(false) } + .onStart { + TangemLogger.i("$TAG: paymentAccountStatusSupplier onStart for $walletId") + emit(false) + } + } + combine(flows) { results -> + val isPaymentMode = results.any { it } + TangemLogger.i("$TAG: paymentMode combine result=$isPaymentMode (${results.toList()})") + isPaymentMode } - combine(flows) { results -> results.any { it } } } } - return combine(cryptoMode, paymentMode) { crypto, payment -> crypto || payment } + return combine(cryptoMode, paymentMode) { crypto, payment -> + val isEnabled = crypto || payment + TangemLogger.i("$TAG: final combine crypto=$crypto, payment=$payment, result=$isEnabled") + isEnabled + } .distinctUntilChanged() } @@ -89,5 +114,6 @@ class IsAccountsModeEnabledUseCase( private companion object { const val PAYMENT_STATUS_SYNC_TIMEOUT_MS = 1_000L + const val TAG = "IsAccountsModeEnabledUseCase" } } \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/analytics/IntroductionProcess.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/analytics/IntroductionProcess.kt index 2b8b3299ae..2810aedfb8 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/analytics/IntroductionProcess.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/analytics/IntroductionProcess.kt @@ -22,20 +22,27 @@ sealed class IntroductionProcess( * Tracks opening the Create Wallet introduction screen. */ class CreateWalletIntroScreenOpened( + screenType: ScreenType, referralId: String?, ) : IntroductionProcess( event = "Create Wallet Intro Screen Opened", params = buildMap { + put(AnalyticsParam.SCREEN_TYPE, screenType.value) putAll(getReferralParams(referralId)) }, - ), CriticalEvent + ), CriticalEvent { + enum class ScreenType(val value: String) { + Cold("Cold Wallet"), + Hot("Mobile Wallet"), + } + } class ButtonScanCard( val source: AnalyticsParam.ScreensSources, ) : IntroductionProcess( event = "Button - Scan Card", params = mapOf( - AnalyticsParam.Key.SOURCE to source.value, + AnalyticsParam.SOURCE to source.value, ), ) } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt index 62c8c4481b..756bb4b54a 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt @@ -138,6 +138,7 @@ sealed class PaymentAccountStatusValue { * @property depositAddress The address for deposits, if available. * @property fiatBalance The fiat balance details. * @property cryptoBalance The crypto balance details. + * @property availableForWithdrawal The crypto amount currently available for withdrawal/swap (excludes pending/locked funds). * @property cards The list of user's cards. */ @Serializable @@ -148,13 +149,14 @@ sealed class PaymentAccountStatusValue { val depositAddress: String?, val fiatBalance: FiatBalance, val cryptoBalance: CryptoBalance, + val availableForWithdrawal: SerializedBigDecimal, val cryptoCurrency: CryptoCurrency.Token, val cards: List, ) : PaymentAccountStatusValue() { val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( currency = cryptoCurrency, value = CryptoCurrencyStatus.Loaded( - amount = cryptoBalance.balance, + amount = availableForWithdrawal, fiatAmount = fiatBalance.availableBalance, fiatRate = BigDecimal.ONE, priceChange = BigDecimal.ZERO, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt index 115e387985..621ef7e2d0 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt @@ -22,4 +22,5 @@ data class TangemPayCard( @SerialName("limit") val limit: TangemPayCardLimitData?, @SerialName("is_frozen") val isFrozen: Boolean, @SerialName("last_digits") val lastDigits: String, + @SerialName("is_reissuing") val isReissuing: Boolean, ) \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt index 8b4e35aa0d..65407e56ca 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt @@ -46,11 +46,13 @@ sealed class StakingAnalyticsEvent( data class StakeInProgressScreenOpened( val validator: String, val action: StakingActionType, + val feeAssetType: AnalyticsParam.FeeAssetType, ) : StakingAnalyticsEvent( event = "Stake In Progress Screen Opened", params = mapOf( "Validator" to validator, "Action" to action.asAnalyticName, + AnalyticsParam.Key.FEE_ASSET_TYPE to feeAssetType.value, ), ), AppsFlyerIncludedEvent diff --git a/domain/visa/build.gradle.kts b/domain/visa/build.gradle.kts index 1ba3d56862..4ec426e295 100644 --- a/domain/visa/build.gradle.kts +++ b/domain/visa/build.gradle.kts @@ -9,6 +9,10 @@ android { namespace = "com.tangem.domain.visa" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Project - Core */ api(projects.core.pagination) @@ -35,4 +39,12 @@ dependencies { implementation(deps.moshi) implementation(deps.moshi.kotlin) ksp(deps.moshi.kotlin.codegen) + + /** Tests */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(projects.common.test) } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index a2bd0d3656..f2ebca73b3 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -83,5 +83,6 @@ data class CustomerInfo( val isPinSet: Boolean, val fiatBalance: PaymentAccountStatusValue.FiatBalance, val cryptoBalance: PaymentAccountStatusValue.CryptoBalance, + val availableForWithdrawal: BigDecimal, ) } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt index fd3d717107..035572167a 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt @@ -5,4 +5,7 @@ enum class OrderStatus { PROCESSING, COMPLETED, CANCELED, -} \ No newline at end of file +} + +val OrderStatus.isFinalStatus + get() = this == OrderStatus.COMPLETED || this == OrderStatus.CANCELED \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayOrderInfo.kt similarity index 71% rename from domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt rename to domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayOrderInfo.kt index 45945f7204..eebc510bd5 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayOrderInfo.kt @@ -1,6 +1,6 @@ package com.tangem.domain.pay.model -data class TangemPayReissueOrderInfo( +data class TangemPayOrderInfo( val orderId: String, val orderStatus: OrderStatus, ) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt index 6778223272..b3eed00dba 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt @@ -7,6 +7,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayCardDetails +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.visa.model.TangemPayCardFrozenState import kotlinx.coroutines.flow.Flow @@ -24,14 +25,12 @@ interface TangemPayCardDetailsRepository { suspend fun setAddToWalletAsDone(userWalletId: UserWalletId): Either - suspend fun freezeCard(userWalletId: UserWalletId, cardId: String): Either - suspend fun unfreezeCard( - userWalletId: UserWalletId, - cardId: String, - ): Either + suspend fun freezeCard(userWalletId: UserWalletId, cardId: String): Either + suspend fun unfreezeCard(userWalletId: UserWalletId, cardId: String): Either fun cardFrozenState(cardId: String): Flow suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? + suspend fun setCardFrozenState(cardId: String, state: TangemPayCardFrozenState) suspend fun updateCardDisplayName( cardId: String, @@ -44,4 +43,6 @@ interface TangemPayCardDetailsRepository { userWalletId: UserWalletId, limit: String, ): Either + + suspend fun getOrderInfo(userWalletId: UserWalletId, orderId: String): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt index 3f756df4ee..1487f57359 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt @@ -4,19 +4,19 @@ import arrow.core.Either import com.tangem.core.error.UniversalError import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.pay.TangemPayReissueCardFee -import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.visa.error.VisaApiError interface TangemPayReissueCardRepository { suspend fun getReissueCardFee(userWalletId: UserWalletId): Either - suspend fun reissueCard(userWalletId: UserWalletId, cardId: String): Either + suspend fun reissueCard(userWalletId: UserWalletId, cardId: String): Either suspend fun storeReissueOrderId(cardId: String, orderId: String): Either suspend fun getReissueOrderInfo( userWalletId: UserWalletId, cardId: String, - ): Either + ): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCase.kt new file mode 100644 index 0000000000..3aeb3e865f --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCase.kt @@ -0,0 +1,45 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.utils.coroutines.AppCoroutineScope +import kotlinx.coroutines.async + +class ChangeCardFrozenStateUseCase( + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, + private val appCoroutineScope: AppCoroutineScope, +) { + suspend operator fun invoke( + userWalletId: UserWalletId, + cardId: String, + isFreezing: Boolean, + ): Either { + val successState = if (isFreezing) TangemPayCardFrozenState.Frozen else TangemPayCardFrozenState.Unfrozen + val failState = if (isFreezing) TangemPayCardFrozenState.Unfrozen else TangemPayCardFrozenState.Frozen + return either { + cardDetailsRepository.setCardFrozenState(cardId, TangemPayCardFrozenState.Pending) + + val order = if (isFreezing) { + cardDetailsRepository.freezeCard(userWalletId, cardId).bind() + } else { + cardDetailsRepository.unfreezeCard(userWalletId, cardId).bind() + } + + val isCompleted = appCoroutineScope.async { + val isCompleted = startTangemPayOrderPollingUseCase(order, userWalletId) + cardDetailsRepository.setCardFrozenState(cardId, if (isCompleted) successState else failState) + isCompleted + }.await() + + if (!isCompleted) raise(VisaApiError.Unspecified) + }.onLeft { + cardDetailsRepository.setCardFrozenState(cardId, failState) + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ReissueTangemPayCardUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ReissueTangemPayCardUseCase.kt new file mode 100644 index 0000000000..7420980320 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ReissueTangemPayCardUseCase.kt @@ -0,0 +1,33 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.repository.TangemPayReissueCardRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.utils.coroutines.AppCoroutineScope +import kotlinx.coroutines.launch + +class ReissueTangemPayCardUseCase( + private val reissueCardRepository: TangemPayReissueCardRepository, + private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + private val appCoroutineScope: AppCoroutineScope, +) { + suspend operator fun invoke(userWalletId: UserWalletId, cardId: String): Either = either { + val order = reissueCardRepository.reissueCard(userWalletId, cardId).bind() + + if (order.orderStatus == OrderStatus.CANCELED) { + raise(VisaApiError.Unspecified) + } + + reissueCardRepository.storeReissueOrderId(cardId, order.orderId) + paymentAccountStatusFetcher.invoke(userWalletId) + + appCoroutineScope.launch { + startTangemPayOrderPollingUseCase(order, userWalletId) + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt new file mode 100644 index 0000000000..50ea3fdfd9 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt @@ -0,0 +1,35 @@ +package com.tangem.domain.pay.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.model.isFinalStatus +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import kotlinx.coroutines.delay + +class StartTangemPayOrderPollingUseCase( + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, +) { + suspend operator fun invoke(order: TangemPayOrderInfo, userWalletId: UserWalletId): Boolean { + while (true) { + val newOrder = if (order.orderStatus.isFinalStatus) { + order + } else { + cardDetailsRepository.getOrderInfo(userWalletId, order.orderId).getOrNull() + } + + if (newOrder != null && newOrder.orderStatus.isFinalStatus) { + paymentAccountStatusFetcher.invoke(userWalletId) + return newOrder.orderStatus == OrderStatus.COMPLETED + } + + delay(POLLING_DELAY) + } + } + + companion object { + private const val POLLING_DELAY = 3000L + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCaseTest.kt new file mode 100644 index 0000000000..67fd67b1a5 --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCaseTest.kt @@ -0,0 +1,107 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.TestAppCoroutineScope +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +internal class ChangeCardFrozenStateUseCaseTest { + + private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk(relaxUnitFun = true) + private val startPollingUseCase: StartTangemPayOrderPollingUseCase = mockk() + + @Test + fun `GIVEN freezeCard fails WHEN invoke with isFreezing=true THEN sets Pending then Unfrozen and returns Left`() = + runTest { + val useCase = createUseCase() + coEvery { + cardDetailsRepository.freezeCard(USER_WALLET_ID, CARD_ID) + } returns VisaApiError.Unspecified.left() + + val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = true) + + assertThat(result.isLeft()).isTrue() + coVerifyOrder { + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending) + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Unfrozen) + } + coVerify(exactly = 0) { startPollingUseCase(any(), any()) } + } + + @Test + fun `GIVEN unfreezeCard fails WHEN invoke with isFreezing=false THEN sets Pending then Frozen and returns Left`() = + runTest { + val useCase = createUseCase() + coEvery { + cardDetailsRepository.unfreezeCard(USER_WALLET_ID, CARD_ID) + } returns VisaApiError.Unspecified.left() + + val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = false) + + assertThat(result.isLeft()).isTrue() + coVerifyOrder { + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending) + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Frozen) + } + coVerify(exactly = 0) { startPollingUseCase(any(), any()) } + } + + @Test + fun `GIVEN freeze succeeds and order COMPLETED WHEN invoke with isFreezing=true THEN sets Frozen and returns Right`() = + runTest { + val useCase = createUseCase() + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED) + coEvery { cardDetailsRepository.freezeCard(USER_WALLET_ID, CARD_ID) } returns order.right() + coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true + + val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = true) + + assertThat(result.isRight()).isTrue() + coVerifyOrder { + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending) + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Frozen) + } + } + + @Test + fun `GIVEN unfreeze succeeds and order COMPLETED WHEN invoke with isFreezing=false THEN sets Unfrozen and returns Right`() = + runTest { + val useCase = createUseCase() + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED) + coEvery { cardDetailsRepository.unfreezeCard(USER_WALLET_ID, CARD_ID) } returns order.right() + coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true + + val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = false) + + assertThat(result.isRight()).isTrue() + coVerifyOrder { + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending) + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Unfrozen) + } + } + + private fun TestScope.createUseCase() = ChangeCardFrozenStateUseCase( + cardDetailsRepository = cardDetailsRepository, + startTangemPayOrderPollingUseCase = startPollingUseCase, + appCoroutineScope = TestAppCoroutineScope(this), + ) + + private companion object { + val USER_WALLET_ID = UserWalletId("aabbcc112233") + const val CARD_ID = "card-test-id" + const val ORDER_ID = "order-test-1" + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt new file mode 100644 index 0000000000..86ab6a5bf2 --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt @@ -0,0 +1,119 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.error.VisaApiError +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +internal class StartTangemPayOrderPollingUseCaseTest { + + private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk() + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk() + + private val useCase = StartTangemPayOrderPollingUseCase( + cardDetailsRepository = cardDetailsRepository, + paymentAccountStatusFetcher = paymentAccountStatusFetcher, + ) + + @Test + fun `GIVEN order already COMPLETED WHEN invoke THEN returns true without polling`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isTrue() + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + coVerify(exactly = 0) { cardDetailsRepository.getOrderInfo(any(), any()) } + } + + @Test + fun `GIVEN order already CANCELED WHEN invoke THEN returns false without polling`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.CANCELED) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isFalse() + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + coVerify(exactly = 0) { cardDetailsRepository.getOrderInfo(any(), any()) } + } + + @Test + fun `GIVEN processing order WHEN poll returns COMPLETED THEN returns true and fetches status`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING) + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returns TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED).right() + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isTrue() + coVerify(exactly = 1) { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) } + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + } + + @Test + fun `GIVEN processing order WHEN poll returns CANCELED THEN returns false and fetches status`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING) + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returns TangemPayOrderInfo(ORDER_ID, OrderStatus.CANCELED).right() + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isFalse() + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + } + + @Test + fun `GIVEN new order WHEN getOrderInfo fails once then returns COMPLETED THEN returns true after two polls`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.NEW) + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returnsMany listOf( + VisaApiError.Unspecified.left(), + TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED).right(), + ) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isTrue() + coVerify(exactly = 2) { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) } + } + + @Test + fun `GIVEN processing order WHEN multiple non-final polls then COMPLETED THEN returns true after all polls`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING) + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returnsMany listOf( + TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING).right(), + TangemPayOrderInfo(ORDER_ID, OrderStatus.NEW).right(), + TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED).right(), + ) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isTrue() + coVerify(exactly = 3) { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) } + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + } + + private companion object { + val USER_WALLET_ID = UserWalletId("aabbcc112233") + const val ORDER_ID = "order-test-1" + } +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt index 7097a4b00b..d7a799f3d5 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt @@ -157,6 +157,7 @@ sealed class WcAnalyticEvents( network: Network, securityStatus: CheckDAppResult, accountDerivation: Int?, + feeAssetType: AnalyticsParam.FeeAssetType = AnalyticsParam.FeeAssetType.Coin, ) : WcAnalyticEvents( event = "Signature Request Handled", params = buildMap { @@ -168,6 +169,7 @@ sealed class WcAnalyticEvents( accountDerivation?.let { put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString()) } + put(AnalyticsParam.Key.FEE_ASSET_TYPE, feeAssetType.value) }, ), AppsFlyerIncludedEvent diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt index 108efdb33c..2a82f3cbdb 100644 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt @@ -18,6 +18,7 @@ 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.url.UrlOpener +import com.tangem.core.ui.utils.parseBigDecimalOrNull import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError @@ -265,7 +266,13 @@ internal class GiveApprovalModel @Inject constructor( private fun sendApproveSuccessAnalytics(feeContent: FeeSelectorUM.Content) { val currency = params.cryptoCurrencyStatus.currency - val feeToken = feeContent.feeExtraInfo.feeCryptoCurrencyStatus.currency.symbol + val feeCurrency = feeContent.feeExtraInfo.feeCryptoCurrencyStatus.currency + val feeToken = feeCurrency.symbol + val feeAssetType = if (feeCurrency is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } val permissionType = when (uiState.value.approveType) { ApproveType.LIMITED -> "Current transaction" ApproveType.UNLIMITED -> "Unlimited" @@ -275,6 +282,7 @@ internal class GiveApprovalModel @Inject constructor( token = currency.symbol, feeType = feeContent.toAnalyticType(), feeToken = feeToken, + feeAssetType = feeAssetType, permissionType = permissionType, ) analyticsEventHandler.send( @@ -286,10 +294,9 @@ internal class GiveApprovalModel @Inject constructor( } private fun getApprovalAmount(): BigDecimal? { - return if (uiState.value.approveType == ApproveType.LIMITED) { - params.amount.toBigDecimalOrNull() - } else { - null + return when (uiState.value.approveType) { + ApproveType.LIMITED -> params.amount.parseBigDecimalOrNull() + ApproveType.UNLIMITED -> null } } @@ -302,7 +309,7 @@ internal class GiveApprovalModel @Inject constructor( val tokenCurrency = cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: return GetFeeError.DataError(IllegalStateException("Currency is not a token")).left() - val amount = params.amount.toBigDecimalOrNull() + val amount = params.amount.parseBigDecimalOrNull() ?: return GetFeeError.DataError(IllegalArgumentException("Invalid amount format")).left() val allowance = getAllowanceInfoUseCase( diff --git a/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt index cf09525c4c..acf4eaa4da 100644 --- a/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt +++ b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt @@ -2,16 +2,21 @@ package com.tangem.features.approval.impl.model import arrow.core.right import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.MutableParamsContainer -import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.AllowanceInfo +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase import com.tangem.domain.transaction.usecase.GetAllowanceInfoUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase @@ -23,6 +28,7 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.approval.api.GiveApprovalComponent import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk @@ -30,6 +36,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.math.BigDecimal @OptIn(ExperimentalCoroutinesApi::class) class GiveApprovalModelTest { @@ -48,31 +55,73 @@ class GiveApprovalModelTest { private val userWalletId = UserWalletId(stringValue = "0123456789ABCDEF") private val userWallet: UserWallet.Hot = mockk(relaxed = true) - private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk { - every { currency } returns mockk(relaxed = true) + private val tokenCurrency: CryptoCurrency.Token = mockk(relaxed = true) { + every { contractAddress } returns "0xContract" + every { network } returns mockk(relaxed = true) } - private val params = GiveApprovalComponent.Params( - userWalletId = userWalletId, - cryptoCurrencyStatus = cryptoCurrencyStatus, - feeCryptoCurrencyStatus = cryptoCurrencyStatus, - amount = "10", - spenderAddress = "0xSpender", - amountFooter = TextReference.EMPTY, - feeFooter = TextReference.EMPTY, - callback = mockk(relaxed = true), - ) + private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk { + every { currency } returns tokenCurrency + } + + private val approvalTx: TransactionData.Uncompiled = mockk(relaxed = true) + private val transactionFee: TransactionFee = mockk(relaxed = true) + private val transactionFeeExtended: TransactionFeeExtended = mockk(relaxed = true) { + every { transactionFee } returns this@GiveApprovalModelTest.transactionFee + } private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) @BeforeEach fun setUp() { every { getUserWalletUseCase.invoke(userWalletId) } returns userWallet.right() + + coEvery { + createApprovalTransactionUseCase( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = any(), + contractAddress = any(), + spenderAddress = any(), + ) + } returns approvalTx.right() + + coEvery { + getAllowanceInfoUseCase( + userWalletId = any(), + cryptoCurrency = any(), + spenderAddress = any(), + requiredAmount = any(), + ) + } returns AllowanceInfo.Enough(allowance = BigDecimal.ZERO).right() + + coEvery { + getFeeUseCase(transactionData = any(), userWallet = any(), network = any()) + } returns transactionFee.right() + + coEvery { + getFeeForGaslessUseCase(transactionData = any(), userWallet = any(), network = any()) + } returns transactionFeeExtended.right() + + coEvery { + getFeeForTokenUseCase(transactionData = any(), userWallet = any(), token = any()) + } returns transactionFeeExtended.right() } - private fun createModel(): GiveApprovalModel = GiveApprovalModel( + private fun createParams(amount: String): GiveApprovalComponent.Params = GiveApprovalComponent.Params( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, + amount = amount, + spenderAddress = "0xSpender", + amountFooter = TextReference.EMPTY, + feeFooter = TextReference.EMPTY, + callback = mockk(relaxed = true), + ) + + private fun createModel(amount: String = "10"): GiveApprovalModel = GiveApprovalModel( dispatchers = TestingCoroutineDispatcherProvider(), - paramsContainer = MutableParamsContainer(params), + paramsContainer = MutableParamsContainer(createParams(amount)), createApprovalTransactionUseCase = createApprovalTransactionUseCase, getAllowanceInfoUseCase = getAllowanceInfoUseCase, sendTransactionUseCase = sendTransactionUseCase, @@ -109,4 +158,118 @@ class GiveApprovalModelTest { coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerLoadingState() } coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerUpdate() } } + + @Test + fun `GIVEN comma decimal amount and LIMITED approveType WHEN loadFeeExtended THEN creates approval tx with parsed amount`() = + runTest { + val model = createModel(amount = "1,1") + + val result = model.loadFeeExtended(maybeToken = null) + + assertThat(result.isRight()).isTrue() + coVerify { + createApprovalTransactionUseCase( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = match { it != null && it.compareTo(BigDecimal("1.1")) == 0 }, + contractAddress = any(), + spenderAddress = any(), + ) + } + } + + @Test + fun `GIVEN point decimal amount and LIMITED approveType WHEN loadFeeExtended THEN creates approval tx with parsed amount`() = + runTest { + val model = createModel(amount = "1.1") + + val result = model.loadFeeExtended(maybeToken = null) + + assertThat(result.isRight()).isTrue() + coVerify { + createApprovalTransactionUseCase( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = match { it != null && it.compareTo(BigDecimal("1.1")) == 0 }, + contractAddress = any(), + spenderAddress = any(), + ) + } + } + + @Test + fun `GIVEN comma decimal amount and UNLIMITED approveType WHEN loadFeeExtended THEN creates approval tx with null amount`() = + runTest { + val model = createModel(amount = "1,1") + model.onChangeApproveType(ApproveType.UNLIMITED) + + val result = model.loadFeeExtended(maybeToken = null) + + assertThat(result.isRight()).isTrue() + coVerify { + createApprovalTransactionUseCase( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = isNull(), + contractAddress = any(), + spenderAddress = any(), + ) + } + } + + @Test + fun `GIVEN unparseable amount and LIMITED approveType WHEN loadFeeExtended THEN creates approval tx with null amount`() = + runTest { + val model = createModel(amount = "abc") + + val result = model.loadFeeExtended(maybeToken = null) + + assertThat(result.isRight()).isTrue() + coVerify { + createApprovalTransactionUseCase( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = isNull(), + contractAddress = any(), + spenderAddress = any(), + ) + } + } + + @Test + fun `GIVEN comma decimal amount and LIMITED approveType WHEN loadFee THEN creates approval tx with parsed amount`() = + runTest { + val model = createModel(amount = "2,5") + + val result = model.loadFee() + + assertThat(result.isRight()).isTrue() + coVerify { + createApprovalTransactionUseCase( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = match { it != null && it.compareTo(BigDecimal("2.5")) == 0 }, + contractAddress = any(), + spenderAddress = any(), + ) + } + } + + @Test + fun `GIVEN unparseable amount WHEN loadFee THEN returns DataError and does not check allowance`() = runTest { + val model = createModel(amount = "abc") + + val result = model.loadFee() + + assertThat(result.isLeft()).isTrue() + assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.DataError::class.java) + coVerify(exactly = 0) { + getAllowanceInfoUseCase( + userWalletId = any(), + cryptoCurrency = any(), + spenderAddress = any(), + requiredAmount = any(), + ) + } + } } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt index 346ef7f259..bfe3e2c965 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt @@ -141,12 +141,12 @@ internal class AddToPortfolioModel @Inject constructor( // but its call AddToPortfolioManager.onAddedTokenClick callback !isAvailableToAdd -> { val singleNetwork = portfolio.account.addedMarketNetworks.first() - callbackDelegate.onNetworkSelected(singleNetwork) + callbackDelegate.onNetworkSelected.send(singleNetwork) } // force select a network, triggers [selectedNetwork] isSingleAvailableNetwork -> { val singleNetwork = portfolio.account.availableToAddNetworks.first() - callbackDelegate.onNetworkSelected(singleNetwork) + callbackDelegate.onNetworkSelected.send(singleNetwork) } // it's important to control root screen, UI depends on it(close/arrow icon) isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio)) @@ -358,7 +358,7 @@ internal class AddToPortfolioModel @Inject constructor( val isSingleAvailableNetwork = portfolio.account.isSingleNetwork if (isSingleAvailableNetwork) { val singleNetwork = portfolio.account.availableToAddNetworks.first() - callbackDelegate.onNetworkSelected(singleNetwork) + callbackDelegate.onNetworkSelected.send(singleNetwork) } else { navigation.pushNew(routeToNetworkSelector(portfolio)) } diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt index 125cf4eb0c..b53525f33f 100644 --- a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -23,6 +23,7 @@ import com.tangem.core.ui.message.dialog.Dialogs.hotWalletCreationNotSupportedDi import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.card.analytics.IntroductionProcess +import com.tangem.domain.card.analytics.IntroductionProcess.CreateWalletIntroScreenOpened.ScreenType import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.SaveWalletError @@ -137,6 +138,10 @@ internal class CreateWalletStartModel @Inject constructor( modelScope.launch { analyticsEventHandler.send( event = IntroductionProcess.CreateWalletIntroScreenOpened( + screenType = when (params.mode) { + CreateWalletStartComponent.Mode.ColdWallet -> ScreenType.Cold + CreateWalletStartComponent.Mode.HotWallet -> ScreenType.Hot + }, referralId = appsFlyerStore.get()?.refcode, ), ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt index f61f46c0a3..393512c4d7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt @@ -32,14 +32,13 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.domain.models.earn.PreselectedEarnType +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.feed.components.feed.FeedBottomSheetRoute -import kotlinx.serialization.Serializable import com.tangem.features.feed.model.earn.EarnModel -import com.tangem.features.feed.model.earn.analytics.EarnSource import com.tangem.features.feed.ui.components.FeedSearchBar import com.tangem.features.feed.ui.earn.EarnContent +import kotlinx.serialization.Serializable internal class DefaultEarnComponent( appComponentContext: AppComponentContext, @@ -116,17 +115,12 @@ internal class DefaultEarnComponent( componentContext: ComponentContext, ): ComposableBottomSheetComponent = when (config) { is FeedBottomSheetRoute.AddToPortfolio -> { + val manager = checkNotNull(earnModel.currentAddToPortfolioManager) { + "currentAddToPortfolioManager must be set before activating AddToPortfolio slot" + } addToPortfolioComponentFactory.create( context = childByContext(componentContext), - params = AddToPortfolioComponent.Params( - addToPortfolioManager = when { - config.source == EarnSource.BEST_OPPORTUNITIES_SOURCE.value -> - earnModel.addBestOpportunitiesPortfolioManager - config.source == EarnSource.MOSTLY_USED_SOURCE.value -> - earnModel.addMostlyUsedPortfolioManager - else -> error("Unknown source: ${config.source}") - }, - ), + params = AddToPortfolioComponent.Params(addToPortfolioManager = manager), ) } is FeedBottomSheetRoute.NetworkFilter -> EarnNetworkFilterComponent( 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 65a7fc6b83..b82ddab432 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 @@ -95,11 +95,12 @@ internal class DefaultFeedComponent( componentContext: ComponentContext, ): ComposableBottomSheetComponent = when (config) { is FeedBottomSheetRoute.AddToPortfolio -> { + val manager = checkNotNull(feedComponentModel.currentAddToPortfolioManager) { + "currentAddToPortfolioManager must be set before activating AddToPortfolio slot" + } addToPortfolioComponentFactory.create( context = childByContext(componentContext), - params = AddToPortfolioComponent.Params( - addToPortfolioManager = feedComponentModel.addToPortfolioManager, - ), + params = AddToPortfolioComponent.Params(addToPortfolioManager = manager), ) } is FeedBottomSheetRoute.NetworkFilter -> EmptyComposableBottomSheetComponent diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt index ae1280b2cf..bd7e22b2e4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt @@ -22,8 +22,9 @@ import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.EarnNetworks import com.tangem.domain.models.earn.EarnTokenWithCurrency -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.domain.models.earn.PreselectedEarnType +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams.Companion.CategoryEarn import com.tangem.features.feed.components.earn.DefaultEarnComponent import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent import com.tangem.features.feed.components.earn.EarnTypeFilterComponent @@ -44,8 +45,8 @@ import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM import com.tangem.features.feed.ui.earn.state.EarnUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import javax.inject.Inject @Stable @@ -85,24 +86,13 @@ internal class EarnModel @Inject constructor( dispatchers = dispatchers, ) - val addBestOpportunitiesPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( - scope = modelScope, - settings = AddToPortfolioManager.Settings.Earn, - analyticsParams = AddToPortfolioManager.AnalyticsParams(source = EarnSource.BEST_OPPORTUNITIES_SOURCE.value), - ).apply { - updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) - } - - val addMostlyUsedPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( - scope = modelScope, - settings = AddToPortfolioManager.Settings.Earn, - analyticsParams = AddToPortfolioManager.AnalyticsParams(source = EarnSource.MOSTLY_USED_SOURCE.value), - ).apply { - updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) - } - val bottomSheetNavigation: SlotNavigation = SlotNavigation() + var currentAddToPortfolioManager: AddToPortfolioManager? = null + private set + + private var currentAddToPortfolioManagerScope: CoroutineScope? = null + val state: StateFlow get() = stateController.uiState @@ -114,30 +104,6 @@ internal class EarnModel @Inject constructor( subscribeOnNetworks() subscribeOnBatchFlow() subscribeToMostlyUsed() - - addBestOpportunitiesPortfolioManager.onDismiss.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .launchIn(modelScope) - addBestOpportunitiesPortfolioManager.onSuccessAdded.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .onEach(::openCurrencyDetails) - .launchIn(modelScope) - addBestOpportunitiesPortfolioManager.onAddedTokenClick.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .onEach(::openCurrencyDetails) - .launchIn(modelScope) - - addMostlyUsedPortfolioManager.onDismiss.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .launchIn(modelScope) - addMostlyUsedPortfolioManager.onSuccessAdded.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .onEach(::openCurrencyDetails) - .launchIn(modelScope) - addMostlyUsedPortfolioManager.onAddedTokenClick.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .onEach(::openCurrencyDetails) - .launchIn(modelScope) } private fun openCurrencyDetails(result: AddToPortfolioManager.Result) { @@ -354,17 +320,44 @@ internal class EarnModel @Inject constructor( contractAddress = earnTokenWithCurrency.earnToken.tokenAddress, decimalCount = earnTokenWithCurrency.earnToken.decimalCount, ) - when (source) { - EarnSource.BEST_OPPORTUNITIES_SOURCE -> addBestOpportunitiesPortfolioManager.apply { - setTokenParams(token) - setTokenNetworks(listOf(network)) - } - EarnSource.MOSTLY_USED_SOURCE -> addMostlyUsedPortfolioManager.apply { - setTokenParams(token) - setTokenNetworks(listOf(network)) - } + val manager = createAddToPortfolioManager(source = source).apply { + setTokenParams(token) + setTokenNetworks(listOf(network)) } - bottomSheetNavigation.activate(FeedBottomSheetRoute.AddToPortfolio(source.value)) + currentAddToPortfolioManager = manager + // Drop the slot through null so the same-source repeat click still recreates the child. + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate(FeedBottomSheetRoute.AddToPortfolio(source = source.value)) + } + + private fun createAddToPortfolioManager(source: EarnSource): AddToPortfolioManager { + currentAddToPortfolioManagerScope?.cancel() + val managerScope = CoroutineScope( + modelScope.coroutineContext + SupervisorJob(modelScope.coroutineContext.job), + ) + currentAddToPortfolioManagerScope = managerScope + + val manager = addToPortfolioManagerFactory.create( + scope = managerScope, + settings = AddToPortfolioManager.Settings.Earn, + analyticsParams = AddToPortfolioManager.AnalyticsParams(source = source.value, category = CategoryEarn), + ).apply { + updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) + } + + manager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(managerScope) + manager.onSuccessAdded.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(managerScope) + manager.onAddedTokenClick.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(managerScope) + + return manager } private fun onTypeFilterOptionSelected(type: EarnFilterType) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 7a7c71ccc5..a0cb8e55db 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -41,10 +41,8 @@ import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentHashMap -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import org.joda.time.DateTime import org.joda.time.DateTimeZone import javax.inject.Inject @@ -62,7 +60,7 @@ internal class FeedComponentModel @Inject constructor( private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase, private val appRouter: AppRouter, private val designFeatureToggles: DesignFeatureToggles, - addToPortfolioManagerFactory: AddToPortfolioManager.Factory, + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, paramsContainer: ParamsContainer, @@ -87,16 +85,13 @@ internal class FeedComponentModel @Inject constructor( dispatchers = dispatchers, ) - val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( - scope = modelScope, - settings = AddToPortfolioManager.Settings.Earn, - analyticsParams = AddToPortfolioManager.AnalyticsParams(source = AnalyticsParam.ScreensSources.Markets.value), - ).apply { - updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) - } - val bottomSheetNavigation: SlotNavigation = SlotNavigation() + var currentAddToPortfolioManager: AddToPortfolioManager? = null + private set + + private var currentAddToPortfolioManagerScope: CoroutineScope? = null + val state: StateFlow get() = stateController.uiState @@ -110,18 +105,6 @@ internal class FeedComponentModel @Inject constructor( fetchCharts() subscribeOnCurrencyUpdate() subscribeOnDataState() - - addToPortfolioManager.onDismiss.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .launchIn(modelScope) - addToPortfolioManager.onSuccessAdded.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .onEach(::openCurrencyDetails) - .launchIn(modelScope) - addToPortfolioManager.onAddedTokenClick.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .onEach(::openCurrencyDetails) - .launchIn(modelScope) } private fun openCurrencyDetails(result: AddToPortfolioManager.Result) { @@ -431,12 +414,46 @@ internal class FeedComponentModel @Inject constructor( contractAddress = earnTokenWithCurrency.earnToken.tokenAddress, decimalCount = earnTokenWithCurrency.earnToken.decimalCount, ) - val route = FeedBottomSheetRoute.AddToPortfolio(AnalyticsParam.ScreensSources.Markets.value) - addToPortfolioManager.apply { + val manager = createAddToPortfolioManager().apply { setTokenParams(token) setTokenNetworks(listOf(network)) } - bottomSheetNavigation.activate(route) + currentAddToPortfolioManager = manager + // Drop the slot through null so the same-source repeat click still recreates the child. + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate(FeedBottomSheetRoute.AddToPortfolio(AnalyticsParam.ScreensSources.Markets.value)) + } + + private fun createAddToPortfolioManager(): AddToPortfolioManager { + currentAddToPortfolioManagerScope?.cancel() + val managerScope = CoroutineScope( + modelScope.coroutineContext + SupervisorJob(modelScope.coroutineContext.job), + ) + currentAddToPortfolioManagerScope = managerScope + + val manager = addToPortfolioManagerFactory.create( + scope = managerScope, + settings = AddToPortfolioManager.Settings.Earn, + analyticsParams = AddToPortfolioManager.AnalyticsParams( + source = AnalyticsParam.ScreensSources.Markets.value, + ), + ).apply { + updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) + } + + manager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(managerScope) + manager.onSuccessAdded.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(managerScope) + manager.onAddedTokenClick.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(managerScope) + + return manager } private fun handleEarnPageOpenClicked() { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt index e288c415ef..73d13815e7 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt @@ -30,6 +30,7 @@ internal sealed class SendAnalyticEvents( val isNonceNotEmpty: Boolean, private val ensStatus: AnalyticsParam.EmptyFull, private val feeToken: String, + private val feeAssetType: AnalyticsParam.FeeAssetType, private val fromDerivationIndex: Int?, private val toDerivationIndex: Int?, ) : SendAnalyticEvents( @@ -47,6 +48,7 @@ internal sealed class SendAnalyticEvents( } put(ENS_ADDRESS, ensAddress) put(FEE_TOKEN, feeToken) + put(AnalyticsParam.Key.FEE_ASSET_TYPE, feeAssetType.value) }, ), AppsFlyerIncludedEvent diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt index c3d536b383..8ab32e31a1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt @@ -31,6 +31,11 @@ internal class SendAnalyticHelper @Inject constructor( val feeSelectorUM = sendUM.feeSelectorUM as? FeeSelectorUM.Content ?: return val feeType = feeSelectorUM.toAnalyticType() val feeTokenSymbol = feeToken.symbol + val feeAssetType = if (feeToken is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } val fromDerivationIndex = account?.derivationIndex?.value val destination = destinationUM?.addressTextField?.actualAddress ?: return val destinationAccount = getAccountCurrencyByAddressUseCase(destination) @@ -46,6 +51,7 @@ internal class SendAnalyticHelper @Inject constructor( fromDerivationIndex = fromDerivationIndex, toDerivationIndex = toDerivationIndex, feeToken = feeTokenSymbol, + feeAssetType = feeAssetType, ), ) analyticsEventHandler.send( @@ -55,6 +61,7 @@ internal class SendAnalyticHelper @Inject constructor( token = cryptoCurrency.symbol, feeType = feeType, feeToken = feeTokenSymbol, + feeAssetType = feeAssetType, ), memoType = getSendTransactionMemoType(destinationUM.memoTextField), ), diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/analytics/NFTSendAnalyticHelper.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/analytics/NFTSendAnalyticHelper.kt index b8c6f2e66c..5172f13211 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/analytics/NFTSendAnalyticHelper.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/analytics/NFTSendAnalyticHelper.kt @@ -37,6 +37,7 @@ internal class NFTSendAnalyticHelper @Inject constructor( token = NFT_SEND_CATEGORY, // should send "NFT" in token param feeType = feeType, feeToken = cryptoCurrency.symbol, + feeAssetType = AnalyticsParam.FeeAssetType.Coin, ), memoType = getSendTransactionMemoType(destinationUM?.memoTextField), ), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt index d02f06b8ba..7d1a8d7044 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt @@ -73,6 +73,7 @@ internal class StakingAnalyticSender( feeType = AnalyticsParam.FeeType.Normal, permissionType = ApproveType.LIMITED.name, feeToken = tokenCryptoCurrency.symbol, + feeAssetType = AnalyticsParam.FeeAssetType.Coin, ), memoType = Basic.TransactionSent.MemoType.Null, ), @@ -82,7 +83,7 @@ internal class StakingAnalyticSender( fun sendTransactionStakingAnalytics(value: StakingUiState, cryptoCurrencyStatus: CryptoCurrencyStatus) { val validatorState = value.validatorState as? StakingStates.ValidatorState.Data val validatorName = validatorState?.chosenTarget?.name ?: return - + val feeAssetType = AnalyticsParam.FeeAssetType.Coin // support only coin as fee asset for staking transactions analyticsEventHandler.send( Basic.TransactionSent( sentFrom = AnalyticsParam.TxSentFrom.Staking( @@ -90,6 +91,7 @@ internal class StakingAnalyticSender( token = value.cryptoCurrencySymbol, feeType = AnalyticsParam.FeeType.Normal, feeToken = cryptoCurrencyStatus.currency.symbol, + feeAssetType = feeAssetType, ), memoType = Basic.TransactionSent.MemoType.Null, ), @@ -98,6 +100,7 @@ internal class StakingAnalyticSender( StakingAnalyticsEvent.StakeInProgressScreenOpened( validator = validatorName, action = getStakingActionType(value), + feeAssetType = feeAssetType, ), ) } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt index 5a68284603..e51dca3a75 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt @@ -61,6 +61,7 @@ internal sealed class SendWithSwapAnalyticEvents( val feeType: AnalyticsParam.FeeType, val fromToken: CryptoCurrency, val toToken: CryptoCurrency, + val feeAssetType: AnalyticsParam.FeeAssetType, val fromDerivationIndex: Int?, val toDerivationIndex: Int?, ) : SendWithSwapAnalyticEvents( @@ -74,6 +75,7 @@ internal sealed class SendWithSwapAnalyticEvents( put(RECEIVE_BLOCKCHAIN, toToken.network.name) if (fromDerivationIndex != null) put(ACCOUNT_DERIVATION_FROM, fromDerivationIndex.toString()) if (toDerivationIndex != null) put(ACCOUNT_DERIVATION_TO, toDerivationIndex.toString()) + put(AnalyticsParam.Key.FEE_ASSET_TYPE, feeAssetType.value) }, ), AppsFlyerIncludedEvent diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 7e59abe87e..ad6eac10b6 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -503,12 +503,19 @@ internal class SendWithSwapConfirmModel @Inject constructor( .getOrNull()?.account val toDerivationIndex = destinationAccount?.derivationIndex?.value + val feeToken = getSelectedFeeToken() + val feeAssetType = if (feeToken is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } analyticsEventHandler.send( SendWithSwapAnalyticEvents.TransactionScreenOpened( providerName = selectedProvider.name, feeType = feeType, fromToken = fromCurrency, toToken = toCurrency, + feeAssetType = feeAssetType, fromDerivationIndex = fromDerivationIndex, toDerivationIndex = toDerivationIndex, ), @@ -519,7 +526,8 @@ internal class SendWithSwapConfirmModel @Inject constructor( blockchain = fromCurrency.network.name, token = fromCurrency.symbol, feeType = feeType, - feeToken = getSelectedFeeToken().symbol, + feeToken = feeToken.symbol, + feeAssetType = feeAssetType, ), memoType = Basic.TransactionSent.MemoType.Null, ), diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 09b5c11731..e3cbf8b44f 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -1950,6 +1950,7 @@ internal class SwapInteractorImpl @Inject constructor( private fun Fee.increaseGasLimitBy(percentage: Int): Fee { if (this !is Fee.Ethereum) return this val gasLimit = this.gasLimit + if (gasLimit == BigInteger.ZERO) return this val increasedGasPrice = this.amount.value?.movePointRight(this.amount.decimals) ?.divide(gasLimit.toBigDecimal(), RoundingMode.HALF_UP) val increasedGasLimit = gasLimit.multiply(percentage.toBigInteger()).divide(hundredPercent) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index 04428126e9..54c93824d2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -5,6 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FR import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN @@ -103,6 +104,7 @@ sealed class SwapEvents( val sendToken: String, val receiveToken: String, val feeToken: String, + val feeAssetType: AnalyticsParam.FeeAssetType, val fromDerivationIndex: Int?, val toDerivationIndex: Int?, val referralId: String?, @@ -118,6 +120,7 @@ sealed class SwapEvents( if (fromDerivationIndex != null) put(ACCOUNT_DERIVATION_FROM, fromDerivationIndex.toString()) if (toDerivationIndex != null) put(ACCOUNT_DERIVATION_TO, toDerivationIndex.toString()) put(FEE_TOKEN, feeToken) + put(AnalyticsParam.Key.FEE_ASSET_TYPE, feeAssetType.value) putAll(getReferralParams(referralId)) }, ), AppsFlyerIncludedEvent diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 444a6df6b4..55defa360f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -196,6 +196,7 @@ internal class SwapModel @Inject constructor( isAccountsModeProvider = Provider { isAccountsMode }, isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, swapFeatureToggles = swapFeatureToggles, + appRouter = appRouter, ) private val inputNumberFormatter = InputNumberFormatter( @@ -826,7 +827,7 @@ internal class SwapModel @Inject constructor( shouldSanitize = false, ) } - }.getOrNull() + }.getOrNull() ?: fromSwapCurrencyStatus.status } dataState = dataState.copy(feePaidCryptoCurrency = feePaidCryptoCurrency) @@ -1180,7 +1181,7 @@ internal class SwapModel @Inject constructor( } }, ) - sendSuccessEvent() + sendSwapInProgressEvent() router.replaceAll(SwapRoute.Success) } @@ -1256,7 +1257,7 @@ internal class SwapModel @Inject constructor( } } - private suspend fun sendSuccessEvent() { + private suspend fun sendSwapInProgressEvent() { val provider = dataState.selectedProvider ?: return val fee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return @@ -1264,6 +1265,12 @@ internal class SwapModel @Inject constructor( val fromDerivationIndex = fromSwapCurrencyStatus.account.derivationIndex?.value val toDerivationIndex = toSwapCurrencyStatus.account.derivationIndex?.value + val feeToken = getFeeToken() + val feeAssetType = if (feeToken is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } analyticsEventHandler.send( SwapEvents.SwapInProgressScreen( provider = provider, @@ -1272,7 +1279,8 @@ internal class SwapModel @Inject constructor( receiveBlockchain = toSwapCurrencyStatus.currency.network.name, sendToken = fromSwapCurrencyStatus.currency.symbol, receiveToken = toSwapCurrencyStatus.currency.symbol, - feeToken = getFeeToken().symbol, + feeToken = feeToken.symbol, + feeAssetType = feeAssetType, fromDerivationIndex = fromDerivationIndex, toDerivationIndex = toDerivationIndex, referralId = appsFlyerStore.get()?.refcode, @@ -1599,16 +1607,6 @@ internal class SwapModel @Inject constructor( onProviderFilterSelect = { filterType -> uiState = stateBuilder.updateProviderFilterType(uiState, filterType) }, - onBuyClick = { - val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@UiActions - val feePaidCryptoCurrency = dataState.feePaidCryptoCurrency ?: return@UiActions - val route = AppRoute.CurrencyDetails( - userWalletId = fromSwapCurrencyStatus.userWalletId, - currency = feePaidCryptoCurrency.currency, - ) - - appRouter.push(route) - }, onRetryClick = { startLoadingQuotesFromLastState() }, @@ -1678,11 +1676,18 @@ internal class SwapModel @Inject constructor( } private fun sendSuccessSwapEvent(fromToken: CryptoCurrency, feeType: FeeType) { + val feeToken = getFeeToken() + val feeAssetType = if (feeToken is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } val event = AnalyticsParam.TxSentFrom.Swap( blockchain = fromToken.network.name, token = fromToken.symbol, feeType = AnalyticsParam.FeeType.fromString(feeType.getNameForAnalytics()), - feeToken = getFeeToken().symbol, + feeToken = feeToken.symbol, + feeAssetType = feeAssetType, ) analyticsEventHandler.send( Basic.TransactionSent( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index cc39b1d373..e3e66f9f71 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -1,6 +1,8 @@ package com.tangem.feature.swap.model import com.tangem.common.TangemSiteUrlBuilder +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification @@ -106,6 +108,7 @@ internal class SwapNotificationsFactory( feeCryptoCurrencyStatus: CryptoCurrencyStatus?, selectedFeeType: FeeType, hideFee: Boolean, + appRouter: AppRouter, ): ImmutableList { val warnings = buildList { maybeAddRentExemptionError(quoteModel) @@ -113,7 +116,12 @@ internal class SwapNotificationsFactory( maybeAddNeedReserveToCreateAccountWarning(quoteModel) maybeAddPermissionNeededWarning(quoteModel) maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType) - maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, hideFee) + maybeAddUnableCoverFeeWarning( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + hideFee = hideFee, + appRouter = appRouter, + ) maybeAddTransactionInProgressWarning(quoteModel) maybeAddPriceImpactNotification(quoteModel.priceImpact) } @@ -295,17 +303,20 @@ internal class SwapNotificationsFactory( } } + @Suppress("CyclomaticComplexMethod") private fun MutableList.maybeAddUnableCoverFeeWarning( quoteModel: SwapState.QuotesLoadedState, feeCryptoCurrencyStatus: CryptoCurrencyStatus?, hideFee: Boolean, + appRouter: AppRouter, ) { - if (hideFee) return - val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency + if (hideFee || feeCryptoCurrencyStatus == null) return + val fromSwapCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus + val fromCurrency = fromSwapCurrency.currency val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough val shouldShowCoverWarning = !quoteModel.preparedSwapConfigState.isBalanceEnough && quoteModel.permissionState !is PermissionDataState.PermissionLoading && - feeCryptoCurrencyStatus?.currency != fromCurrency + feeCryptoCurrencyStatus.currency != fromCurrency val isCEXProvider = quoteModel.swapProvider.type == ExchangeProviderType.CEX @@ -315,13 +326,25 @@ internal class SwapNotificationsFactory( val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCEXProvider if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) { add( - SwapNotificationUM.Error.UnableToCoverFeeWarning( - fromToken = fromCurrency, - feeCurrency = feeCryptoCurrencyStatus?.currency, - currencyName = feeEnoughState?.currencyName ?: fromCurrency.network.name, - currencySymbol = feeEnoughState?.currencySymbol ?: fromCurrency.network.currencySymbol, - onConfirmClick = actions.onBuyClick, - ), + if (fromCurrency.id == feeCryptoCurrencyStatus.currency.id) { + SwapNotificationUM.Error.InsufficientFunds + } else { + val route = AppRoute.CurrencyDetails( + userWalletId = fromSwapCurrency.userWalletId, + currency = feeCryptoCurrencyStatus.currency, + ) + SwapNotificationUM.Error.UnableToCoverFeeWarning( + fromToken = fromCurrency, + feeCurrency = feeCryptoCurrencyStatus.currency, + currencyName = feeEnoughState?.currencyName ?: fromCurrency.network.name, + currencySymbol = feeEnoughState?.currencySymbol ?: fromCurrency.network.currencySymbol, + onConfirmClick = if (!appRouter.stack.contains(route)) { + { appRouter.push(route) } + } else { + null + }, + ) + }, ) } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt index 69307b7b51..f49d855dea 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -24,7 +24,6 @@ internal data class UiActions( val onProviderClick: (String) -> Unit, val onProviderSelect: (String) -> Unit, val onProviderFilterSelect: (ProviderFilterType) -> Unit, - val onBuyClick: () -> Unit, val onSelectTokenClick: (TokenSelectionDirection) -> Unit, val onSuccess: () -> Unit, val onLinkClick: (String) -> Unit, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index 8234385d53..7896ea85ca 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -58,12 +58,17 @@ internal object SwapNotificationUM { ), ) + data object InsufficientFunds : Error( + title = resourceReference(R.string.swapping_insufficient_funds), + subtitle = resourceReference(R.string.swapping_insufficient_funds_description), + ) + data class UnableToCoverFeeWarning( val fromToken: CryptoCurrency, val currencyName: String, val currencySymbol: String, - val feeCurrency: CryptoCurrency?, - val onConfirmClick: () -> Unit, + val feeCurrency: CryptoCurrency, + val onConfirmClick: (() -> Unit)?, ) : Error( title = resourceReference( R.string.warning_express_not_enough_fee_for_token_tx_title, @@ -74,7 +79,7 @@ internal object SwapNotificationUM { wrappedList(currencyName, currencySymbol), ), iconResId = fromToken.networkIconResId, - buttonState = feeCurrency?.let { + buttonState = onConfirmClick?.let { NotificationConfig.ButtonsState.SecondaryButtonConfig( text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)), onClick = onConfirmClick, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index fd8ab0c1ef..43a6714263 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.ui import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.common.routing.AppRouter import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter @@ -11,13 +12,16 @@ import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToI import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.domain.express.models.ProviderFilterType import com.tangem.core.ui.extensions.* -import com.tangem.core.ui.format.bigdecimal.* +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.parseBigDecimalOrNull import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ProviderFilterType import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -27,20 +31,16 @@ import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNet import com.tangem.feature.swap.converters.SwapProviderStateBuilder import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType -import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount -import com.tangem.feature.swap.domain.models.domain.RateType -import com.tangem.feature.swap.domain.models.domain.SwapProvider -import com.tangem.feature.swap.domain.models.domain.SwapUIMode +import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.model.SwapNotificationsFactory import com.tangem.feature.swap.model.SwapProcessDataState import com.tangem.feature.swap.models.* import com.tangem.feature.swap.models.SwapButton.Mode import com.tangem.feature.swap.models.states.* -import com.tangem.features.swap.SwapFeatureToggles import com.tangem.feature.swap.presentation.R import com.tangem.feature.swap.utils.formatToUIRepresentation +import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.Provider import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns.DASH_SIGN @@ -54,7 +54,7 @@ import java.math.BigDecimal /** * State builder creates a specific states for SwapScreen */ -@Suppress("LargeClass", "TooManyFunctions") +@Suppress("LargeClass", "TooManyFunctions", "LongParameterList") internal class StateBuilder( private val actions: UiActions, private val isBalanceHiddenProvider: Provider, @@ -62,6 +62,7 @@ internal class StateBuilder( private val isAccountsModeProvider: Provider, private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, private val swapFeatureToggles: SwapFeatureToggles, + private val appRouter: AppRouter, ) { private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) @@ -469,6 +470,7 @@ internal class StateBuilder( feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, selectedFeeType = selectedFeeType, hideFee = hideFee, + appRouter = appRouter, ) val fromAccountTitleUM = when { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt index adb20d2e84..4c40dcfb92 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRouter import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.express.models.ExpressError import com.tangem.domain.models.account.Account @@ -33,6 +34,7 @@ internal class StateBuilderInitialStateTest { private val isAccountsModeProvider: Provider = mockk() private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true) + private val appRouter: AppRouter = mockk() private lateinit var sut: StateBuilder @@ -51,6 +53,7 @@ internal class StateBuilderInitialStateTest { isAccountsModeProvider = isAccountsModeProvider, isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, swapFeatureToggles = swapFeatureToggles, + appRouter = appRouter ) } diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt index cc41bb298f..833a71b7cf 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRouter import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.wallet.UserWallet @@ -28,6 +29,7 @@ internal class StateBuilderPairsTest { private val isAccountsModeProvider: Provider = mockk() private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true) + private val appRouter: AppRouter = mockk() private lateinit var sut: StateBuilder @@ -56,6 +58,7 @@ internal class StateBuilderPairsTest { isAccountsModeProvider = isAccountsModeProvider, isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, swapFeatureToggles = swapFeatureToggles, + appRouter = appRouter, ) } diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt index fef9a581bb..badfe8f5af 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRouter import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -31,6 +32,7 @@ internal class StateBuilderQuotesTest { private val isAccountsModeProvider: Provider = mockk() private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true) + private val appRouter: AppRouter = mockk() private lateinit var sut: StateBuilder @@ -60,6 +62,7 @@ internal class StateBuilderQuotesTest { isAccountsModeProvider = isAccountsModeProvider, isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, swapFeatureToggles = swapFeatureToggles, + appRouter = appRouter, ) } diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt index d83dfd97c9..ffeb69d172 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRouter import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -33,6 +34,7 @@ internal class StateBuilderSwapDataTest { private val isAccountsModeProvider: Provider = mockk() private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true) + private val appRouter: AppRouter = mockk() private lateinit var sut: StateBuilder @@ -62,6 +64,7 @@ internal class StateBuilderSwapDataTest { isAccountsModeProvider = isAccountsModeProvider, isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, swapFeatureToggles = swapFeatureToggles, + appRouter = appRouter, ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt index 2804ef46d5..f2dcb1d346 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt @@ -58,9 +58,7 @@ internal class TangemPayCardPageScreenComponent( TangemPayCardPageScreen( state = state, cardDetailsBlockComponent = cardDetailsBlockComponent, - cardDetailsState = cardDetailsState.copy( - isActive = !state.isReissueInProgress, - ), + cardDetailsState = cardDetailsState, modifier = modifier, ) bottomSheet.child?.instance?.BottomSheet() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt index 6b67faa04f..f59d7bde31 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt @@ -41,6 +41,7 @@ internal class TangemPayEditDisplayNameComponent( displayNameState = DisplayNameState.Editing( displayName = state.editingValue.text, editingValue = state.editingValue, + isSubmitEnabled = state.isDoneEnabled, onValueChanged = state.onValueChanged, onSubmit = state.onDoneClick, onDismiss = state.onDismiss, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt index 13f1c7c061..dcb213790d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt @@ -7,7 +7,6 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.model.TangemPayReissueOrderInfo import com.tangem.features.tangempay.model.TangemPayReissueCardModel import com.tangem.features.tangempay.ui.TangemPayReissueCardContent @@ -34,7 +33,6 @@ internal class TangemPayReissueCardComponent( } internal interface ReissueCardListener { - fun onReissueOrderCreate(order: TangemPayReissueOrderInfo) fun onDismissReissueCard() fun onClickAddFunds() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index 0ac34d8ff2..0ad8f929af 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -21,7 +21,11 @@ internal class TangemPayDetailsStateFactory( private val cardFrozenState: TangemPayCardFrozenState, ) { @Suppress("LongMethod") - fun getInitialState(isTangemPayDeactivated: Boolean, cardNumberEnd: String): TangemPayDetailsUM { + fun getInitialState( + isTangemPayDeactivated: Boolean, + cardNumberEnd: String, + isReissuing: Boolean, + ): TangemPayDetailsUM { return TangemPayDetailsUM( topBarConfig = TangemPayDetailsTopBarConfig( onBackClick = onBack, @@ -51,7 +55,8 @@ internal class TangemPayDetailsStateFactory( cards = persistentListOf( TangemPayDetailsBalanceBlockState.Card( lastDigits = cardNumberEnd, - onClick = intents::onCardClick, + onClick = {}, + isReissuing = isReissuing, ), ), onAddCardClick = intents::onAddCardClick, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 2575939acf..69f1f0f2b3 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -31,7 +31,7 @@ internal data class TangemPayCardDetailsUM( val isLoading: Boolean = false, val cardFrozenState: TangemPayCardFrozenState, val displayNameState: DisplayNameState?, - val isActive: Boolean = true, + val isActionsAvailable: Boolean = false, ) internal sealed interface DisplayNameState { @@ -47,6 +47,7 @@ internal sealed interface DisplayNameState { data class Editing( override val displayName: String, val editingValue: TextFieldValue, + val isSubmitEnabled: Boolean, val onValueChanged: (TextFieldValue) -> Unit, val onSubmit: () -> Unit, val onDismiss: () -> Unit, @@ -83,7 +84,11 @@ internal sealed class TangemPayDetailsBalanceBlockState { ) : TangemPayDetailsBalanceBlockState() data class CardsBlockState(val cards: ImmutableList, val onAddCardClick: () -> Unit) - data class Card(val lastDigits: String, val onClick: () -> Unit) + data class Card( + val lastDigits: String, + val onClick: () -> Unit, + val isReissuing: Boolean, + ) } internal data class AddToWalletBlockState( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt index 8d4c09a816..9885d4f337 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.text.input.TextFieldValue internal data class TangemPayEditDisplayNameUM( val editingValue: TextFieldValue, val isLoading: Boolean, + val isDoneEnabled: Boolean, val onValueChanged: (TextFieldValue) -> Unit, val onDoneClick: () -> Unit, val onDismiss: () -> Unit, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt index 6b57692fdc..c54347dce0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -75,7 +75,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( private val showCardDetailsTimerJobHolder = JobHolder() init { - subscribeToCardNameChanges(cardId = card.id, userWalletId = params.userWalletId) + subscribeToCardChanges(cardId = card.id, userWalletId = params.userWalletId) subscribeToCardFrozenState() modelScope.launch { cardDetailsEventListener.event.collectLatest { event -> @@ -87,7 +87,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( } } - private fun subscribeToCardNameChanges(cardId: String, userWalletId: UserWalletId) { + private fun subscribeToCardChanges(cardId: String, userWalletId: UserWalletId) { paymentAccountStatusSupplier.invoke(userWalletId) .onEach { state -> val status = state.value @@ -98,7 +98,11 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( val card = status.requireCardWithId(cardId) val displayName = card.displayName ?: return@onEach + if (card.isReissuing) { + hideCardDetails() + } uiState.update(TangemPayCardDetailsUpdateNameTransformer(displayName)) + uiState.update { it.copy(isActionsAvailable = !card.isReissuing) } } } .launchIn(modelScope) @@ -142,10 +146,8 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( } fun hideCardDetails() { - modelScope.launch { - revealCardDetailsJobHolder.cancel() - uiState.transformerUpdate(transformer = DetailsHiddenStateTransformer(stateFactory)) - } + revealCardDetailsJobHolder.cancel() + uiState.transformerUpdate(transformer = DetailsHiddenStateTransformer(stateFactory)) } private fun showError() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index fc48eab649..89584be4c6 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -19,6 +19,7 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode import com.tangem.core.ui.format.bigdecimal.optionalDecimals import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.PaymentAccountStatusValue @@ -27,13 +28,10 @@ import com.tangem.domain.models.account.requireCardWithId import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier -import com.tangem.domain.pay.model.OrderStatus -import com.tangem.domain.pay.model.TangemPayReissueOrderInfo import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository -import com.tangem.domain.pay.repository.TangemPayReissueCardRepository +import com.tangem.domain.pay.usecase.ChangeCardFrozenStateUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents -import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.components.AddFundsListener import com.tangem.features.tangempay.components.ReissueCardListener import com.tangem.features.tangempay.components.TangemPayCardPageComponent @@ -65,7 +63,7 @@ internal class TangemPayCardPageModel @Inject constructor( private val analytics: AnalyticsEventHandler, private val cardDetailsRepository: TangemPayCardDetailsRepository, private val uiMessageSender: UiMessageSender, - private val reissueCardRepository: TangemPayReissueCardRepository, + private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase, ) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener { private val params: TangemPayCardPageComponent.Params = paramsContainer.require() @@ -75,6 +73,7 @@ internal class TangemPayCardPageModel @Inject constructor( private val addToWalletBannerJobHolder = JobHolder() private val addFundsJobHolder = JobHolder() + private val frozenStateJobHolder = JobHolder() val uiState: StateFlow field = MutableStateFlow( @@ -87,7 +86,6 @@ internal class TangemPayCardPageModel @Inject constructor( val bottomSheetNavigation: SlotNavigation = SlotNavigation() - // TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed init { analytics.send(TangemPayAnalyticsEvents.CardManagementScreenOpened()) fetchAddToWalletBanner() @@ -112,7 +110,13 @@ internal class TangemPayCardPageModel @Inject constructor( } else { TangemPayDailyLimitBlockState.Error } - uiState.update { it.copy(dailyLimitState = dailyLimitState, settings = buildSettings(card)) } + uiState.update { uiState -> + uiState.copy( + dailyLimitState = dailyLimitState, + settings = buildSettings(card), + isReissueInProgress = card.isReissuing, + ) + } } else { uiState.update { it.copy(dailyLimitState = TangemPayDailyLimitBlockState.Error) } } @@ -125,12 +129,18 @@ internal class TangemPayCardPageModel @Inject constructor( TangemPayCardPageSetting( title = TextReference.Res(R.string.tangempay_card_details_change_pin), onSettingClick = { onClickChangePIN(card.hasPinCode) }, - testTag = com.tangem.core.ui.test.TangemPayTestTags.CHANGE_PIN_ROW, + testTag = TangemPayTestTags.CHANGE_PIN_ROW, ), TangemPayCardPageSetting( - title = TextReference.Res(R.string.tangempay_card_details_freeze_card), + title = TextReference.Res( + if (card.isFrozen) { + R.string.tangempay_card_details_unfreeze_card + } else { + R.string.tangempay_card_details_freeze_card + }, + ), onSettingClick = { onClickFreezeOrUnfreezeCard(card.isFrozen) }, - testTag = com.tangem.core.ui.test.TangemPayTestTags.FREEZE_CARD_ROW, + testTag = TangemPayTestTags.FREEZE_CARD_ROW, ), TangemPayCardPageSetting( title = TextReference.Res(R.string.tangempay_card_details_reissue_card), @@ -158,6 +168,8 @@ internal class TangemPayCardPageModel @Inject constructor( } private fun onClickFreezeOrUnfreezeCard(isFrozen: Boolean) { + if (frozenStateJobHolder.isActive) return + val message = if (isFrozen) { TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard) } else { @@ -171,18 +183,6 @@ internal class TangemPayCardPageModel @Inject constructor( bottomSheetNavigation.activate(TangemPayCardNavigation.ReissueCard) } - override fun onReissueOrderCreate(order: TangemPayReissueOrderInfo) { - bottomSheetNavigation.dismiss() - onReissueOrderStatusReceived(order.orderStatus) - if (order.orderStatus != OrderStatus.CANCELED) { - modelScope.launch { - reissueCardRepository.storeReissueOrderId(cardId, order.orderId) - } - } else { - uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong))) - } - } - override fun onDismissReissueCard() { bottomSheetNavigation.dismiss() } @@ -244,40 +244,34 @@ internal class TangemPayCardPageModel @Inject constructor( private fun freezeCard() { modelScope.launch { - cardDetailsRepository.freezeCard( + changeCardFrozenStateUseCase( userWalletId = userWalletId, cardId = cardId, + isFreezing = true, ).onLeft { val message = SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)) uiMessageSender.send(message) - }.onRight { state -> - val message = if (state == TangemPayCardFrozenState.Frozen) { - SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success)) - } else { - SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)) - } + }.onRight { + val message = SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success)) uiMessageSender.send(message) } - } + }.saveIn(frozenStateJobHolder) } private fun unfreezeCard() { modelScope.launch { - cardDetailsRepository.unfreezeCard( + changeCardFrozenStateUseCase( userWalletId = userWalletId, cardId = cardId, + isFreezing = false, ).onLeft { val message = SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)) uiMessageSender.send(message) - }.onRight { state -> - val message = if (state == TangemPayCardFrozenState.Unfrozen) { - SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success)) - } else { - SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)) - } + }.onRight { + val message = SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success)) uiMessageSender.send(message) } - } + }.saveIn(frozenStateJobHolder) } private fun fetchAddToWalletBanner() { @@ -315,18 +309,4 @@ internal class TangemPayCardPageModel @Inject constructor( override fun onDismissViewPin() { bottomSheetNavigation.dismiss() } - - private fun onReissueOrderStatusReceived(orderStatus: OrderStatus) { - when (orderStatus) { - OrderStatus.NEW, OrderStatus.PROCESSING, OrderStatus.COMPLETED -> { - uiState.update { state -> - state.copy( - addToWalletBlockState = null, - isReissueInProgress = true, - ) - } - } - OrderStatus.CANCELED -> Unit - } - } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 8382f135fc..1304c613aa 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -19,9 +19,11 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.models.WalletMetaInfo +import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository @@ -49,10 +51,7 @@ import com.tangem.utils.coroutines.saveIn import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -61,6 +60,7 @@ import javax.inject.Inject @ModelScoped internal class TangemPayDetailsModel @Inject constructor( paramsContainer: ParamsContainer, + paymentAccountStatusSupplier: PaymentAccountStatusSupplier, override val dispatchers: CoroutineDispatcherProvider, private val analytics: AnalyticsEventHandler, private val router: Router, @@ -102,6 +102,7 @@ internal class TangemPayDetailsModel @Inject constructor( stateFactory.getInitialState( isTangemPayDeactivated = isTangemPayDeactivated, cardNumberEnd = firstCard?.lastDigits.orEmpty(), + isReissuing = firstCard?.isReissuing ?: false, ), ) @@ -120,6 +121,21 @@ internal class TangemPayDetailsModel @Inject constructor( if (!isTangemPayDeactivated && firstCard != null) { subscribeToCardFrozenState(firstCard.id) fetchAddToWalletBanner() + + paymentAccountStatusSupplier.invoke(userWalletId) + .map { it.value } + .filterIsInstance() + .filter { it.source == StatusSource.ACTUAL } + .onEach { state -> + val card = state.cards.firstOrNull() ?: return@onEach + uiState.update( + TangemPayCardDataTransformer( + card = card, + onCardClick = { onCardClick() }, + ), + ) + } + .launchIn(modelScope) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt index ce52805326..6bca0bdf1b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt @@ -52,6 +52,7 @@ internal class TangemPayEditDisplayNameModel @Inject constructor( selection = TextRange(originalDisplayName.length), ), isLoading = false, + isDoneEnabled = true, onValueChanged = ::onValueChanged, onDoneClick = ::onDoneClick, onDismiss = ::onDismiss, @@ -87,9 +88,9 @@ internal class TangemPayEditDisplayNameModel @Inject constructor( } private fun onValueChanged(value: TextFieldValue) { - if (value.text.length <= CardDisplayName.MAX_LENGTH) { - uiState.update { it.copy(editingValue = value) } - } + val displayName = CardDisplayName(value.text) + val isAvailableForConfirm = displayName.isRight() + uiState.update { it.copy(editingValue = value, isDoneEnabled = isAvailableForConfirm) } } private fun onDoneClick() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt index 6c454671a1..ff2a256c1f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt @@ -13,6 +13,7 @@ import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayReissueCardRepository +import com.tangem.domain.pay.usecase.ReissueTangemPayCardUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.TangemPayReissueCardComponent import com.tangem.features.tangempay.details.impl.R @@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @Stable @ModelScoped internal class TangemPayReissueCardModel @Inject constructor( @@ -36,6 +38,7 @@ internal class TangemPayReissueCardModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val cardDetailsRepository: TangemPayCardDetailsRepository, private val reissueCardRepository: TangemPayReissueCardRepository, + private val reissueTangemPayCardUseCase: ReissueTangemPayCardUseCase, private val uiMessageSender: UiMessageSender, private val analytics: AnalyticsEventHandler, ) : Model() { @@ -72,15 +75,13 @@ internal class TangemPayReissueCardModel @Inject constructor( analytics.send(TangemPayAnalyticsEvents.ReplaceCardConfirmed()) state.update { it.copy(isReissuingInProgress = true) } modelScope.launch { - reissueCardRepository.reissueCard( + reissueTangemPayCardUseCase( userWalletId = params.userWalletId, cardId = params.cardId, ).onLeft { uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong))) - onDismiss() - }.onRight { order -> - params.listener.onReissueOrderCreate(order) } + onDismiss() }.saveIn(reissueJobHolder) } @@ -96,7 +97,7 @@ internal class TangemPayReissueCardModel @Inject constructor( val error = if (fee == null || cardBalance == null) { TangemPayReissueCardError.InitialDataLoading - } else if (cardBalance.availableForWithdrawal < fee.amount) { + } else if (cardBalance.fiatBalance < fee.amount) { TangemPayReissueCardError.InsufficientFunds } else { null diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDataTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDataTransformer.kt new file mode 100644 index 0000000000..c81ee51011 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDataTransformer.kt @@ -0,0 +1,30 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf + +internal class TangemPayCardDataTransformer( + private val card: TangemPayCard, + private val onCardClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + val updatedCard = TangemPayDetailsBalanceBlockState.Card( + lastDigits = card.lastDigits, + onClick = onCardClick, + isReissuing = card.isReissuing, + ) + val cardsBlockState = prevState.balanceBlockState.cardsBlockState?.copy( + cards = persistentListOf(updatedCard), + ) + val newBalanceBlockState = when (val bs = prevState.balanceBlockState) { + is TangemPayDetailsBalanceBlockState.Loading -> bs.copy(cardsBlockState = cardsBlockState) + is TangemPayDetailsBalanceBlockState.Content -> bs.copy(cardsBlockState = cardsBlockState) + is TangemPayDetailsBalanceBlockState.Error -> bs.copy(cardsBlockState = cardsBlockState) + } + return prevState.copy(balanceBlockState = newBalanceBlockState) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index 0a58c2672a..5a21bce025 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -50,7 +50,6 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TangemPayTestTags -import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.DisplayNameState @@ -98,7 +97,7 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M shape = RoundedCornerShape(16.dp), ), ) { - if (shouldShowDetails && state.isActive) { + if (shouldShowDetails) { TangemPayCardDetailsShownBlock( cardNumber = state.number, expiry = state.expiry, @@ -148,7 +147,7 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif ) } - if (state.isActive) { + if (state.isActionsAvailable) { ConstraintLayout( modifier = Modifier .align(Alignment.BottomCenter) @@ -273,11 +272,7 @@ private fun EditingCardDisplayName(state: DisplayNameState.Editing, modifier: Mo BasicTextField( value = state.editingValue, - onValueChange = { newValue -> - if (newValue.text.length <= CardDisplayName.MAX_LENGTH) { - state.onValueChanged(newValue) - } - }, + onValueChange = state.onValueChanged, modifier = modifier .width(textWidthDp.coerceAtLeast(1.dp)) .focusRequester(focusRequester), @@ -285,7 +280,13 @@ private fun EditingCardDisplayName(state: DisplayNameState.Editing, modifier: Mo singleLine = true, cursorBrush = SolidColor(TangemTheme.colors.text.constantWhite), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { state.onSubmit() }), + keyboardActions = KeyboardActions( + onDone = if (state.isSubmitEnabled) { + { state.onSubmit() } + } else { + null + }, + ), decorationBox = { innerTextField -> Box { if (state.editingValue.text.isEmpty()) { @@ -481,6 +482,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide displayName = "Tangem", editingValue = TextFieldValue(text = "movet", selection = TextRange("movet".length)), onValueChanged = {}, + isSubmitEnabled = true, onSubmit = {}, onDismiss = {}, ), @@ -500,6 +502,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide displayName = "Tangem", editingValue = TextFieldValue(text = ""), onValueChanged = {}, + isSubmitEnabled = true, onSubmit = {}, onDismiss = {}, ), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt index 5217c1be53..f225204b8b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -24,10 +24,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.util.fastForEach import com.tangem.core.ui.components.appbar.AppBarWithBackButton -import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -76,23 +73,25 @@ internal fun TangemPayCardPageScreen( state = cardDetailsState, ) } - if (state.addToWalletBlockState != null) { - cardPageItem(key = "GooglePay") { - TangemPayAddToWalletBlock(state = state.addToWalletBlockState) - } - } - cardPageItem(key = "Limit") { - TangemPayDailyLimitBlock(state = state.dailyLimitState) - } - if (state.dailyLimitState == TangemPayDailyLimitBlockState.Error) { - cardPageItem(key = "LimitError") { - TangemPayDailyLimitErrorBlock() - } - } - cardPageItem(key = "Settings") { - if (state.isReissueInProgress) { + if (state.isReissueInProgress) { + cardPageItem(key = "Reissue") { TangemPayReplacingCardBlock() - } else { + } + } else { + if (state.addToWalletBlockState != null) { + cardPageItem(key = "GooglePay") { + TangemPayAddToWalletBlock(state = state.addToWalletBlockState) + } + } + cardPageItem(key = "Limit") { + TangemPayDailyLimitBlock(state = state.dailyLimitState) + } + if (state.dailyLimitState == TangemPayDailyLimitBlockState.Error) { + cardPageItem(key = "LimitError") { + TangemPayDailyLimitErrorBlock() + } + } + cardPageItem(key = "Settings") { TangemPayCardPageSettingsBlock(settings = state.settings) } } @@ -100,19 +99,6 @@ internal fun TangemPayCardPageScreen( } } -@Composable -private fun TangemPayReplacingCardBlock(modifier: Modifier = Modifier) { - Notification( - modifier = modifier, - config = NotificationConfig( - iconResId = com.tangem.core.ui.R.drawable.ic_update_32, - iconTint = NotificationConfig.IconTint.Accent, - title = resourceReference(R.string.tangempay_reissue_card_in_progress), - subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description), - ), - ) -} - @Composable private fun TangemPayCardPageSettingsBlock( settings: ImmutableList, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 901e8a5428..37747cd1d6 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastForEach import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.ui.components.RectangleShimmer @@ -106,6 +107,7 @@ internal fun TangemPayDetailsScreen( TangemPayDetailsBalanceBlock( modifier = Modifier .padding(horizontal = TangemTheme.dimens.spacing16) + .padding(top = 12.dp) .fillMaxWidth(), state = state.balanceBlockState, isBalanceHidden = state.isBalanceHidden, @@ -113,30 +115,45 @@ internal fun TangemPayDetailsScreen( SpacerH12() }, ) - if (state.addToWalletBlockState != null) { + + if (state.balanceBlockState.cardsBlockState?.cards?.fastAny { it.isReissuing } == true) { item( - key = AddToWalletBlockState::class.java, + key = "REISSUE_MESSAGE", content = { - TangemPayAddToWalletBlock( - state = state.addToWalletBlockState, - modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), - ) - }, - ) - } - if (state.accountDeactivatedNotificationConfig != null) { - item( - key = "DEACTIVATION_MESSAGE", - content = { - Notification( - modifier = modifier + TangemPayReplacingCardBlock( + modifier = Modifier .padding(horizontal = TangemTheme.dimens.spacing16) .fillMaxWidth(), - config = state.accountDeactivatedNotificationConfig, ) SpacerH12() }, ) + } else { + if (state.addToWalletBlockState != null) { + item( + key = AddToWalletBlockState::class.java, + content = { + TangemPayAddToWalletBlock( + state = state.addToWalletBlockState, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + ) + }, + ) + } + if (state.accountDeactivatedNotificationConfig != null) { + item( + key = "DEACTIVATION_MESSAGE", + content = { + Notification( + modifier = modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + config = state.accountDeactivatedNotificationConfig, + ) + SpacerH12() + }, + ) + } } if (state.accountDeactivatedNotificationConfig == null) { with(expressTransactionsComponent) { @@ -287,17 +304,25 @@ private fun TangemPayCardItem(card: TangemPayDetailsBalanceBlockState.Card, modi ) { Image( modifier = Modifier.fillMaxSize(), - painter = painterResource(R.drawable.img_visa_card_48x32), + painter = painterResource( + if (card.isReissuing) { + R.drawable.img_visa_card_inactive_48_32 + } else { + R.drawable.img_visa_card_48_32 + }, + ), contentDescription = null, ) - Text( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(4.dp, bottom = 2.dp), - text = card.lastDigits, - style = TangemTheme.typography.overline.copy(letterSpacing = 0.sp), - color = TangemTheme.colors.text.constantWhite, - ) + if (!card.isReissuing) { + Text( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(4.dp, bottom = 2.dp), + text = card.lastDigits, + style = TangemTheme.typography.overline.copy(letterSpacing = 0.sp), + color = TangemTheme.colors.text.constantWhite, + ) + } } } @@ -422,8 +447,8 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider { Action.UnlockWallet( selectedWallet = selectedWallet, - unlockedWallets = wallets.filterNot(UserWallet::isLocked), + unlockedWallets = getJustUnlockedWallets(state = state, wallets = wallets), ) } isAnyWalletNameChanged(state, wallets) -> { @@ -232,11 +232,28 @@ internal class WalletsUpdateActionResolver @Inject constructor( private fun isAnyWalletUnlocked(state: WalletScreenState, wallets: List): Boolean { return state.wallets.any { walletState -> val wallet = wallets.firstOrNull { it.walletId == walletState.walletCardState.id } ?: return@any false - !wallet.isLocked && - (walletState is WalletState.MultiCurrency.Locked || walletState is WalletState.SingleCurrency.Locked) + wallet.isJustUnlockedFrom(walletState) } } + /** + * Wallets that transitioned from a locked UI state to unlocked data state in this update cycle. + * Does not include wallets that were already unlocked before — otherwise downstream handlers + * (e.g. [com.tangem.feature.wallet.child.wallet.model.WalletModel.unlockWallet]) would re-fetch + * already-loaded wallets and emit redundant transformer state changes. + */ + private fun getJustUnlockedWallets(state: WalletScreenState, wallets: List): List { + return state.wallets.mapNotNull { walletState -> + val wallet = wallets.firstOrNull { it.walletId == walletState.walletCardState.id } + wallet?.takeIf { it.isJustUnlockedFrom(walletState) } + } + } + + private fun UserWallet.isJustUnlockedFrom(walletState: WalletState): Boolean { + return !isLocked && + (walletState is WalletState.MultiCurrency.Locked || walletState is WalletState.SingleCurrency.Locked) + } + private fun isSelectedWalletCardsCountChanged(state: WalletScreenState, selectedWallet: UserWallet): Boolean { if (selectedWallet !is UserWallet.Cold) return false val prevSelectedWallet = state.getPrevSelectedWallet() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt index d56549b7fd..ad7df36052 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt @@ -8,12 +8,13 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.* import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.OpenBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.WalletScreenStateTransformer +import com.tangem.feature.wallet.presentation.wallet.state.transformers.WalletStateTransformer import com.tangem.utils.extensions.indexOfFirstOrNull +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject import javax.inject.Singleton @@ -41,7 +42,14 @@ internal class WalletStateController @Inject constructor( } fun update(transformer: WalletScreenStateTransformer) { - TangemLogger.d("Applying: ${transformer::class.simpleName}") + val walletStateTransformer = transformer as? WalletStateTransformer + val maybeWalletId = if (walletStateTransformer != null) { + " for ${walletStateTransformer.userWalletId}" + } else { + "" + } + + TangemLogger.d("Applying: ${transformer::class.simpleName}$maybeWalletId") mutableUiState.update(function = transformer::transform) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt index f74ded9240..5b4c0c0404 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt @@ -67,7 +67,7 @@ internal class UnlockWalletTransformer( is WalletState.MultiCurrency.Content, is WalletState.SingleCurrency.Content, -> { - TangemLogger.e("Impossible to unlock wallet with not locked state") + TangemLogger.e("createLoadingState: Impossible to unlock wallet with not locked state") prevState } } @@ -79,7 +79,7 @@ internal class UnlockWalletTransformer( userWallet = unlockedWallet, ) is WalletUM.Content -> { - TangemLogger.e("Impossible to unlock wallet with not locked state") + TangemLogger.e("createLoadingState2: Impossible to unlock wallet with not locked state") walletUM } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/WalletStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/WalletStateTransformer.kt index 2790e44084..437b7f9493 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/WalletStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/WalletStateTransformer.kt @@ -7,7 +7,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM import kotlinx.collections.immutable.toImmutableList internal abstract class WalletStateTransformer( - protected val userWalletId: UserWalletId, + internal val userWalletId: UserWalletId, ) : WalletScreenStateTransformer { abstract fun transform(prevState: WalletState): WalletState diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt index d7f37ae607..f34c06ef04 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt @@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert import androidx.compose.ui.text.SpanStyle import com.tangem.common.ui.R import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format @@ -66,7 +67,11 @@ internal class TangemPayMainBlockConverter( is PaymentAccountStatusValue.Loaded -> { val card = statusValue.cards.firstOrNull() ?: return TangemPayMainUM.TemporaryUnavailable TangemPayMainUM.Content( - subtitle = stringReference("*${card.lastDigits}"), + subtitle = if (card.isReissuing) { + resourceReference(R.string.tangempay_status_replacing) + } else { + stringReference("*${card.lastDigits}") + }, isBalanceFlickering = statusValue.source == StatusSource.CACHE, balance = getBalanceText( currencyCode = statusValue.currencyCode, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt index a440c34a7f..f0857fa6db 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt @@ -14,6 +14,7 @@ import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.utils.coroutines.combine7 +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -22,6 +23,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import java.math.BigDecimal /** @@ -46,38 +49,69 @@ internal class AccountListSubscriber @AssistedInject constructor( override val isAddAndManageTokensEnabled: Boolean get() = walletFeatureToggles.isAddAndManageTokensEnabled - override fun create(coroutineScope: CoroutineScope): Flow<*> = combine7( - flow1 = getAccountStatusListFlow(), - flow2 = getAppCurrencyFlow(), - flow3 = accountDependencies.expandedAccountsHolder.expandedAccounts(userWallet), - flow4 = accountDependencies.isAccountsModeEnabledUseCase(), - flow5 = yieldSupplyApyFlow(), - flow6 = yieldSupplyGetShouldShowMainPromoFlow(), - flow7 = stakingAvailabilityFlow(), - ) { - accountList, appCurrency, expandedAccounts, isAccountMode, - yieldSupplyApyMap, shouldShowMainPromo, stakingAvailabilityMap, - -> - if (designFeatureToggles.isRedesignEnabled) { - updateState2( - accountList = accountList, - appCurrency = appCurrency, - expandedAccounts = expandedAccounts, - isAccountMode = isAccountMode, - yieldSupplyApyMap = yieldSupplyApyMap, - stakingAvailabilityMap = stakingAvailabilityMap, - shouldShowMainPromo = shouldShowMainPromo, - ) - } else { - updateState( - accountList = accountList, - appCurrency = appCurrency, - expandedAccounts = expandedAccounts, - isAccountMode = isAccountMode, - yieldSupplyApyMap = yieldSupplyApyMap, - stakingAvailabilityMap = stakingAvailabilityMap, - shouldShowMainPromo = shouldShowMainPromo, + override fun create(coroutineScope: CoroutineScope): Flow<*> { + val walletId = userWallet.walletId.stringValue + TangemLogger.i("$TAG[$walletId]: create() called, building combine7") + return combine7( + flow1 = getAccountStatusListFlow() + .onStart { TangemLogger.i("$TAG[$walletId]: flow1 accountStatusList subscribed") } + .onEach { list -> + val count = list.flattenCurrencies().size + TangemLogger.i("$TAG[$walletId]: flow1 accountStatusList emitted (currencies=$count)") + }, + flow2 = getAppCurrencyFlow() + .onStart { TangemLogger.i("$TAG[$walletId]: flow2 appCurrency subscribed") } + .onEach { TangemLogger.i("$TAG[$walletId]: flow2 appCurrency emitted=${it.code}") }, + flow3 = accountDependencies.expandedAccountsHolder.expandedAccounts(userWallet) + .onStart { TangemLogger.i("$TAG[$walletId]: flow3 expandedAccounts subscribed") } + .onEach { TangemLogger.i("$TAG[$walletId]: flow3 expandedAccounts emitted (size=${it.size})") }, + flow4 = accountDependencies.isAccountsModeEnabledUseCase() + .onStart { TangemLogger.i("$TAG[$walletId]: flow4 isAccountsModeEnabled subscribed") } + .onEach { TangemLogger.i("$TAG[$walletId]: flow4 isAccountsModeEnabled emitted=$it") }, + flow5 = yieldSupplyApyFlow() + .onStart { TangemLogger.i("$TAG[$walletId]: flow5 yieldSupplyApy subscribed") } + .onEach { TangemLogger.i("$TAG[$walletId]: flow5 yieldSupplyApy emitted (size=${it.size})") }, + flow6 = yieldSupplyGetShouldShowMainPromoFlow() + .onStart { TangemLogger.i("$TAG[$walletId]: flow6 shouldShowMainPromo subscribed") } + .onEach { TangemLogger.i("$TAG[$walletId]: flow6 shouldShowMainPromo emitted=$it") }, + flow7 = stakingAvailabilityFlow() + .onStart { TangemLogger.i("$TAG[$walletId]: flow7 stakingAvailability subscribed") } + .onEach { TangemLogger.i("$TAG[$walletId]: flow7 stakingAvailability emitted (size=${it.size})") }, + ) { + accountList, appCurrency, expandedAccounts, isAccountMode, + yieldSupplyApyMap, shouldShowMainPromo, stakingAvailabilityMap, + -> + TangemLogger.i( + "$TAG[$walletId]: combine7 transform fired — " + + "currencies=${accountList.flattenCurrencies().size}, " + + "appCurrency=${appCurrency.code}, " + + "expanded=${expandedAccounts.size}, " + + "isAccountMode=$isAccountMode, " + + "apyMap=${yieldSupplyApyMap.size}, " + + "promo=$shouldShowMainPromo, " + + "stakingMap=${stakingAvailabilityMap.size}", ) + if (designFeatureToggles.isRedesignEnabled) { + updateState2( + accountList = accountList, + appCurrency = appCurrency, + expandedAccounts = expandedAccounts, + isAccountMode = isAccountMode, + yieldSupplyApyMap = yieldSupplyApyMap, + stakingAvailabilityMap = stakingAvailabilityMap, + shouldShowMainPromo = shouldShowMainPromo, + ) + } else { + updateState( + accountList = accountList, + appCurrency = appCurrency, + expandedAccounts = expandedAccounts, + isAccountMode = isAccountMode, + yieldSupplyApyMap = yieldSupplyApyMap, + stakingAvailabilityMap = stakingAvailabilityMap, + shouldShowMainPromo = shouldShowMainPromo, + ) + } } } @@ -104,4 +138,8 @@ internal class AccountListSubscriber @AssistedInject constructor( interface Factory { fun create(userWallet: UserWallet): AccountListSubscriber } + + private companion object { + const val TAG = "AccountListSubscriber" + } } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt index 1cbf1b303f..8cc09abf5b 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt @@ -25,6 +25,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase +import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyGetContractAddressUseCase @@ -141,59 +142,69 @@ internal class YieldSupplyApproveModel @Inject constructor( userWallet = userWallet, network = cryptoCurrency.network, ).fold( - ifLeft = { error -> - TangemLogger.e(error.toString()) - uiState.update(YieldSupplyTransactionReadyTransformer) - analyticsEventHandler.send( - YieldSupplyAnalytics.EarnErrors( - action = YieldSupplyAnalytics.Action.Approve, - errorDescription = error.getAnalyticsDescription(), - ), - ) - yieldSupplyAlertFactory.getSendTransactionErrorState( - error = error, - popBack = params.callback::onDismissClick, - onFailedTxEmailClick = { errorMessage -> - modelScope.launch(dispatchers.default) { - yieldSupplyAlertFactory.onFailedTxEmailClick( - userWallet = userWallet, - cryptoCurrency = cryptoCurrency, - errorMessage = errorMessage, - ) - } - }, - ) - params.callback.onTransactionProgress(false) - }, - ifRight = { txHash -> - yieldSupplyPendingTracker.addPending( - userWalletId = userWallet.walletId, - cryptoCurrency = cryptoCurrency, - txIds = listOf(txHash), - ) - val event = AnalyticsParam.TxSentFrom.Earning( - blockchain = cryptoCurrency.network.name, - token = cryptoCurrency.symbol, - feeType = AnalyticsParam.FeeType.Normal, - feeToken = feeCryptoCurrencyStatus.currency.symbol, - ) - analyticsEventHandler.send( - Basic.TransactionSent( - sentFrom = event, - memoType = Basic.TransactionSent.MemoType.Null, - ), - ) - analyticsEventHandler.send(YieldSupplyAnalytics.ApprovalAction( - token = cryptoCurrency.symbol, - blockchain = cryptoCurrency.network.name, - action = YieldSupplyAnalytics.Action.Approve, - )) - params.callback.onTransactionSent() - }, + ifLeft = ::onTransactionError, + ifRight = { onTransactionSuccess(it) }, ) } } + private fun onTransactionError(error: SendTransactionError) { + TangemLogger.e(error.toString()) + uiState.update(YieldSupplyTransactionReadyTransformer) + analyticsEventHandler.send( + YieldSupplyAnalytics.EarnErrors( + action = YieldSupplyAnalytics.Action.Approve, + errorDescription = error.getAnalyticsDescription(), + ), + ) + yieldSupplyAlertFactory.getSendTransactionErrorState( + error = error, + popBack = params.callback::onDismissClick, + onFailedTxEmailClick = { errorMessage -> + modelScope.launch(dispatchers.default) { + yieldSupplyAlertFactory.onFailedTxEmailClick( + userWallet = userWallet, + cryptoCurrency = cryptoCurrency, + errorMessage = errorMessage, + ) + } + }, + ) + params.callback.onTransactionProgress(false) + } + + private suspend fun onTransactionSuccess(txHash: String) { + yieldSupplyPendingTracker.addPending( + userWalletId = userWallet.walletId, + cryptoCurrency = cryptoCurrency, + txIds = listOf(txHash), + ) + val feeAssetType = if (feeCryptoCurrencyStatus.currency is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } + val event = AnalyticsParam.TxSentFrom.Earning( + blockchain = cryptoCurrency.network.name, + token = cryptoCurrency.symbol, + feeType = AnalyticsParam.FeeType.Normal, + feeToken = feeCryptoCurrencyStatus.currency.symbol, + feeAssetType = feeAssetType, + ) + analyticsEventHandler.send( + Basic.TransactionSent( + sentFrom = event, + memoType = Basic.TransactionSent.MemoType.Null, + ), + ) + analyticsEventHandler.send(YieldSupplyAnalytics.ApprovalAction( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + action = YieldSupplyAnalytics.Action.Approve, + )) + params.callback.onTransactionSent() + } + private fun subscribeOnCurrencyStatusUpdates() { modelScope.launch { feeCryptoCurrencyStatusFlow.update { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index c850cb0581..3a08b50d1e 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -17,6 +17,7 @@ import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSy import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency +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.models.wallet.isHotWallet @@ -267,11 +268,18 @@ internal class YieldSupplyStartEarningModel @Inject constructor( cryptoCurrency = cryptoCurrency, yieldSupplyPendingStatus = YieldSupplyPendingStatus.Enter(txsData), ) + val feeCurrency = feeCryptoCurrencyStatusFlow.value.currency + val feeAssetType = if (feeCurrency is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } val event = AnalyticsParam.TxSentFrom.Earning( blockchain = cryptoCurrency.network.name, token = cryptoCurrency.symbol, feeType = AnalyticsParam.FeeType.Normal, - feeToken = feeCryptoCurrencyStatusFlow.value.currency.symbol, + feeToken = feeCurrency.symbol, + feeAssetType = feeAssetType, ) analytics.send( YieldSupplyAnalytics.FundsEarned( diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt index 0a339baa9b..3123618250 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt @@ -195,11 +195,17 @@ internal class YieldSupplyStopEarningModel @Inject constructor( referralId = appsFlyerStore.get()?.refcode, ), ) + val feeAssetType = if (feeCryptoCurrencyStatus.currency is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } val event = AnalyticsParam.TxSentFrom.Earning( blockchain = cryptoCurrency.network.name, token = cryptoCurrency.symbol, feeType = AnalyticsParam.FeeType.Normal, feeToken = feeCryptoCurrencyStatus.currency.symbol, + feeAssetType = feeAssetType, ) analytics.send( Basic.TransactionSent( diff --git a/libs/visa/src/main/kotlin/com/tangem/lib/visa/utils/Constants.kt b/libs/visa/src/main/kotlin/com/tangem/lib/visa/utils/Constants.kt index 06e365e4ec..1331b995b3 100644 --- a/libs/visa/src/main/kotlin/com/tangem/lib/visa/utils/Constants.kt +++ b/libs/visa/src/main/kotlin/com/tangem/lib/visa/utils/Constants.kt @@ -10,9 +10,6 @@ internal object Constants { const val GAS_LIMIT = 500_000_000L const val PRIVATE_KEY_LENGTH = 32 - const val VISA_API_PROD_URL = "https://payapi.tangem-tech.com/api/v1/" - const val VISA_API_DEV_URL = "[REDACTED_ENV_URL]" - const val NETWORK_TIMEOUT_SECONDS = 65L const val NETWORK_LOGS_TAG = "VisaNetworkLogs" } \ No newline at end of file