diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt index ba746abcc9..6430517e73 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt @@ -66,6 +66,12 @@ fun AccountIcon( fun AccountIcon(name: TextReference, icon: AccountIconUM, size: AccountIconSize, modifier: Modifier = Modifier) { when (icon) { is AccountIconUM.Payment -> PaymentAccountIcon(size = size, modifier = modifier) + is AccountIconUM.Virtual -> AccountResIcon( + resId = icon.icon.getResId(), + color = icon.color.getUiColor(), + size = size, + modifier = modifier, + ) is AccountIconUM.CryptoPortfolio -> AccountIcon( name = name, icon = icon, diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconUM.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconUM.kt index ca79e78402..caa4e584e5 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconUM.kt @@ -7,4 +7,9 @@ sealed class AccountIconUM { data class CryptoPortfolio(val value: Icon, val color: Color) : AccountIconUM() data object Payment : AccountIconUM() + + data object Virtual : AccountIconUM() { + val icon: Icon = Icon.Safe + val color: Color = Color.VitalGreen + } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitle.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitle.kt index a77848f899..67f5d1cda6 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitle.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitle.kt @@ -90,6 +90,10 @@ private fun PreviewAccountTitle() { accountTitleUM = AccountTitleUM.Account.payment(prefixText = stringReference(StringsSigns.DOT)), modifier = Modifier.padding(4.dp), ) + AccountTitle( + accountTitleUM = AccountTitleUM.Account.virtual(prefixText = stringReference(StringsSigns.DOT)), + modifier = Modifier.padding(4.dp), + ) } } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitleUM.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitleUM.kt index f5cdef91d2..87ac773931 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitleUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitleUM.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable 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 /** * A sealed interface representing the title of an account, which can be either a simple text @@ -31,6 +32,14 @@ sealed interface AccountTitleUM { icon = AccountIconUM.Payment, ) } + + fun virtual(prefixText: TextReference = TextReference.EMPTY): Account { + return Account( + prefixText = prefixText, + name = stringReference("Virtual account"), + icon = AccountIconUM.Virtual, + ) + } } } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountAccountConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountAccountConverter.kt index d347bf2908..4f105a05d8 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountAccountConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountAccountConverter.kt @@ -31,6 +31,7 @@ class AmountAccountConverter( return when (account) { is Account.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(account.icon) is Account.Payment -> AccountIconUM.Payment + is Account.Virtual -> AccountIconUM.Virtual } } } \ No newline at end of file diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultAccountTxHistoryFetcher.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultAccountTxHistoryFetcher.kt index 89cd675194..0c091eca9a 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultAccountTxHistoryFetcher.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultAccountTxHistoryFetcher.kt @@ -55,6 +55,8 @@ internal class DefaultAccountTxHistoryFetcher @AssistedInject constructor( .filterIsInstance() .controlFetchersForCryptoAccount() is Account.Payment -> controlFetchersForPaymentAccount() + // Virtual account tx-history isn't wired yet (separate task) — no express fetchers for now. + is Account.Virtual -> emptyFlow() } controlFetchersFlow.launchIn(this) diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt index 9775180441..04b2a4bddf 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt @@ -103,6 +103,7 @@ data class AccountList private constructor( when (account) { is Account.CryptoPortfolio -> account.cryptoCurrencies is Account.Payment -> emptyList() + is Account.Virtual -> emptyList() } } } @@ -156,6 +157,12 @@ data class AccountList private constructor( "$tag: The number of payment accounts must not exceed $MAX_PAYMENT_ACCOUNTS_COUNT" } + @Serializable + data object ExceedsMaxVirtualAccountsCount : Error { + override fun toString(): String = + "$tag: The number of virtual accounts must not exceed $MAX_VIRTUAL_ACCOUNTS_COUNT" + } + @Serializable data object DuplicateAccountIds : Error { override fun toString(): String = "$tag: Account list contains duplicate account IDs" @@ -175,6 +182,7 @@ data class AccountList private constructor( companion object { const val MAX_PAYMENT_ACCOUNTS_COUNT = 1 + const val MAX_VIRTUAL_ACCOUNTS_COUNT = 1 const val MAX_CRYPTO_PORTFOLIO_ACCOUNTS_COUNT = 20 const val MAX_ARCHIVED_ACCOUNTS_COUNT = 1000 private const val MAX_MAIN_ACCOUNTS_COUNT = 1 @@ -200,6 +208,9 @@ data class AccountList private constructor( val paymentAccounts = accounts.filterIsInstance() ensure(paymentAccounts.size <= MAX_PAYMENT_ACCOUNTS_COUNT) { Error.ExceedsMaxPaymentAccountsCount } + val virtualAccounts = accounts.filterIsInstance() + ensure(virtualAccounts.size <= MAX_VIRTUAL_ACCOUNTS_COUNT) { Error.ExceedsMaxVirtualAccountsCount } + val cryptoAccounts = accounts.filterIsInstance() ensure(cryptoAccounts.size <= MAX_CRYPTO_PORTFOLIO_ACCOUNTS_COUNT) { Error.ExceedsMaxAccountsCount } diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt index c93443f0a6..1e0a570fa5 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt @@ -63,5 +63,6 @@ fun AccountStatusList.hasMultiCurrencyAccount(): Boolean = accountStatuses.any { when (status) { is AccountStatus.CryptoPortfolio -> status.tokenList.flattenCurrencies().size > 1 is AccountStatus.Payment -> false + is AccountStatus.Virtual -> false } } \ No newline at end of file diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt index 2457408d52..daeaf4e42d 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt @@ -128,6 +128,14 @@ internal class AccountListTest { accounts = createAccounts(count = 21), expected = AccountList.Error.ExceedsMaxAccountsCount.left(), ), + CreateTestModel( + accounts = listOf( + Account.CryptoPortfolio.createMainAccount(userWalletId), + Account.Virtual(userWalletId), + Account.Virtual(userWalletId), + ), + expected = AccountList.Error.ExceedsMaxVirtualAccountsCount.left(), + ), CreateTestModel( accounts = listOf( createAccount(derivationIndex = 1), 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 1da0c170d6..88b02761a8 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 @@ -18,6 +18,7 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.VirtualAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -93,6 +94,10 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo private val logger = TangemLogger.withTag(TAG) + // VirtualAccount status pipeline lands in a follow-up PR; until then surface an unavailable status. + private val Account.Virtual.errorVirtualAccountStatus: AccountStatus.Virtual + get() = AccountStatus.Virtual(this, VirtualAccountStatusValue.Error.Unavailable) + override val fallback: Option = none() override fun produce(): Flow { @@ -185,6 +190,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo val accountStatuses = accountList.accounts.map { account -> when (account) { is Account.Payment -> paymentAccountStatus + is Account.Virtual -> account.errorVirtualAccountStatus is Account.CryptoPortfolio -> if (account.cryptoCurrencies.isEmpty()) { account.toEmptyAccountStatus() } else { @@ -410,6 +416,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo when (accountStatus) { is AccountStatus.CryptoPortfolio -> accountStatus.tokenList.totalFiatBalance is AccountStatus.Payment -> accountStatus.value.totalFiatBalance + is AccountStatus.Virtual -> accountStatus.value.totalFiatBalance } } } @@ -435,6 +442,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo ) } is Account.Payment -> null + is Account.Virtual -> null } }, totalAccounts = accountList.totalAccounts, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt index 79ab7d3f5a..521eb77597 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt @@ -176,7 +176,7 @@ sealed interface Account { } @Serializable - data class Payment( + data class Payment private constructor( override val accountId: AccountId, ) : Account { override val accountName: AccountName.Custom = AccountName.Custom("Payment").getOrElse { @@ -189,10 +189,26 @@ sealed interface Account { } } } + + @Serializable + data class Virtual private constructor( + override val accountId: AccountId, + ) : Account { + override val accountName: AccountName.Custom = AccountName.Custom("Virtual").getOrElse { + error("Can not create account name for Virtual account with userWalletId = ${accountId.userWalletId}") + } + + companion object { + operator fun invoke(userWalletId: UserWalletId): Virtual { + return Virtual(accountId = AccountId.forVirtualAccount(userWalletId = userWalletId)) + } + } + } } val Account.derivationIndex: DerivationIndex? get() = when (this) { is Account.CryptoPortfolio -> derivationIndex is Account.Payment -> null + is Account.Virtual -> null } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt index d4c73a73ef..8cd186ee2b 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt @@ -38,6 +38,7 @@ data class AccountId private constructor( companion object { const val PaymentAccountIdPrefix = "payment_" + const val VirtualAccountIdPrefix = "virtual_" private val sha256Digest: MessageDigest by lazy { MessageDigest.getInstance("SHA-256") } private val hexRegex = Regex("^[a-fA-F0-9]{64}$") @@ -77,5 +78,9 @@ data class AccountId private constructor( fun forPaymentAccount(userWalletId: UserWalletId): AccountId { return AccountId(value = "$PaymentAccountIdPrefix$userWalletId", userWalletId = userWalletId) } + + fun forVirtualAccount(userWalletId: UserWalletId): AccountId { + return AccountId(value = "$VirtualAccountIdPrefix$userWalletId", userWalletId = userWalletId) + } } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountStatus.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountStatus.kt index 748f8de199..88dc2de961 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountStatus.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountStatus.kt @@ -44,6 +44,12 @@ sealed interface AccountStatus { override val account: Account.Payment, val value: PaymentAccountStatusValue, ) : AccountStatus + + @Serializable + data class Virtual( + override val account: Account.Virtual, + val value: VirtualAccountStatusValue, + ) : AccountStatus } fun Iterable.filterCryptoPortfolio(): List { diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountStatusValue.kt new file mode 100644 index 0000000000..d9b46eafa9 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountStatusValue.kt @@ -0,0 +1,230 @@ +package com.tangem.domain.models.account + +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.serialization.SerializedBigDecimal +import kotlinx.serialization.Serializable +import java.math.BigDecimal + +/** + * Represents the various states a virtual account (VA) can have, encapsulating different information based on + * the state. Mirrors [PaymentAccountStatusValue] but carries VA-specific states (no card-related variants). + * + * @property source The source of the status information. + */ +@Serializable +sealed class VirtualAccountStatusValue { + abstract val source: StatusSource + + /** The total fiat balance associated with this status. */ + val totalFiatBalance: TotalFiatBalance + get() = when (this) { + is Empty, + is NotCreated, + is UnderReview, + is Provisioning, + is CountryNotSupported, + is Error, + -> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source) + is Loading -> TotalFiatBalance.Loading + is Active -> { + val rate = fiatRate ?: return TotalFiatBalance.Failed + TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source) + } + } + + /** + * Copies the status with a new [source]. + * + * @param source The new source of the status information. + */ + fun copySealed(source: StatusSource): VirtualAccountStatusValue { + return when (this) { + is UnderReview -> copy(source = source) + is Provisioning -> copy(source = source) + is Active -> copy(source = source) + is Loading, + is Empty, + is NotCreated, + is CountryNotSupported, + is Error, + -> this + } + } + + /** Represents an empty virtual account status when no specific state is available. */ + @Serializable + data object Empty : VirtualAccountStatusValue() { + override val source: StatusSource = StatusSource.ACTUAL + } + + /** Represents the Loading state of a virtual account, typically while fetching its details. */ + @Serializable + data object Loading : VirtualAccountStatusValue() { + override val source: StatusSource = StatusSource.ACTUAL + } + + /** Represents a state where the virtual account has not been created yet. */ + @Serializable + data object NotCreated : VirtualAccountStatusValue() { + override val source: StatusSource = StatusSource.ACTUAL + } + + /** + * Represents a state where the virtual account is under review (KYC). + * + * @property source The source of the status information. + * @property kycStatus The current KYC status. + * @property customerId The unique identifier of the customer. + */ + @Serializable + data class UnderReview( + override val source: StatusSource, + val kycStatus: KycStatus, + val customerId: String, + ) : VirtualAccountStatusValue() + + /** + * Represents a state where the virtual account is being provisioned on the backend (e.g. via Rain), + * after KYC approval and terms acceptance. + * + * @property source The source of the status information. + */ + @Serializable + data class Provisioning(override val source: StatusSource) : VirtualAccountStatusValue() + + /** Represents a state where the user's country is not eligible for a virtual account. */ + @Serializable + data object CountryNotSupported : VirtualAccountStatusValue() { + override val source: StatusSource = StatusSource.ACTUAL + } + + /** + * Represents a state where the virtual account is successfully loaded with complete information. + * + * @property source The source of the status information. + * @property customerId The unique identifier of the customer. + * @property currencyCode The code of the currency. + * @property depositAddress The on-chain 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. + * @property cryptoCurrency The crypto currency held in the account (e.g. USDC). + * @property fiatRate Exchange rate of [cryptoCurrency] to the app's selected fiat currency, + * or `null` if the quote is not yet available. When `null`, + * [totalFiatBalance] resolves to [TotalFiatBalance.Failed]. + */ + @Serializable + data class Active( + override val source: StatusSource, + val customerId: String, + val currencyCode: String, + val depositAddress: String?, + val fiatBalance: FiatBalance, + val cryptoBalance: CryptoBalance, + val availableForWithdrawal: SerializedBigDecimal, + val cryptoCurrency: CryptoCurrency.Token, + val fiatRate: SerializedBigDecimal?, + ) : VirtualAccountStatusValue() { + val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = cryptoCurrency, + value = buildCryptoCurrencyStatusValue( + amount = availableForWithdrawal, + fiatAmount = fiatBalance.availableBalance, + fiatRate = fiatRate, + depositAddress = cryptoBalance.depositAddress, + ), + ) + } + + /** Represents an error state for the virtual account status. */ + @Serializable + sealed class Error : VirtualAccountStatusValue() { + /** Error state indicating the device is exposed. */ + @Serializable + data object ExposedDevice : Error() { + override val source: StatusSource = StatusSource.ACTUAL + } + + /** Error state indicating the account is unavailable. */ + @Serializable + data object Unavailable : Error() { + override val source: StatusSource = StatusSource.ACTUAL + } + + /** Error state indicating the account data is not synced. */ + @Serializable + data object NotSynced : Error() { + override val source: StatusSource = StatusSource.ACTUAL + } + } + + /** + * Represents the fiat balance of the virtual account. + * + * @property availableBalance The amount of available balance in fiat. + * @property currency The currency of the balance. + */ + @Serializable + data class FiatBalance(val availableBalance: SerializedBigDecimal, val currency: String) + + /** + * Represents the crypto balance of the virtual account. + * + * @property id The unique identifier of the crypto asset. + * @property chainId The identifier of the blockchain network. + * @property depositAddress The on-chain address for deposits. + * @property tokenContractAddress The contract address of the token. + * @property balance The amount of the crypto balance. + */ + @Serializable + data class CryptoBalance( + val id: String, + val chainId: Long, + val depositAddress: String, + val tokenContractAddress: String, + val balance: SerializedBigDecimal, + ) +} + +private fun buildCryptoCurrencyStatusValue( + amount: SerializedBigDecimal, + fiatAmount: SerializedBigDecimal, + fiatRate: SerializedBigDecimal?, + depositAddress: String, +): CryptoCurrencyStatus.Value { + val networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + type = NetworkAddress.Address.Type.Primary, + value = depositAddress, + ), + ) + return if (fiatRate != null) { + CryptoCurrencyStatus.Loaded( + amount = amount, + fiatAmount = fiatAmount, + fiatRate = fiatRate, + priceChange = BigDecimal.ZERO, + networkAddress = networkAddress, + sources = CryptoCurrencyStatus.Sources(), + pendingTransactions = emptySet(), + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + ) + } else { + CryptoCurrencyStatus.NoQuote( + amount = amount, + networkAddress = networkAddress, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + sources = CryptoCurrencyStatus.Sources(), + ) + } +} \ No newline at end of file diff --git a/domain/models/src/test/kotlin/com/tangem/domain/models/account/VirtualAccountStatusValueTest.kt b/domain/models/src/test/kotlin/com/tangem/domain/models/account/VirtualAccountStatusValueTest.kt new file mode 100644 index 0000000000..1693a9d596 --- /dev/null +++ b/domain/models/src/test/kotlin/com/tangem/domain/models/account/VirtualAccountStatusValueTest.kt @@ -0,0 +1,86 @@ +package com.tangem.domain.models.account + +import com.google.common.truth.Truth +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import io.mockk.mockk +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Verifies that [VirtualAccountStatusValue.Active] converts its fiat balance to the app's selected currency + * via [VirtualAccountStatusValue.Active.fiatRate] (mirror of the Payment account fix, [REDACTED_TASK_KEY]). + */ +class VirtualAccountStatusValueTest { + + private val cryptoCurrency = mockk(relaxed = true) + + private fun activeWith(fiatRate: BigDecimal?, balance: BigDecimal = BigDecimal("100")) = + VirtualAccountStatusValue.Active( + source = StatusSource.ACTUAL, + customerId = "customer", + currencyCode = "USD", + depositAddress = "0xabc", + fiatBalance = VirtualAccountStatusValue.FiatBalance(availableBalance = balance, currency = "USD"), + cryptoBalance = VirtualAccountStatusValue.CryptoBalance( + id = "usd-coin", + chainId = 137L, + depositAddress = "0xabc", + tokenContractAddress = "0xdef", + balance = balance, + ), + availableForWithdrawal = balance, + cryptoCurrency = cryptoCurrency, + fiatRate = fiatRate, + ) + + @Test + fun `totalFiatBalance converts balance via fiatRate when rate is present`() { + // Arrange + val rate = BigDecimal("0.9") + val active = activeWith(fiatRate = rate, balance = BigDecimal("100")) + + // Act + val result = active.totalFiatBalance + + // Assert + Truth.assertThat(result).isInstanceOf(TotalFiatBalance.Loaded::class.java) + Truth.assertThat((result as TotalFiatBalance.Loaded).amount) + .isEqualTo(BigDecimal("100").multiply(rate)) + } + + @Test + fun `totalFiatBalance is Failed when fiatRate is null`() { + // Arrange + val active = activeWith(fiatRate = null) + + // Act & Assert + Truth.assertThat(active.totalFiatBalance).isEqualTo(TotalFiatBalance.Failed) + } + + @Test + fun `cryptoCurrencyStatus is NoQuote when fiatRate is null`() { + // Arrange + val active = activeWith(fiatRate = null) + + // Act & Assert + Truth.assertThat(active.cryptoCurrencyStatus.value) + .isInstanceOf(CryptoCurrencyStatus.NoQuote::class.java) + } + + @Test + fun `cryptoCurrencyStatus is Loaded with the rate when fiatRate is present`() { + // Arrange + val rate = BigDecimal("0.9") + val active = activeWith(fiatRate = rate) + + // Act + val value = active.cryptoCurrencyStatus.value + + // Assert + Truth.assertThat(value).isInstanceOf(CryptoCurrencyStatus.Loaded::class.java) + Truth.assertThat((value as CryptoCurrencyStatus.Loaded).fiatRate).isEqualTo(rate) + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt index 85639c2e6b..3ca744cb50 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt @@ -87,6 +87,7 @@ internal class ChooseTokenListItemConverter( when (accountStatus) { is AccountStatus.CryptoPortfolio -> accountStatus.toPortfolioItem(params) is AccountStatus.Payment -> accountStatus.createPaymentAccountItem(params.expandedAccounts) + is AccountStatus.Virtual -> null } } .filter { portfolio -> portfolio.tokens.isNotEmpty() } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt index a8408116b9..e1e31a0728 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt @@ -5,12 +5,12 @@ import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.PortfolioSelectUM import com.tangem.common.ui.account.toUM import com.tangem.common.ui.addtoken.AddTokenUM +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference -import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase @@ -51,6 +51,7 @@ internal class OnrampAddTokenUiBuilder @Inject constructor( accountIcon = when (accountStatus) { is AccountStatus.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(accountStatus.account.icon) is AccountStatus.Payment -> AccountIconUM.Payment + is AccountStatus.Virtual -> AccountIconUM.Virtual } } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index e2cc71268c..a0dbd31b63 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -234,6 +234,7 @@ internal class SendDestinationModel @Inject constructor( when (accountStatus) { is AccountStatus.CryptoPortfolio -> accountStatus.getDestinationWalletUM(wallet) is AccountStatus.Payment -> listOfNotNull(accountStatus.getDestinationWalletUM(wallet)) + is AccountStatus.Virtual -> emptyList() } } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientWalletListConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientWalletListConverter.kt index 6ac08e8caf..47b90757be 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientWalletListConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientWalletListConverter.kt @@ -67,6 +67,7 @@ internal class SendRecipientWalletListConverter( account.icon, ) is Account.Payment -> AccountIconUM.Payment + is Account.Virtual -> AccountIconUM.Virtual }, prefixText = stringReference(StringsSigns.DOT), ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt index df9ff0e757..a3bf3f6243 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt @@ -110,6 +110,8 @@ internal class InitialCurrenciesResolver @Inject constructor( val currencyStatuses = when (accountStatus) { is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies() is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus) + // Virtual account isn't a swap source in the MVP (withdrawal reuses the send flow) + is AccountStatus.Virtual -> emptyList() } val availabilityStates = rampStateManager.availableForSwap( userWalletId, 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 fcde34f4db..0cd67e00e5 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 @@ -1571,6 +1571,8 @@ internal class SwapModel @Inject constructor( userWalletId = swapCurrencyStatus.userWalletId, cryptoCurrency = swapCurrency, ).map { (_, status) -> status } + // Virtual account isn't a swap source in the MVP (withdrawal reuses the send flow) + is Account.Virtual -> emptyFlow() }.distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes .onEach { currencyStatus -> 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 b7e207d65f..18be642bb1 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 @@ -1265,6 +1265,7 @@ internal class StateBuilder( return when (this) { is Account.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(icon) is Account.Payment -> AccountIconUM.Payment + is Account.Virtual -> AccountIconUM.Virtual } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index 9f2b1065b0..aca4366462 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -208,6 +208,7 @@ internal class SwapTransferStateBuilder @Inject constructor( return when (this) { is Account.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(icon) is Account.Payment -> AccountIconUM.Payment + is Account.Virtual -> AccountIconUM.Virtual } } diff --git a/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/VirtualAccountFeatureToggles.kt b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/VirtualAccountFeatureToggles.kt new file mode 100644 index 0000000000..d01bb74ff3 --- /dev/null +++ b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/VirtualAccountFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.features.virtualaccount + +interface VirtualAccountFeatureToggles { + val isVirtualAccountsEnabled: Boolean +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/build.gradle.kts b/features/virtual-accounts/details/impl/build.gradle.kts index 902b8634e2..3fec5d85d5 100644 --- a/features/virtual-accounts/details/impl/build.gradle.kts +++ b/features/virtual-accounts/details/impl/build.gradle.kts @@ -1,6 +1,8 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) id("configuration") } @@ -9,4 +11,13 @@ android { } dependencies { + implementation(projects.features.virtualAccounts.details.api) + + implementation(projects.core.configToggles) + + implementation(deps.compose.runtime) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) } \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/java/com/tangem/features/virtualaccount/onboarding/impl/DefaultVirtualAccountsFeatureToggles.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/DefaultVirtualAccountFeatureToggles.kt similarity index 58% rename from features/virtual-accounts/onboarding/impl/src/main/java/com/tangem/features/virtualaccount/onboarding/impl/DefaultVirtualAccountsFeatureToggles.kt rename to features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/DefaultVirtualAccountFeatureToggles.kt index f48911b2d9..5bab2f0a5d 100644 --- a/features/virtual-accounts/onboarding/impl/src/main/java/com/tangem/features/virtualaccount/onboarding/impl/DefaultVirtualAccountsFeatureToggles.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/DefaultVirtualAccountFeatureToggles.kt @@ -1,15 +1,12 @@ -package com.tangem.features.virtualaccount.onboarding.impl +package com.tangem.features.virtualaccount import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.features.virtualaccount.onboarding.api.VirtualAccountsFeatureToggles import javax.inject.Inject -internal class DefaultVirtualAccountsFeatureToggles -@Inject constructor( +internal class DefaultVirtualAccountFeatureToggles @Inject constructor( private val featureTogglesManager: FeatureTogglesManager, -) : VirtualAccountsFeatureToggles { - +) : VirtualAccountFeatureToggles { override val isVirtualAccountsEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.VIRTUAL_ACCOUNTS_ENABLED) } \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountDetailsModule.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountDetailsModule.kt new file mode 100644 index 0000000000..8e35f7eb24 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountDetailsModule.kt @@ -0,0 +1,23 @@ +package com.tangem.features.virtualaccount.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.virtualaccount.DefaultVirtualAccountFeatureToggles +import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object VirtualAccountDetailsModule { + + @Provides + @Singleton + fun provideVirtualAccountFeatureToggles( + featureTogglesManager: FeatureTogglesManager, + ): VirtualAccountFeatureToggles { + return DefaultVirtualAccountFeatureToggles(featureTogglesManager = featureTogglesManager) + } +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/api/src/main/java/com/tangem/features/virtualaccount/onboarding/api/VirtualAccountsFeatureToggles.kt b/features/virtual-accounts/onboarding/api/src/main/java/com/tangem/features/virtualaccount/onboarding/api/VirtualAccountsFeatureToggles.kt deleted file mode 100644 index 495785d40d..0000000000 --- a/features/virtual-accounts/onboarding/api/src/main/java/com/tangem/features/virtualaccount/onboarding/api/VirtualAccountsFeatureToggles.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.features.virtualaccount.onboarding.api - -interface VirtualAccountsFeatureToggles { - val isVirtualAccountsEnabled: Boolean -} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/java/com/tangem/features/virtualaccount/onboarding/impl/di/VirtualAccountsOnboardingModule.kt b/features/virtual-accounts/onboarding/impl/src/main/java/com/tangem/features/virtualaccount/onboarding/impl/di/VirtualAccountsOnboardingModule.kt deleted file mode 100644 index 18c5808c74..0000000000 --- a/features/virtual-accounts/onboarding/impl/src/main/java/com/tangem/features/virtualaccount/onboarding/impl/di/VirtualAccountsOnboardingModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.features.virtualaccount.onboarding.impl.di - -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.features.virtualaccount.onboarding.api.VirtualAccountsFeatureToggles -import com.tangem.features.virtualaccount.onboarding.impl.DefaultVirtualAccountsFeatureToggles -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object VirtualAccountsOnboardingModule { - - @Provides - @Singleton - fun provideVirtualAccountsFeatureToggles( - featureTogglesManager: FeatureTogglesManager, - ): VirtualAccountsFeatureToggles { - return DefaultVirtualAccountsFeatureToggles(featureTogglesManager) - } -} \ No newline at end of file