diff --git a/app/src/main/java/com/babylon/wallet/android/data/repository/tokenprice/FiatPriceRepository.kt b/app/src/main/java/com/babylon/wallet/android/data/repository/tokenprice/FiatPriceRepository.kt index c9d5f42965..dc59e272af 100644 --- a/app/src/main/java/com/babylon/wallet/android/data/repository/tokenprice/FiatPriceRepository.kt +++ b/app/src/main/java/com/babylon/wallet/android/data/repository/tokenprice/FiatPriceRepository.kt @@ -15,16 +15,20 @@ import com.babylon.wallet.android.data.repository.tokenprice.FiatPriceRepository import com.babylon.wallet.android.domain.RadixWalletException import com.radixdlt.sargon.NetworkId import com.radixdlt.sargon.ResourceAddress +import com.radixdlt.sargon.Timestamp import com.radixdlt.sargon.extensions.init import com.radixdlt.sargon.extensions.string +import com.radixdlt.sargon.extensions.toDecimal192 import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import okhttp3.ResponseBody import rdx.works.core.domain.assets.FiatPrice import rdx.works.core.domain.assets.SupportedCurrency import rdx.works.core.domain.resources.XrdResource +import rdx.works.core.domain.toDouble import rdx.works.peerdroid.di.IoDispatcher import timber.log.Timber +import java.time.OffsetDateTime import javax.inject.Inject import javax.inject.Qualifier import kotlin.random.Random @@ -156,6 +160,8 @@ class TestnetFiatPriceRepository @Inject constructor( "resource_rdx1tkk83magp3gjyxrpskfsqwkg4g949rmcjee4tu2xmw93ltw2cz94sq" ).map { ResourceAddress.init(it) } + private val testnetPricesCache: MutableMap = mutableMapOf() + override suspend fun updateFiatPrices(currency: SupportedCurrency): Result { if (!BuildConfig.EXPERIMENTAL_FEATURES_ENABLED) { return Result.failure(FiatPriceRepository.PricesNotSupportedInNetwork()) @@ -191,8 +197,7 @@ class TestnetFiatPriceRepository @Inject constructor( if (priceRequestAddress.address in XrdResource.addressesPerNetwork().values) { priceRequestAddress.address to xrdPrice } else { - val randomPrice = prices.entries.elementAt(Random.nextInt(prices.entries.size)).value - priceRequestAddress.address to randomPrice + priceRequestAddress.address to getTestnetPrice(address = priceRequestAddress.address, isRefreshing = isRefreshing) } }.mapNotNull { addressAndFiatPrice -> addressAndFiatPrice.value?.let { fiatPrice -> addressAndFiatPrice.key to fiatPrice } @@ -202,4 +207,50 @@ class TestnetFiatPriceRepository @Inject constructor( } } } + + private fun getTestnetPrice(address: ResourceAddress, isRefreshing: Boolean): FiatPrice { + val cachedPrice = testnetPricesCache[address] + + return if (cachedPrice == null) { + FiatPrice( + price = Random.nextDouble(from = 0.01, until = 1.0).toDecimal192(), + currency = SupportedCurrency.USD + ).also { + testnetPricesCache[address] = TestnetPrice( + price = it, + updatedAt = Timestamp.now() + ) + } + } else { + val lastUpdatedAt = cachedPrice.updatedAt + if (isRefreshing || lastUpdatedAt.isBefore(OffsetDateTime.now().minusMinutes(MEMORY_CACHE_VALIDITY_MINUTES))) { + val price = cachedPrice.price.price.toDouble() + FiatPrice( + price = Random.nextDouble( + from = (price - PRICE_FLUCTUATION).coerceAtLeast(PRICE_MINIMUM), + until = price + PRICE_FLUCTUATION + ).toDecimal192(), + currency = SupportedCurrency.USD + ).also { + testnetPricesCache[address] = TestnetPrice( + price = it, + updatedAt = Timestamp.now() + ) + } + } else { + cachedPrice.price + } + } + } + + private data class TestnetPrice( + val price: FiatPrice, + val updatedAt: Timestamp + ) + + companion object { + private const val MEMORY_CACHE_VALIDITY_MINUTES = 5L + private const val PRICE_FLUCTUATION = 0.01 + private const val PRICE_MINIMUM = 0.01 + } } diff --git a/app/src/main/java/com/babylon/wallet/android/domain/usecases/GetEntitiesWithSecurityPromptUseCase.kt b/app/src/main/java/com/babylon/wallet/android/domain/usecases/GetEntitiesWithSecurityPromptUseCase.kt index c920517374..09a717d5ac 100644 --- a/app/src/main/java/com/babylon/wallet/android/domain/usecases/GetEntitiesWithSecurityPromptUseCase.kt +++ b/app/src/main/java/com/babylon/wallet/android/domain/usecases/GetEntitiesWithSecurityPromptUseCase.kt @@ -1,5 +1,6 @@ package com.babylon.wallet.android.domain.usecases +import com.radixdlt.sargon.AddressOfAccountOrPersona import com.radixdlt.sargon.FactorSource import com.radixdlt.sargon.FactorSourceId import com.radixdlt.sargon.extensions.ProfileEntity @@ -78,6 +79,16 @@ data class EntityWithSecurityPrompt( val prompts: Set ) +fun List.accountPrompts() = mapNotNull { + val accountAddress = it.entity.address as? AddressOfAccountOrPersona.Account ?: return@mapNotNull null + accountAddress.v1 to it.prompts +}.associate { it } + +fun List.personaPrompts() = mapNotNull { + val identityAddress = it.entity.address as? AddressOfAccountOrPersona.Identity ?: return@mapNotNull null + identityAddress.v1 to it.prompts +}.associate { it } + enum class SecurityPromptType { WRITE_DOWN_SEED_PHRASE, RECOVERY_REQUIRED, diff --git a/app/src/main/java/com/babylon/wallet/android/presentation/account/AccountScreen.kt b/app/src/main/java/com/babylon/wallet/android/presentation/account/AccountScreen.kt index d060cb5647..c36c6c11c6 100644 --- a/app/src/main/java/com/babylon/wallet/android/presentation/account/AccountScreen.kt +++ b/app/src/main/java/com/babylon/wallet/android/presentation/account/AccountScreen.kt @@ -43,6 +43,8 @@ import com.babylon.wallet.android.designsystem.theme.RadixTheme import com.babylon.wallet.android.designsystem.theme.gradient import com.babylon.wallet.android.domain.model.assets.AccountWithAssets import com.babylon.wallet.android.domain.usecases.SecurityPromptType +import com.babylon.wallet.android.presentation.account.AccountViewModel.Event +import com.babylon.wallet.android.presentation.account.AccountViewModel.State import com.babylon.wallet.android.presentation.transfer.assets.AssetsTab import com.babylon.wallet.android.presentation.ui.RadixWalletPreviewTheme import com.babylon.wallet.android.presentation.ui.composables.ApplySecuritySettingsLabel @@ -87,9 +89,9 @@ fun AccountScreen( LaunchedEffect(Unit) { viewModel.oneOffEvent.collect { when (it) { - is AccountEvent.NavigateToSecurityCenter -> onNavigateToSecurityCenter() - is AccountEvent.OnFungibleClick -> onFungibleResourceClick(it.resource, it.account) - is AccountEvent.OnNonFungibleClick -> onNonFungibleResourceClick(it.resource, it.item, it.account) + is Event.NavigateToSecurityCenter -> onNavigateToSecurityCenter() + is Event.OnFungibleClick -> onFungibleResourceClick(it.resource, it.account) + is Event.OnNonFungibleClick -> onNonFungibleResourceClick(it.resource, it.item, it.account) } } } @@ -127,7 +129,7 @@ fun AccountScreen( @Composable private fun AccountScreenContent( modifier: Modifier = Modifier, - state: AccountUiState, + state: State, onShowHideBalanceToggle: (isVisible: Boolean) -> Unit, onAccountPreferenceClick: (address: AccountAddress) -> Unit, onBackClick: () -> Unit, @@ -235,7 +237,7 @@ private fun AccountScreenContent( fun AssetsContent( modifier: Modifier = Modifier, lazyListState: LazyListState, - state: AccountUiState, + state: State, onShowHideBalanceToggle: (isVisible: Boolean) -> Unit, onTabClick: (AssetsTab) -> Unit, onCollectionClick: (String) -> Unit, @@ -259,10 +261,10 @@ fun AssetsContent( state.accountWithAssets?.account?.address } - val assetsViewData = remember(state.accountWithAssets?.assets, state.assetsWithAssetsPrices, state.epoch) { + val assetsViewData = remember(state.accountWithAssets?.assets, state.assetsWithPrices, state.epoch) { AssetsViewData.from( assets = state.accountWithAssets?.assets, - prices = state.assetsWithAssetsPrices, + prices = state.assetsWithPrices, epoch = state.epoch ) } @@ -291,7 +293,7 @@ fun AssetsContent( ) } - if (state.isFiatBalancesEnabled) { + if (!state.isPricesDisabled) { TotalFiatBalanceView( modifier = Modifier.padding(bottom = RadixTheme.dimensions.paddingXXLarge), fiatPrice = state.totalFiatValue, @@ -370,17 +372,13 @@ fun AssetsContent( } assetsView( - assetsViewData = if (state.isFiatBalancesEnabled) { + assetsViewData = if (!state.isPricesDisabled) { assetsViewData } else { assetsViewData?.copy(prices = null) }, state = state.assetsViewState, - isLoadingBalance = if (state.isFiatBalancesEnabled) { - state.isAccountBalanceLoading - } else { - false - }, + isLoadingBalance = state.isAccountBalanceLoading, action = AssetsViewAction.Click( onFungibleClick = onFungibleTokenClick, onNonFungibleItemClick = onNonFungibleItemClick, @@ -450,11 +448,11 @@ private fun HistoryButton( fun AccountContentPreview() { RadixWalletPreviewTheme { AccountScreenContent( - state = AccountUiState( + state = State( + pricesState = State.PricesState.Enabled(emptyMap()), accountWithAssets = AccountWithAssets( account = Account.sampleMainnet() ), - assetsWithAssetsPrices = emptyMap(), securityPrompts = listOf( SecurityPromptType.WRITE_DOWN_SEED_PHRASE, SecurityPromptType.CONFIGURATION_BACKUP_PROBLEM @@ -486,12 +484,11 @@ fun AccountContentPreview() { fun AccountContentWithFiatBalancesDisabledPreview() { RadixWalletPreviewTheme { AccountScreenContent( - state = AccountUiState( - isFiatBalancesEnabled = false, + state = State( + pricesState = State.PricesState.Disabled, accountWithAssets = AccountWithAssets( account = Account.sampleMainnet() - ), - assetsWithAssetsPrices = emptyMap() + ) ), onShowHideBalanceToggle = {}, onAccountPreferenceClick = { _ -> }, diff --git a/app/src/main/java/com/babylon/wallet/android/presentation/account/AccountTopBar.kt b/app/src/main/java/com/babylon/wallet/android/presentation/account/AccountTopBar.kt index 883fa60ebe..fd91bda6cb 100644 --- a/app/src/main/java/com/babylon/wallet/android/presentation/account/AccountTopBar.kt +++ b/app/src/main/java/com/babylon/wallet/android/presentation/account/AccountTopBar.kt @@ -29,6 +29,7 @@ import com.babylon.wallet.android.R import com.babylon.wallet.android.designsystem.composable.RadixSecondaryButton import com.babylon.wallet.android.designsystem.theme.RadixTheme import com.babylon.wallet.android.domain.usecases.SecurityPromptType +import com.babylon.wallet.android.presentation.account.AccountViewModel.State import com.babylon.wallet.android.presentation.ui.composables.ApplySecuritySettingsLabel import com.babylon.wallet.android.presentation.ui.composables.actionableaddress.ActionableAddressView import com.babylon.wallet.android.presentation.ui.composables.toText @@ -42,7 +43,7 @@ import com.radixdlt.sargon.Address @Composable fun AccountTopBar( modifier: Modifier = Modifier, - state: AccountUiState, + state: State, lazyListState: LazyListState, onBackClick: () -> Unit, onAccountPreferenceClick: (AccountAddress) -> Unit, diff --git a/app/src/main/java/com/babylon/wallet/android/presentation/account/AccountViewModel.kt b/app/src/main/java/com/babylon/wallet/android/presentation/account/AccountViewModel.kt index 06bbbc2a72..e217ce4392 100644 --- a/app/src/main/java/com/babylon/wallet/android/presentation/account/AccountViewModel.kt +++ b/app/src/main/java/com/babylon/wallet/android/presentation/account/AccountViewModel.kt @@ -5,10 +5,12 @@ package com.babylon.wallet.android.presentation.account import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.babylon.wallet.android.data.repository.tokenprice.FiatPriceRepository +import com.babylon.wallet.android.di.coroutines.DefaultDispatcher import com.babylon.wallet.android.domain.model.assets.AccountWithAssets import com.babylon.wallet.android.domain.usecases.GetEntitiesWithSecurityPromptUseCase import com.babylon.wallet.android.domain.usecases.GetNetworkInfoUseCase import com.babylon.wallet.android.domain.usecases.SecurityPromptType +import com.babylon.wallet.android.domain.usecases.accountPrompts import com.babylon.wallet.android.domain.usecases.assets.GetFiatValueUseCase import com.babylon.wallet.android.domain.usecases.assets.GetNextNFTsPageUseCase import com.babylon.wallet.android.domain.usecases.assets.GetWalletAssetsUseCase @@ -22,28 +24,35 @@ import com.babylon.wallet.android.presentation.common.UiMessage import com.babylon.wallet.android.presentation.common.UiState import com.babylon.wallet.android.presentation.transfer.assets.AssetsTab import com.babylon.wallet.android.presentation.ui.composables.assets.AssetsViewState -import com.babylon.wallet.android.utils.AppEvent +import com.babylon.wallet.android.utils.AppEvent.RefreshAssetsNeeded +import com.babylon.wallet.android.utils.AppEvent.RestoredMnemonic import com.babylon.wallet.android.utils.AppEventBus import com.radixdlt.sargon.Account import com.radixdlt.sargon.ResourceAddress -import com.radixdlt.sargon.extensions.ProfileEntity import com.radixdlt.sargon.extensions.orZero import com.radixdlt.sargon.extensions.plus import com.radixdlt.sargon.extensions.toDecimal192 import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import rdx.works.core.domain.assets.Asset import rdx.works.core.domain.assets.AssetPrice +import rdx.works.core.domain.assets.Assets import rdx.works.core.domain.assets.FiatPrice import rdx.works.core.domain.assets.LiquidStakeUnit import rdx.works.core.domain.assets.NonFungibleCollection @@ -58,6 +67,7 @@ import rdx.works.profile.domain.GetProfileUseCase import rdx.works.profile.domain.display.ChangeBalanceVisibilityUseCase import timber.log.Timber import javax.inject.Inject +import kotlin.time.Duration.Companion.minutes @Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel @@ -72,89 +82,102 @@ class AccountViewModel @Inject constructor( private val changeBalanceVisibilityUseCase: ChangeBalanceVisibilityUseCase, private val appEventBus: AppEventBus, private val sendClaimRequestUseCase: SendClaimRequestUseCase, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, savedStateHandle: SavedStateHandle -) : StateViewModel(), OneOffEventHandler by OneOffEventHandlerImpl() { +) : StateViewModel(), OneOffEventHandler by OneOffEventHandlerImpl() { private val args = AccountArgs(savedStateHandle) - override fun initialState(): AccountUiState = AccountUiState(accountWithAssets = null) + override fun initialState(): State = State(accountWithAssets = null) - private val refreshFlow = MutableSharedFlow() - private val accountFlow = combine( - getProfileUseCase.flow.mapNotNull { profile -> + private var automaticRefreshJob: Job? = null + private val refreshFlow = MutableSharedFlow() + private val accountFlow = getProfileUseCase.flow + .mapNotNull { profile -> profile.activeAccountsOnCurrentNetwork.find { it.address == args.accountAddress } - }, - refreshFlow - ) { account, _ -> account } + } + .distinctUntilChanged() init { - viewModelScope.launch { - accountFlow - .onEach { account -> - // Update details of profile account each time it is updated - _state.update { state -> - val accountWithAssets = state.accountWithAssets?.copy(account = account) ?: AccountWithAssets(account = account) - state.copy(accountWithAssets = accountWithAssets) - } - } - .flatMapLatest { account -> - getWalletAssetsUseCase(listOf(account), state.value.isRefreshing) - .catch { error -> - _state.update { - it.copy(isRefreshing = false, uiMessage = UiMessage.ErrorMessage(error = error)) - } - } - .mapNotNull { it.firstOrNull() } - } - .collectLatest { accountWithAssets -> - // keep the val here because the assets have been updated and we need to stop refreshing - // in the next update of the state (below) - val isRefreshing = state.value.isRefreshing - - // Update assets of the account each time they are updated - _state.update { state -> - state.copy( - accountWithAssets = state.accountWithAssets?.copy(assets = accountWithAssets.assets), - isRefreshing = false - ) - } + observeAccountAssets() + observeGlobalAppEvents() + observeSecurityPrompt() + } - getFiatValueUseCase.forAccount( + private fun observeAccountAssets() { + combine( + accountFlow, + refreshFlow.onStart { + loadAccountDetails( + refreshType = State.RefreshType.Manual(overrideCache = false, showRefreshIndicator = false, firstRequest = true) + ) + } + ) { account, refreshEvent -> + _state.update { it.onAccount(account, refreshEvent) } + account + }.flatMapLatest { account -> + getWalletAssetsUseCase( + accounts = listOf(account), + isRefreshing = _state.value.refreshType.overrideCache + ).catch { error -> + _state.update { it.onAssetsError(error = error) } + }.mapNotNull { it.firstOrNull() } + }.onEach { accountWithAssets -> + val refreshState = state.value.refreshType + + _state.update { it.onAssetsReceived(assets = accountWithAssets.assets) } + + getFiatValueUseCase.forAccount( + accountWithAssets = accountWithAssets, + isRefreshing = refreshState.overrideCache + ).onSuccess { assetsPrices -> + _state.update { it.onPricesReceived(prices = assetsPrices) } + }.onFailure { error -> + if (error is FiatPriceRepository.PricesNotSupportedInNetwork) { + _state.update { it.onPricesDisabled() } + } else { + Timber.e("Failed to fetch prices for account: ${error.message}") + // now try to fetch prices per asset of the account + getAssetsPricesForAccount( accountWithAssets = accountWithAssets, - isRefreshing = isRefreshing + isRefreshing = refreshState.overrideCache ) - .onSuccess { assetsPrices -> - _state.update { state -> - state.copy( - assetsWithAssetsPrices = assetsPrices.associateBy { it.asset }, - hasFailedToFetchPricesForAccount = false - ) - } - } - .onFailure { - if (it is FiatPriceRepository.PricesNotSupportedInNetwork) { - disableFiatPrices() - } else { - _state.update { state -> - state.copy(hasFailedToFetchPricesForAccount = true) - } - Timber.e("Failed to fetch prices for account: ${it.message}") - // now try to fetch prices per asset of the account - getAssetsPricesForAccount(accountWithAssets = accountWithAssets, isRefreshing = isRefreshing) - } - } } - } + } + + if (refreshState.isFirstRefreshRequest()) { + /** + * Only in the first time we ask to fetch data again + * So user does not have to wait [REFRESH_INTERVAL] to get fresh data + */ + loadAccountDetails( + refreshType = State.RefreshType.Manual(overrideCache = true, showRefreshIndicator = false, firstRequest = false) + ) + } + }.flowOn(defaultDispatcher).launchIn(viewModelScope) + } + private fun observeGlobalAppEvents() { viewModelScope.launch { appEventBus.events.filter { event -> - event is AppEvent.RefreshAssetsNeeded || event is AppEvent.RestoredMnemonic - }.collect { - loadAccountDetails(withRefresh = it !is AppEvent.RestoredMnemonic) + event is RefreshAssetsNeeded || event is RestoredMnemonic + }.collect { event -> + when (event) { + RefreshAssetsNeeded -> loadAccountDetails( + refreshType = State.RefreshType.Manual( + overrideCache = true, + showRefreshIndicator = true, + firstRequest = false + ) + ) + + RestoredMnemonic -> loadAccountDetails( + refreshType = State.RefreshType.Manual(overrideCache = false, showRefreshIndicator = false, firstRequest = false) + ) + + else -> {} + } } } - - observeSecurityPrompt() - loadAccountDetails(withRefresh = false) } private suspend fun getAssetsPricesForAccount( @@ -170,7 +193,7 @@ class AccountViewModel @Inject constructor( isRefreshing = isRefreshing ) _state.update { state -> - state.copy(assetsWithAssetsPrices = assetsPrices.mapNotNull { it }.associateBy { it.asset }) + state.copy(pricesState = State.PricesState.Enabled(assetsPrices.mapNotNull { it }.associateBy { it.asset })) } } } @@ -179,9 +202,7 @@ class AccountViewModel @Inject constructor( private fun observeSecurityPrompt() { viewModelScope.launch { getEntitiesWithSecurityPromptUseCase().collect { entities -> - val securityPrompts = entities.find { - (it.entity as? ProfileEntity.AccountEntity)?.account?.address == args.accountAddress - }?.prompts?.toList() + val securityPrompts = entities.accountPrompts()[args.accountAddress]?.toList() _state.update { state -> state.copy(securityPrompts = securityPrompts) @@ -191,7 +212,7 @@ class AccountViewModel @Inject constructor( } fun refresh() { - loadAccountDetails(withRefresh = true) + loadAccountDetails(refreshType = State.RefreshType.Manual(overrideCache = true, showRefreshIndicator = true, firstRequest = false)) } fun onShowHideBalanceToggle(isVisible: Boolean) { @@ -204,7 +225,7 @@ class AccountViewModel @Inject constructor( val account = _state.value.accountWithAssets?.account ?: return viewModelScope.launch { - sendEvent(AccountEvent.OnFungibleClick(resource, account)) + sendEvent(Event.OnFungibleClick(resource, account)) } } @@ -216,7 +237,7 @@ class AccountViewModel @Inject constructor( viewModelScope.launch { sendEvent( - AccountEvent.OnNonFungibleClick( + Event.OnNonFungibleClick( resource = nonFungibleResource, item = item, account = account @@ -229,7 +250,7 @@ class AccountViewModel @Inject constructor( val account = _state.value.accountWithAssets?.account ?: return viewModelScope.launch { - sendEvent(AccountEvent.OnFungibleClick(resource = liquidStakeUnit.fungibleResource, account = account)) + sendEvent(Event.OnFungibleClick(resource = liquidStakeUnit.fungibleResource, account = account)) } } @@ -237,13 +258,13 @@ class AccountViewModel @Inject constructor( val account = _state.value.accountWithAssets?.account ?: return viewModelScope.launch { - sendEvent(AccountEvent.OnFungibleClick(resource = poolUnit.resource, account)) + sendEvent(Event.OnFungibleClick(resource = poolUnit.resource, account)) } } fun onApplySecuritySettingsClick() { viewModelScope.launch { - sendEvent(AccountEvent.NavigateToSecurityCenter) + sendEvent(Event.NavigateToSecurityCenter) } } @@ -315,104 +336,171 @@ class AccountViewModel @Inject constructor( } } - private fun loadAccountDetails(withRefresh: Boolean) { - _state.update { it.copy(isRefreshing = withRefresh) } - viewModelScope.launch { refreshFlow.emit(Unit) } + private fun loadAccountDetails(refreshType: State.RefreshType) { + automaticRefreshJob?.cancel() + viewModelScope.launch { refreshFlow.emit(refreshType) } onLatestEpochRequest() + automaticRefreshJob = viewModelScope.launch { + delay(REFRESH_INTERVAL) + loadAccountDetails(refreshType = State.RefreshType.Automatic) + } } - private fun disableFiatPrices() { - _state.update { accountUiState -> - accountUiState.copy(isFiatBalancesEnabled = false) - } + internal sealed interface Event : OneOffEvent { + data object NavigateToSecurityCenter : Event + data class OnFungibleClick(val resource: Resource.FungibleResource, val account: Account) : Event + data class OnNonFungibleClick( + val resource: Resource.NonFungibleResource, + val item: Resource.NonFungibleResource.Item, + val account: Account + ) : Event } -} -internal sealed interface AccountEvent : OneOffEvent { - data object NavigateToSecurityCenter : AccountEvent - data class OnFungibleClick(val resource: Resource.FungibleResource, val account: Account) : AccountEvent - data class OnNonFungibleClick( - val resource: Resource.NonFungibleResource, - val item: Resource.NonFungibleResource.Item, - val account: Account - ) : AccountEvent -} + data class State( + val accountWithAssets: AccountWithAssets? = null, + private val pricesState: PricesState = PricesState.None, + val refreshType: RefreshType = RefreshType.None, + val nonFungiblesWithPendingNFTs: Set = setOf(), + val pendingStakeUnits: Boolean = false, + val securityPrompts: List? = null, + val assetsViewState: AssetsViewState = AssetsViewState.init(), + val epoch: Long? = null, + val uiMessage: UiMessage? = null + ) : UiState { + + val isRefreshing: Boolean = refreshType.showRefreshIndicator + + sealed interface RefreshType { + val overrideCache: Boolean + val showRefreshIndicator: Boolean + + fun isFirstRefreshRequest(): Boolean = if (this is Manual) this.firstRequest else false + + data object None : RefreshType { + override val overrideCache: Boolean = false + override val showRefreshIndicator: Boolean = false + } -data class AccountUiState( - val accountWithAssets: AccountWithAssets? = null, - val isFiatBalancesEnabled: Boolean = true, - val assetsWithAssetsPrices: Map? = null, - private val hasFailedToFetchPricesForAccount: Boolean = false, - val nonFungiblesWithPendingNFTs: Set = setOf(), - val pendingStakeUnits: Boolean = false, - val securityPrompts: List? = null, - val assetsViewState: AssetsViewState = AssetsViewState.init(), - val epoch: Long? = null, - val isRefreshing: Boolean = false, - val uiMessage: UiMessage? = null -) : UiState { - - val isAccountBalanceLoading: Boolean - get() = assetsWithAssetsPrices == null - - val totalFiatValue: FiatPrice? - get() { - if (hasFailedToFetchPricesForAccount) return null - - var total = 0.toDecimal192() - var currency = SupportedCurrency.USD - assetsWithAssetsPrices?.let { assetsWithAssetsPrices -> - assetsWithAssetsPrices.values - .mapNotNull { it } - .forEach { assetPrice -> - total += assetPrice.price?.price.orZero() - currency = assetPrice.price?.currency ?: SupportedCurrency.USD - } - } ?: return null + data class Manual( + override val overrideCache: Boolean, + override val showRefreshIndicator: Boolean, + val firstRequest: Boolean + ) : RefreshType - return FiatPrice(price = total, currency = currency) + data object Automatic : RefreshType { + override val overrideCache: Boolean = true + override val showRefreshIndicator: Boolean = false + } } - val isTransferEnabled: Boolean - get() = accountWithAssets?.assets != null + sealed interface PricesState { + val totalPrice: FiatPrice? - fun onNFTsLoading(forResource: Resource.NonFungibleResource): AccountUiState { - return copy(nonFungiblesWithPendingNFTs = nonFungiblesWithPendingNFTs + forResource.address) - } + data object None : PricesState { + override val totalPrice: FiatPrice? = null + } + + data class Enabled( + val prices: Map + ) : PricesState { + override val totalPrice: FiatPrice = run { + var total = 0.toDecimal192() + var currency = SupportedCurrency.USD + prices.values.mapNotNull { it } + .forEach { assetPrice -> + total += assetPrice.price?.price.orZero() + currency = assetPrice.price?.currency ?: SupportedCurrency.USD + } + + FiatPrice(price = total, currency = currency) + } + } + + data object Disabled : PricesState { + override val totalPrice: FiatPrice? = null + } + } + + val isAccountBalanceLoading: Boolean + get() = pricesState is PricesState.None + + val isPricesDisabled: Boolean + get() = pricesState is PricesState.Disabled + + val assetsWithPrices: Map? + get() = (pricesState as? PricesState.Enabled)?.prices + + val totalFiatValue: FiatPrice? + get() = pricesState.totalPrice + + val isTransferEnabled: Boolean + get() = accountWithAssets?.assets != null + + fun onAccount(account: Account, refreshType: RefreshType): State = copy( + accountWithAssets = accountWithAssets?.copy(account = account) ?: AccountWithAssets(account = account), + refreshType = refreshType + ) + + fun onAssetsError(error: Throwable): State = copy( + refreshType = RefreshType.None, + uiMessage = if (refreshType is RefreshType.Automatic) null else UiMessage.ErrorMessage(error = error) + ) - fun onNFTsReceived(forResource: Resource.NonFungibleResource): AccountUiState { - if (accountWithAssets?.assets?.nonFungibles == null) return this - return copy( - accountWithAssets = accountWithAssets.copy( - assets = accountWithAssets.assets.copy( - nonFungibles = accountWithAssets.assets.nonFungibles.mapWhen( - predicate = { - it.collection.address == forResource.address && - it.collection.items.size < forResource.items.size - }, - mutation = { NonFungibleCollection(forResource) } + fun onAssetsReceived(assets: Assets?): State = copy( + accountWithAssets = accountWithAssets?.copy(assets = assets), + refreshType = State.RefreshType.None + ) + + fun onPricesReceived(prices: List): State = copy( + pricesState = PricesState.Enabled(prices.associateBy { it.asset }) + ) + + fun onPricesDisabled(): State = copy( + pricesState = PricesState.Disabled + ) + + fun onNFTsLoading(forResource: Resource.NonFungibleResource): State { + return copy(nonFungiblesWithPendingNFTs = nonFungiblesWithPendingNFTs + forResource.address) + } + + fun onNFTsReceived(forResource: Resource.NonFungibleResource): State { + if (accountWithAssets?.assets?.nonFungibles == null) return this + return copy( + accountWithAssets = accountWithAssets.copy( + assets = accountWithAssets.assets.copy( + nonFungibles = accountWithAssets.assets.nonFungibles.mapWhen( + predicate = { + it.collection.address == forResource.address && + it.collection.items.size < forResource.items.size + }, + mutation = { NonFungibleCollection(forResource) } + ) ) + ), + nonFungiblesWithPendingNFTs = nonFungiblesWithPendingNFTs - forResource.address + ) + } + + fun onNFTsError(forResource: Resource.NonFungibleResource, error: Throwable): State { + if (accountWithAssets?.assets?.nonFungibles == null) return this + return copy( + nonFungiblesWithPendingNFTs = nonFungiblesWithPendingNFTs - forResource.address, + uiMessage = UiMessage.ErrorMessage(error = error) + ) + } + + fun onValidatorsReceived(validatorsWithStakes: List): State = copy( + accountWithAssets = accountWithAssets?.copy( + assets = accountWithAssets.assets?.copy( + liquidStakeUnits = validatorsWithStakes.mapNotNull { it.liquidStakeUnit }, + stakeClaims = validatorsWithStakes.mapNotNull { it.stakeClaimNft } ) ), - nonFungiblesWithPendingNFTs = nonFungiblesWithPendingNFTs - forResource.address + pendingStakeUnits = false ) } - fun onNFTsError(forResource: Resource.NonFungibleResource, error: Throwable): AccountUiState { - if (accountWithAssets?.assets?.nonFungibles == null) return this - return copy( - nonFungiblesWithPendingNFTs = nonFungiblesWithPendingNFTs - forResource.address, - uiMessage = UiMessage.ErrorMessage(error = error) - ) + companion object { + private val REFRESH_INTERVAL = 1.minutes } - - fun onValidatorsReceived(validatorsWithStakes: List): AccountUiState = copy( - accountWithAssets = accountWithAssets?.copy( - assets = accountWithAssets.assets?.copy( - liquidStakeUnits = validatorsWithStakes.mapNotNull { it.liquidStakeUnit }, - stakeClaims = validatorsWithStakes.mapNotNull { it.stakeClaimNft } - ) - ), - pendingStakeUnits = false - ) } diff --git a/app/src/main/java/com/babylon/wallet/android/presentation/wallet/AccountCardView.kt b/app/src/main/java/com/babylon/wallet/android/presentation/wallet/AccountCardView.kt index 861513f510..e91fa941fd 100644 --- a/app/src/main/java/com/babylon/wallet/android/presentation/wallet/AccountCardView.kt +++ b/app/src/main/java/com/babylon/wallet/android/presentation/wallet/AccountCardView.kt @@ -33,6 +33,8 @@ import com.babylon.wallet.android.presentation.ui.composables.actionableaddress. import com.babylon.wallet.android.presentation.ui.composables.assets.TotalFiatBalanceView import com.babylon.wallet.android.presentation.ui.composables.toText import com.babylon.wallet.android.presentation.ui.modifier.radixPlaceholder +import com.babylon.wallet.android.presentation.wallet.WalletViewModel.State.AccountTag +import com.babylon.wallet.android.presentation.wallet.WalletViewModel.State.AccountUiItem import com.radixdlt.sargon.Account import com.radixdlt.sargon.DisplayName import com.radixdlt.sargon.annotation.UsesSampleValues @@ -47,7 +49,7 @@ import rdx.works.core.domain.assets.SupportedCurrency @Composable fun AccountCardView( modifier: Modifier = Modifier, - accountWithAssets: WalletUiState.AccountUiItem, + accountWithAssets: AccountUiItem, onApplySecuritySettingsClick: () -> Unit ) { ConstraintLayout( @@ -214,9 +216,9 @@ fun AccountCardView( } } -private fun WalletUiState.AccountTag.toLabel(context: Context): String { +private fun AccountTag.toLabel(context: Context): String { return when (this) { - WalletUiState.AccountTag.LEDGER_BABYLON -> { + AccountTag.LEDGER_BABYLON -> { StringBuilder() .append(" ") .append(context.resources.getString(R.string.dot_separator)) @@ -225,7 +227,7 @@ private fun WalletUiState.AccountTag.toLabel(context: Context): String { .toString() } - WalletUiState.AccountTag.LEDGER_LEGACY -> { + AccountTag.LEDGER_LEGACY -> { StringBuilder() .append(" ") .append(context.resources.getString(R.string.dot_separator)) @@ -234,7 +236,7 @@ private fun WalletUiState.AccountTag.toLabel(context: Context): String { .toString() } - WalletUiState.AccountTag.LEGACY_SOFTWARE -> { + AccountTag.LEGACY_SOFTWARE -> { StringBuilder() .append(" ") .append(context.resources.getString(R.string.dot_separator)) @@ -243,7 +245,7 @@ private fun WalletUiState.AccountTag.toLabel(context: Context): String { .toString() } - WalletUiState.AccountTag.DAPP_DEFINITION -> { + AccountTag.DAPP_DEFINITION -> { StringBuilder() .append(" ") .append(context.resources.getString(R.string.dot_separator)) @@ -261,7 +263,7 @@ fun AccountCardPreview() { RadixWalletPreviewTheme { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { AccountCardView( - accountWithAssets = WalletUiState.AccountUiItem( + accountWithAssets = AccountUiItem( account = Account.sampleMainnet(), assets = Assets( tokens = emptyList(), @@ -271,7 +273,7 @@ fun AccountCardPreview() { stakeClaims = emptyList() ), fiatTotalValue = FiatPrice(price = 3450900.899.toDecimal192(), currency = SupportedCurrency.USD), - tag = WalletUiState.AccountTag.DAPP_DEFINITION, + tag = AccountTag.DAPP_DEFINITION, securityPrompts = null, isFiatBalanceVisible = true, isLoadingAssets = false, @@ -290,7 +292,7 @@ fun AccountCardWithLongNameAndShortTotalValuePreview() { RadixWalletPreviewTheme { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { AccountCardView( - accountWithAssets = WalletUiState.AccountUiItem( + accountWithAssets = AccountUiItem( account = Account.sampleMainnet().copy( displayName = DisplayName("a very long name for my account") ), @@ -302,7 +304,7 @@ fun AccountCardWithLongNameAndShortTotalValuePreview() { stakeClaims = emptyList() ), fiatTotalValue = FiatPrice(price = 3450.0.toDecimal192(), currency = SupportedCurrency.USD), - tag = WalletUiState.AccountTag.DAPP_DEFINITION, + tag = AccountTag.DAPP_DEFINITION, securityPrompts = listOf(SecurityPromptType.RECOVERY_REQUIRED), isFiatBalanceVisible = true, isLoadingAssets = false, @@ -321,7 +323,7 @@ fun AccountCardWithLongNameAndLongTotalValuePreview() { RadixWalletPreviewTheme { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { AccountCardView( - accountWithAssets = WalletUiState.AccountUiItem( + accountWithAssets = AccountUiItem( account = Account.sampleMainnet().copy( displayName = DisplayName("a very long name for my account again much more longer oh god ") ), @@ -333,7 +335,7 @@ fun AccountCardWithLongNameAndLongTotalValuePreview() { stakeClaims = emptyList() ), fiatTotalValue = FiatPrice(price = 345008999008932.4.toDecimal192(), currency = SupportedCurrency.USD), - tag = WalletUiState.AccountTag.DAPP_DEFINITION, + tag = AccountTag.DAPP_DEFINITION, securityPrompts = listOf( SecurityPromptType.CONFIGURATION_BACKUP_PROBLEM, SecurityPromptType.WRITE_DOWN_SEED_PHRASE, @@ -357,7 +359,7 @@ fun AccountCardWithLongNameAndTotalValueHiddenPreview() { CompositionLocalProvider(value = LocalBalanceVisibility.provides(false)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { AccountCardView( - accountWithAssets = WalletUiState.AccountUiItem( + accountWithAssets = AccountUiItem( account = Account.sampleMainnet().copy( displayName = DisplayName("a very long name for my account again much more longer oh god ") ), @@ -369,7 +371,7 @@ fun AccountCardWithLongNameAndTotalValueHiddenPreview() { stakeClaims = emptyList() ), fiatTotalValue = FiatPrice(price = 34509008998732.4.toDecimal192(), currency = SupportedCurrency.USD), - tag = WalletUiState.AccountTag.DAPP_DEFINITION, + tag = AccountTag.DAPP_DEFINITION, securityPrompts = listOf(SecurityPromptType.WALLET_NOT_RECOVERABLE), isLoadingAssets = false, isLoadingBalance = false, @@ -389,7 +391,7 @@ fun AccountCardLoadingPreview() { RadixWalletPreviewTheme { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { AccountCardView( - accountWithAssets = WalletUiState.AccountUiItem( + accountWithAssets = AccountUiItem( account = Account.sampleMainnet(), assets = Assets( tokens = emptyList(), @@ -399,7 +401,7 @@ fun AccountCardLoadingPreview() { stakeClaims = emptyList() ), fiatTotalValue = FiatPrice(price = 3450900899.0.toDecimal192(), currency = SupportedCurrency.USD), - tag = WalletUiState.AccountTag.DAPP_DEFINITION, + tag = AccountTag.DAPP_DEFINITION, securityPrompts = null, isFiatBalanceVisible = true, isLoadingAssets = true, diff --git a/app/src/main/java/com/babylon/wallet/android/presentation/wallet/WalletScreen.kt b/app/src/main/java/com/babylon/wallet/android/presentation/wallet/WalletScreen.kt index 7510f054f7..a82999614d 100644 --- a/app/src/main/java/com/babylon/wallet/android/presentation/wallet/WalletScreen.kt +++ b/app/src/main/java/com/babylon/wallet/android/presentation/wallet/WalletScreen.kt @@ -52,21 +52,22 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.babylon.wallet.android.R import com.babylon.wallet.android.designsystem.composable.RadixSecondaryButton import com.babylon.wallet.android.designsystem.theme.RadixTheme -import com.babylon.wallet.android.domain.usecases.SecurityPromptType +import com.babylon.wallet.android.domain.model.assets.AccountWithAssets import com.babylon.wallet.android.presentation.ui.RadixWalletPreviewTheme import com.babylon.wallet.android.presentation.ui.composables.RadixSnackbarHost import com.babylon.wallet.android.presentation.ui.composables.SnackbarUIMessage import com.babylon.wallet.android.presentation.ui.composables.assets.TotalFiatBalanceView import com.babylon.wallet.android.presentation.ui.composables.assets.TotalFiatBalanceViewToggle import com.babylon.wallet.android.presentation.ui.modifier.throttleClickable +import com.babylon.wallet.android.presentation.wallet.WalletViewModel.Event import com.babylon.wallet.android.utils.biometricAuthenticateSuspend import com.babylon.wallet.android.utils.openUrl import com.radixdlt.sargon.Account import com.radixdlt.sargon.Decimal192 -import com.radixdlt.sargon.DisplayName import com.radixdlt.sargon.annotation.UsesSampleValues import com.radixdlt.sargon.samples.sample import com.radixdlt.sargon.samples.sampleMainnet +import rdx.works.core.domain.assets.AssetPrice import rdx.works.core.domain.assets.Assets import rdx.works.core.domain.assets.FiatPrice import rdx.works.core.domain.assets.SupportedCurrency @@ -128,7 +129,7 @@ fun WalletScreen( LaunchedEffect(Unit) { viewModel.oneOffEvent.collect { when (it) { - is WalletEvent.NavigateToSecurityCenter -> onNavigateToSecurityCenter() + is Event.NavigateToSecurityCenter -> onNavigateToSecurityCenter() } } } @@ -162,7 +163,7 @@ fun SyncPopUpScreensState(popUpScreen: WalletViewModel.PopUpScreen?, onDismiss: @Composable private fun WalletContent( modifier: Modifier = Modifier, - state: WalletUiState, + state: WalletViewModel.State, onMenuClick: () -> Unit, onShowHideBalanceToggle: (isVisible: Boolean) -> Unit, onAccountClick: (Account) -> Unit, @@ -244,7 +245,7 @@ private fun WalletContent( @Composable private fun WalletAccountList( modifier: Modifier = Modifier, - state: WalletUiState, + state: WalletViewModel.State, onShowHideBalanceToggle: (isVisible: Boolean) -> Unit, onAccountClick: (Account) -> Unit, onAccountCreationClick: () -> Unit, @@ -262,7 +263,7 @@ private fun WalletAccountList( style = RadixTheme.typography.body1HighImportance, color = RadixTheme.colors.gray2 ) - if (state.isFiatBalancesEnabled) { + if (!state.isFiatPricesDisabled) { Spacer(modifier = Modifier.height(RadixTheme.dimensions.paddingXLarge)) Text( text = stringResource(R.string.homePage_totalValue).uppercase(), @@ -271,8 +272,8 @@ private fun WalletAccountList( ) Spacer(modifier = Modifier.height(RadixTheme.dimensions.paddingXXSmall)) TotalFiatBalanceView( - fiatPrice = state.totalFiatValueOfWallet, - isLoading = state.isLoading, + fiatPrice = state.totalBalance, + isLoading = state.isLoadingTotalBalance, currency = SupportedCurrency.USD, formattedContentStyle = RadixTheme.typography.header, onVisibilityToggle = onShowHideBalanceToggle, @@ -283,6 +284,7 @@ private fun WalletAccountList( Spacer(modifier = Modifier.height(RadixTheme.dimensions.paddingXLarge)) } } + itemsIndexed(state.accountUiItems) { _, accountWithAssets -> AccountCardView( modifier = Modifier @@ -295,6 +297,7 @@ private fun WalletAccountList( ) Spacer(modifier = Modifier.height(RadixTheme.dimensions.paddingDefault)) } + item { Spacer(modifier = Modifier.height(RadixTheme.dimensions.paddingDefault)) RadixSecondaryButton( @@ -392,7 +395,7 @@ private fun RadixBanner( @Preview("large font", fontScale = 2f, showBackground = true) @Composable private fun WalletContentPreview( - @PreviewParameter(WalletUiStateProvider::class) uiState: WalletUiState + @PreviewParameter(WalletUiStateProvider::class) uiState: WalletViewModel.State ) { RadixWalletPreviewTheme { WalletContent( @@ -410,69 +413,40 @@ private fun WalletContentPreview( } @UsesSampleValues -class WalletUiStateProvider : PreviewParameterProvider { +class WalletUiStateProvider : PreviewParameterProvider { - override val values: Sequence + override val values: Sequence get() = sequenceOf( - WalletUiState( - accountUiItems = listOf( - WalletUiState.AccountUiItem( + WalletViewModel.State( + accountsWithAssets = listOf( + AccountWithAssets( account = Account.sampleMainnet(), - assets = null, - fiatTotalValue = FiatPrice( - price = Decimal192.sample.invoke(), - currency = SupportedCurrency.USD - ), - tag = null, - securityPrompts = null, - isFiatBalanceVisible = true, - isLoadingAssets = false, - isLoadingBalance = false + assets = null ), - WalletUiState.AccountUiItem( - account = Account.sampleMainnet.other().copy( - displayName = DisplayName("my account with a way too much long name") - ), + AccountWithAssets( + account = Account.sampleMainnet.other(), assets = Assets( tokens = listOf( - Token(Resource.FungibleResource.sampleMainnet.invoke()) + Token(Resource.FungibleResource.sampleMainnet()) ) - ), - fiatTotalValue = FiatPrice( - price = Decimal192.sample.invoke(), - currency = SupportedCurrency.USD - ), - tag = null, - securityPrompts = listOf( - SecurityPromptType.RECOVERY_REQUIRED, - SecurityPromptType.CONFIGURATION_BACKUP_NOT_UPDATED - ), - isFiatBalanceVisible = true, - isLoadingAssets = false, - isLoadingBalance = false - ), - WalletUiState.AccountUiItem( - account = Account.sampleMainnet(), - assets = null, - fiatTotalValue = null, - tag = null, - securityPrompts = null, - isFiatBalanceVisible = false, - isLoadingAssets = true, - isLoadingBalance = true - ), + ) + ) ), - isLoading = true, - totalFiatValueOfWallet = FiatPrice( - price = Decimal192.sample.invoke(), - currency = SupportedCurrency.USD + prices = WalletViewModel.State.PricesState.Enabled( + pricesPerAccount = mapOf( + Account.sampleMainnet().address to emptyList(), + Account.sampleMainnet.other().address to listOf( + AssetPrice.TokenPrice( + asset = Token(Resource.FungibleResource.sampleMainnet()), + price = FiatPrice(Decimal192.sample(), currency = SupportedCurrency.USD) + ) + ) + ) ) ), - WalletUiState( + WalletViewModel.State( isRadixBannerVisible = true ), - WalletUiState( - isLoading = true - ) + WalletViewModel.State() ) } diff --git a/app/src/main/java/com/babylon/wallet/android/presentation/wallet/WalletViewModel.kt b/app/src/main/java/com/babylon/wallet/android/presentation/wallet/WalletViewModel.kt index c12548dc90..cd229c4907 100644 --- a/app/src/main/java/com/babylon/wallet/android/presentation/wallet/WalletViewModel.kt +++ b/app/src/main/java/com/babylon/wallet/android/presentation/wallet/WalletViewModel.kt @@ -8,9 +8,9 @@ import com.babylon.wallet.android.data.repository.p2plink.P2PLinksRepository import com.babylon.wallet.android.data.repository.tokenprice.FiatPriceRepository import com.babylon.wallet.android.di.coroutines.DefaultDispatcher import com.babylon.wallet.android.domain.model.assets.AccountWithAssets -import com.babylon.wallet.android.domain.usecases.EntityWithSecurityPrompt import com.babylon.wallet.android.domain.usecases.GetEntitiesWithSecurityPromptUseCase import com.babylon.wallet.android.domain.usecases.SecurityPromptType +import com.babylon.wallet.android.domain.usecases.accountPrompts import com.babylon.wallet.android.domain.usecases.assets.GetFiatValueUseCase import com.babylon.wallet.android.domain.usecases.assets.GetWalletAssetsUseCase import com.babylon.wallet.android.presentation.common.OneOffEvent @@ -26,11 +26,11 @@ import com.radixdlt.sargon.Account import com.radixdlt.sargon.AccountAddress import com.radixdlt.sargon.extensions.orZero import com.radixdlt.sargon.extensions.plus -import com.radixdlt.sargon.extensions.string import com.radixdlt.sargon.extensions.toDecimal192 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update @@ -64,6 +63,7 @@ import rdx.works.profile.domain.GetProfileUseCase import rdx.works.profile.domain.display.ChangeBalanceVisibilityUseCase import timber.log.Timber import javax.inject.Inject +import kotlin.time.Duration.Companion.minutes private const val DELAY_BETWEEN_POP_UP_SCREENS_MS = 1000L @@ -82,19 +82,13 @@ class WalletViewModel @Inject constructor( private val p2PLinksRepository: P2PLinksRepository, private val checkMigrationToNewBackupSystemUseCase: CheckMigrationToNewBackupSystemUseCase, @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher -) : StateViewModel(), OneOffEventHandler by OneOffEventHandlerImpl() { - - private var accountsWithAssets: List? = null - private var accountsAddressesWithAssetsPrices: Map?>? = null - private var entitiesWithSecurityPrompt: List = emptyList() - - private val refreshFlow = MutableSharedFlow() - private val accountsFlow = combine( - getProfileUseCase.flow.map { it.activeAccountsOnCurrentNetwork }.distinctUntilChanged(), - refreshFlow.onStart { emit(Unit) } - ) { accounts, _ -> - accounts - } +) : StateViewModel(), OneOffEventHandler by OneOffEventHandlerImpl() { + + private var automaticRefreshJob: Job? = null + private val refreshFlow = MutableSharedFlow() + private val accountsFlow = getProfileUseCase.flow.map { + it.activeAccountsOnCurrentNetwork + }.distinctUntilChanged() val babylonFactorSourceDoesNotExistEvent = appEventBus.events.filterIsInstance() @@ -110,7 +104,7 @@ class WalletViewModel @Inject constructor( } } observePrompts() - observeAccounts() + observeWalletAssets() observeGlobalAppEvents() observeNpsSurveyState() observeShowRelinkConnectors() @@ -123,7 +117,7 @@ class WalletViewModel @Inject constructor( } } - override fun initialState() = WalletUiState() + override fun initialState() = State() fun popUpScreen(): StateFlow = popUpScreen @@ -187,56 +181,46 @@ class WalletViewModel @Inject constructor( } @OptIn(ExperimentalCoroutinesApi::class) - private fun observeAccounts() { - accountsFlow.flatMapLatest { accounts -> - _state.update { loadingAssets(isRefreshing = it.isRefreshing) } - - this.accountsWithAssets = accounts.map { account -> - val current = accountsWithAssets?.find { account == it.account } - AccountWithAssets( - account = account, - details = current?.details, - assets = current?.assets - ) + private fun observeWalletAssets() { + combine( + accountsFlow, + refreshFlow.onStart { + loadAssets(refreshType = RefreshType.Manual(overrideCache = false, showRefreshIndicator = false)) } - - getWalletAssetsUseCase(accounts = accounts, isRefreshing = state.value.isRefreshing).catch { error -> - _state.update { onAssetsError(error) } + ) { accounts, refreshType -> + _state.update { it.loadingAssets(accounts = accounts, refreshType = refreshType) } + + accounts + }.flatMapLatest { accounts -> + getWalletAssetsUseCase( + accounts = accounts, + isRefreshing = _state.value.refreshType.overrideCache + ).catch { error -> + _state.update { it.assetsError(error) } Timber.w(error) - } - }.mapLatest { accountsWithAssets -> - this.accountsWithAssets = accountsWithAssets - - // keep the val here because it's set to false below - val isRefreshing = state.value.isRefreshing - _state.update { - state.value.copy( - isRefreshing = false, - accountUiItems = buildAccountUiItems() - ) - } - - accountsAddressesWithAssetsPrices = accountsWithAssets.associate { accountWithAssets -> - accountWithAssets.account.address to getFiatValueUseCase.forAccount( - accountWithAssets = accountWithAssets, - isRefreshing = isRefreshing - ).onSuccess { - shouldEnableFiatPrices(isEnabled = true) - }.onFailure { - if (it is FiatPriceRepository.PricesNotSupportedInNetwork) { - shouldEnableFiatPrices(isEnabled = false) + }.distinctUntilChanged() + }.onEach { accountsWithAssets -> + _state.update { it.assetsReceived(accountsWithAssets) } + + // Only when all assets have concluded (either success or error) then we + // can request for prices. + if (accountsWithAssets.none { it.assets == null }) { + val pricesPerAccount = mutableMapOf?>() + for (accountWithAssets in accountsWithAssets) { + val pricesError = getFiatValueUseCase.forAccount( + accountWithAssets = accountWithAssets, + isRefreshing = _state.value.refreshType.overrideCache + ).onSuccess { prices -> + pricesPerAccount[accountWithAssets.account.address] = prices + }.exceptionOrNull() + + if (pricesError != null && pricesError is FiatPriceRepository.PricesNotSupportedInNetwork) { + _state.update { it.disableFiatPrices() } + break } - }.getOrNull() - } - - val isLoadingAssets = accountsWithAssets.all { it.assets == null } + } - _state.update { - state.value.copy( - isLoading = isLoadingAssets, - accountUiItems = buildAccountUiItems(), - totalFiatValueOfWallet = buildTotalFiatValue().takeIf { !isLoadingAssets } - ) + _state.update { it.onPricesReceived(prices = pricesPerAccount) } } }.flowOn(defaultDispatcher).launchIn(viewModelScope) } @@ -244,13 +228,8 @@ class WalletViewModel @Inject constructor( private fun observePrompts() { viewModelScope.launch { getEntitiesWithSecurityPromptUseCase() - .onEach { entitiesWithSecurityPrompt -> - this@WalletViewModel.entitiesWithSecurityPrompt = entitiesWithSecurityPrompt - _state.update { - it.copy( - accountUiItems = buildAccountUiItems() - ) - } + .onEach { entitiesWithSecurityPrompts -> + _state.update { it.copy(accountsWithSecurityPrompts = entitiesWithSecurityPrompts.accountPrompts()) } } .flowOn(defaultDispatcher) .collect() @@ -266,11 +245,14 @@ class WalletViewModel @Inject constructor( viewModelScope.launch { appEventBus.events.collect { event -> when (event) { - AppEvent.RefreshAssetsNeeded, - RestoredMnemonic -> { - loadAssets(withRefresh = event !is RestoredMnemonic) - } + AppEvent.RefreshAssetsNeeded -> loadAssets( + refreshType = RefreshType.Manual( + overrideCache = true, + showRefreshIndicator = true + ) + ) + RestoredMnemonic -> loadAssets(refreshType = RefreshType.Manual(overrideCache = false, showRefreshIndicator = false)) AppEvent.NPSSurveySubmitted -> { _state.update { it.copy(uiMessage = UiMessage.InfoMessage.NpsSurveySubmitted) } } @@ -281,13 +263,17 @@ class WalletViewModel @Inject constructor( } } - private fun loadAssets(withRefresh: Boolean) { - _state.update { it.copy(isRefreshing = withRefresh) } - viewModelScope.launch { refreshFlow.emit(Unit) } + private fun loadAssets(refreshType: RefreshType) { + automaticRefreshJob?.cancel() + viewModelScope.launch { refreshFlow.emit(refreshType) } + automaticRefreshJob = viewModelScope.launch { + delay(REFRESH_INTERVAL) + loadAssets(refreshType = RefreshType.Automatic) + } } fun onRefresh() { - loadAssets(withRefresh = true) + loadAssets(refreshType = RefreshType.Manual(overrideCache = true, showRefreshIndicator = true)) } fun onShowHideBalanceToggle(isVisible: Boolean) { @@ -302,7 +288,7 @@ class WalletViewModel @Inject constructor( fun onApplySecuritySettingsClick() { viewModelScope.launch { - sendEvent(WalletEvent.NavigateToSecurityCenter) + sendEvent(Event.NavigateToSecurityCenter) } } @@ -310,172 +296,202 @@ class WalletViewModel @Inject constructor( preferencesManager.setRadixBannerVisibility(isVisible = false) } - private fun shouldEnableFiatPrices(isEnabled: Boolean) { - _state.update { walletUiState -> - walletUiState.copy(isFiatBalancesEnabled = isEnabled) - } - } + @Suppress("MagicNumber") + enum class PopUpScreen(val order: Int) { - private fun loadingAssets(isRefreshing: Boolean): WalletUiState = state.value.copy( - accountUiItems = buildAccountUiItems(), - isLoading = true, - isRefreshing = isRefreshing - ) - - private fun onAssetsError(error: Throwable?): WalletUiState = state.value.copy( - uiMessage = UiMessage.ErrorMessage(error), - accountUiItems = state.value.accountUiItems.map { account -> - if (account.assets == null) { - // If assets don't exist leave them empty - account.copy(assets = Assets()) - } else { - // Else continue with what the user used to see before the refresh - account - } - }, - isLoading = false, - isRefreshing = false - ) - - private fun buildAccountUiItems(): List { - return accountsWithAssets.orEmpty() - .map { accountWithAssets -> - val isFiatBalanceVisible = accountWithAssets.assets == null || - accountWithAssets.assets.ownsAnyAssetsThatContributeToBalance - - WalletUiState.AccountUiItem( - account = accountWithAssets.account, - assets = accountWithAssets.assets, - securityPrompts = securityPrompt(accountWithAssets.account)?.toList(), - tag = getTag(accountWithAssets.account), - isFiatBalanceVisible = state.value.isFiatBalancesEnabled && isFiatBalanceVisible, - fiatTotalValue = totalFiatValueForAccount(accountWithAssets.account.address), - isLoadingAssets = accountWithAssets.assets == null, - isLoadingBalance = accountWithAssets.assets == null || - isBalanceLoadingForAccount(accountWithAssets.account.address) - ) - } + RELINK_CONNECTORS(1), + CONNECT_CLOUD_BACKUP(2), + NPS_SURVEY(3) } - private fun isBalanceLoadingForAccount(accountAddress: AccountAddress): Boolean { - return accountsAddressesWithAssetsPrices?.containsKey(accountAddress) != true + sealed interface RefreshType { + val overrideCache: Boolean + val showRefreshIndicator: Boolean + + data object None : RefreshType { + override val overrideCache: Boolean = false + override val showRefreshIndicator: Boolean = false + } + + data class Manual( + override val overrideCache: Boolean, + override val showRefreshIndicator: Boolean + ) : RefreshType + + data object Automatic : RefreshType { + override val overrideCache: Boolean = true + override val showRefreshIndicator: Boolean = false + } } - /** - * if at least one account failed to fetch prices then return null - */ - private fun buildTotalFiatValue(): FiatPrice? { - val isAnyAccountTotalFailed = accountsAddressesWithAssetsPrices?.values?.any { assetsPrices -> - assetsPrices == null - } ?: false - if (isAnyAccountTotalFailed) return null - - var total = 0.toDecimal192() - var currency = SupportedCurrency.USD - accountsAddressesWithAssetsPrices?.values?.forEach { - it?.let { assetsPrices -> - assetsPrices.forEach { assetPrice -> - total += assetPrice.price?.price.orZero() - currency = assetPrice.price?.currency ?: SupportedCurrency.USD + data class State( + val refreshType: RefreshType = RefreshType.None, + private val accountsWithAssets: List? = null, + private val accountsWithSecurityPrompts: Map> = emptyMap(), + val prices: PricesState = PricesState.None, + val isRadixBannerVisible: Boolean = false, + val uiMessage: UiMessage? = null, + ) : UiState { + + val isRefreshing: Boolean = refreshType.showRefreshIndicator + + val accountUiItems: List = accountsWithAssets.orEmpty().map { accountWithAssets -> + val isFiatBalanceVisible = prices !is PricesState.Disabled && + (accountWithAssets.assets == null || accountWithAssets.assets.ownsAnyAssetsThatContributeToBalance) + + val account = accountWithAssets.account + + AccountUiItem( + account = account, + assets = accountWithAssets.assets, + securityPrompts = accountsWithSecurityPrompts[account.address]?.toList(), + tag = when { + !accountWithAssets.isDappDefinitionAccountType && !account.isOlympia && !account.isLedgerAccount -> null + accountWithAssets.isDappDefinitionAccountType -> AccountTag.DAPP_DEFINITION + account.isOlympia && account.isLedgerAccount -> AccountTag.LEDGER_LEGACY + account.isOlympia && !account.isLedgerAccount -> AccountTag.LEGACY_SOFTWARE + !account.isOlympia && account.isLedgerAccount -> AccountTag.LEDGER_BABYLON + else -> null + }, + isFiatBalanceVisible = isFiatBalanceVisible, + fiatTotalValue = prices.totalBalance(forAccount = accountWithAssets), + isLoadingAssets = accountWithAssets.assets == null, + isLoadingBalance = prices.isLoadingBalance(forAccount = accountWithAssets) + ) + } + + val isFiatPricesDisabled: Boolean = prices is PricesState.Disabled + + val totalBalance: FiatPrice? = (prices as? PricesState.Enabled)?.totalBalance + + val isLoadingTotalBalance = prices is PricesState.None || + (prices is PricesState.Enabled && accountsWithAssets.orEmpty().any { prices.isLoadingBalance(it) }) + + fun loadingAssets( + accounts: List, + refreshType: RefreshType + ): State = copy( + accountsWithAssets = accounts.map { account -> + val oldAssets = accountsWithAssets?.find { it.account.address == account.address } + + oldAssets?.copy(account = account) ?: AccountWithAssets(account = account) + }, + refreshType = refreshType + ) + + fun assetsReceived( + accountsWithAssets: List + ): State = copy( + accountsWithAssets = accountsWithAssets + ) + + fun assetsError( + error: Throwable + ): State = copy( + refreshType = RefreshType.None, + uiMessage = if (refreshType is RefreshType.Automatic) null else UiMessage.ErrorMessage(error), + accountsWithAssets = accountsWithAssets?.map { accountWithAssets -> + if (accountWithAssets.assets == null) { + // If assets don't exist leave them empty + accountWithAssets.copy(assets = Assets()) + } else { + accountWithAssets } } - } ?: return null + ) - return FiatPrice(price = total, currency = currency) - } + fun onPricesReceived(prices: Map?>): State = copy( + refreshType = RefreshType.None, + prices = PricesState.Enabled( + pricesPerAccount = prices + ) + ) - /** - * if the account has zero assets then return Zero price - * if the account has assets but without prices then return Zero price - * if the account has assets but failed to fetch prices then return Null - */ - private fun totalFiatValueForAccount(accountAddress: AccountAddress): FiatPrice? { - val accountWithAssets = accountsWithAssets?.find { - it.account.address == accountAddress - } - if (accountWithAssets?.assets?.ownsAnyAssetsThatContributeToBalance?.not() == true) { - return FiatPrice(price = 0.toDecimal192(), currency = SupportedCurrency.USD) + fun disableFiatPrices() = copy(prices = PricesState.Disabled) + + enum class AccountTag { + LEDGER_BABYLON, DAPP_DEFINITION, LEDGER_LEGACY, LEGACY_SOFTWARE } - val assetsPrices = accountsAddressesWithAssetsPrices?.get(accountAddress) ?: return null - val hasAtLeastOnePrice = assetsPrices.any { assetPrice -> assetPrice.price != null } + @SuppressLint("VisibleForTests") + data class AccountUiItem( + val account: Account, + val assets: Assets?, + val fiatTotalValue: FiatPrice?, + val tag: AccountTag?, + val securityPrompts: List?, + val isFiatBalanceVisible: Boolean, + val isLoadingAssets: Boolean, + val isLoadingBalance: Boolean + ) + + sealed interface PricesState { + // Shimmering state + data object None : PricesState + + // Price service available + data class Enabled( + val pricesPerAccount: Map?> + ) : PricesState { + + val totalBalance: FiatPrice? = run { + val prices = (this as? Enabled)?.pricesPerAccount ?: return@run null + + val isAnyAccountTotalFailed = prices.values.any { assetsPrices -> assetsPrices == null } + if (isAnyAccountTotalFailed) return@run null + + var total = 0.toDecimal192() + var currency = SupportedCurrency.USD + prices.values.mapNotNull { it }.forEach { assetPrices -> + assetPrices.forEach { assetPrice -> + total += assetPrice.price?.price.orZero() + currency = assetPrice.price?.currency ?: SupportedCurrency.USD + } + } - return if (hasAtLeastOnePrice) { - var total = 0.toDecimal192() - var currency = SupportedCurrency.USD - assetsPrices.forEach { assetPrice -> - total += assetPrice.price?.price.orZero() - currency = assetPrice.price?.currency ?: SupportedCurrency.USD + FiatPrice(price = total, currency = currency) + } } - FiatPrice(price = total, currency = currency) - } else { - null - } - } - private fun securityPrompt(forAccount: Account) = entitiesWithSecurityPrompt.find { - it.entity.address.string == forAccount.address.string - }?.prompts - - private fun getTag(forAccount: Account): WalletUiState.AccountTag? { - return when { - !isDappDefinitionAccount(forAccount) && !isLegacyAccount(forAccount) && !isLedgerAccount(forAccount) -> null - isDappDefinitionAccount(forAccount) -> WalletUiState.AccountTag.DAPP_DEFINITION - isLegacyAccount(forAccount) && isLedgerAccount(forAccount) -> WalletUiState.AccountTag.LEDGER_LEGACY - isLegacyAccount(forAccount) && !isLedgerAccount(forAccount) -> WalletUiState.AccountTag.LEGACY_SOFTWARE - !isLegacyAccount(forAccount) && isLedgerAccount(forAccount) -> WalletUiState.AccountTag.LEDGER_BABYLON - else -> null - } - } + // Price service not available, nothing is shown + data object Disabled : PricesState - private fun isLegacyAccount(forAccount: Account): Boolean = forAccount.isOlympia + fun isLoadingBalance(forAccount: AccountWithAssets): Boolean = this is None || + (this is Enabled && !pricesPerAccount.containsKey(forAccount.account.address)) - private fun isLedgerAccount(forAccount: Account): Boolean { - return forAccount.isLedgerAccount - } - - private fun isDappDefinitionAccount(forAccount: Account): Boolean { - return accountsWithAssets?.find { accountWithAssets -> - accountWithAssets.account.address == forAccount.address - }?.isDappDefinitionAccountType ?: false - } + /** + * if the account has zero assets then return Zero price + * if the account has assets but without prices then return Zero price + * if the account has assets but failed to fetch prices then return Null + */ + fun totalBalance(forAccount: AccountWithAssets): FiatPrice? { + if (forAccount.assets?.ownsAnyAssetsThatContributeToBalance?.not() == true) { + return FiatPrice(price = 0.toDecimal192(), currency = SupportedCurrency.USD) + } - @Suppress("MagicNumber") - enum class PopUpScreen(val order: Int) { + val assetsPrices = (this as? Enabled)?.pricesPerAccount?.get(forAccount.account.address) ?: return null + val hasAtLeastOnePrice = assetsPrices.any { assetPrice -> assetPrice.price != null } - RELINK_CONNECTORS(1), - CONNECT_CLOUD_BACKUP(2), - NPS_SURVEY(3) + return if (hasAtLeastOnePrice) { + var total = 0.toDecimal192() + var currency = SupportedCurrency.USD + assetsPrices.forEach { assetPrice -> + total += assetPrice.price?.price.orZero() + currency = assetPrice.price?.currency ?: SupportedCurrency.USD + } + FiatPrice(price = total, currency = currency) + } else { + null + } + } + } } -} -internal sealed interface WalletEvent : OneOffEvent { - data object NavigateToSecurityCenter : WalletEvent -} - -data class WalletUiState( - val isLoading: Boolean = true, - val isRefreshing: Boolean = false, - val accountUiItems: List = emptyList(), - val isRadixBannerVisible: Boolean = false, - val isFiatBalancesEnabled: Boolean = true, - val uiMessage: UiMessage? = null, - val totalFiatValueOfWallet: FiatPrice? = null -) : UiState { - - enum class AccountTag { - LEDGER_BABYLON, DAPP_DEFINITION, LEDGER_LEGACY, LEGACY_SOFTWARE + internal sealed interface Event : OneOffEvent { + data object NavigateToSecurityCenter : Event } - @SuppressLint("VisibleForTests") - data class AccountUiItem( - val account: Account, - val assets: Assets?, - val fiatTotalValue: FiatPrice?, - val tag: AccountTag?, - val securityPrompts: List?, - val isFiatBalanceVisible: Boolean, - val isLoadingAssets: Boolean, - val isLoadingBalance: Boolean - ) + companion object { + private val REFRESH_INTERVAL = 5.minutes + } } diff --git a/app/src/test/java/com/babylon/wallet/android/presentation/WalletViewModelTest.kt b/app/src/test/java/com/babylon/wallet/android/presentation/WalletViewModelTest.kt index a8b684fad5..7a826ad959 100644 --- a/app/src/test/java/com/babylon/wallet/android/presentation/WalletViewModelTest.kt +++ b/app/src/test/java/com/babylon/wallet/android/presentation/WalletViewModelTest.kt @@ -9,7 +9,6 @@ import com.babylon.wallet.android.domain.model.assets.AccountWithAssets import com.babylon.wallet.android.domain.usecases.GetEntitiesWithSecurityPromptUseCase import com.babylon.wallet.android.domain.usecases.assets.GetFiatValueUseCase import com.babylon.wallet.android.domain.usecases.assets.GetWalletAssetsUseCase -import com.babylon.wallet.android.presentation.wallet.WalletUiState import com.babylon.wallet.android.presentation.wallet.WalletViewModel import com.babylon.wallet.android.utils.AppEventBus import com.radixdlt.sargon.NetworkId @@ -138,7 +137,7 @@ class WalletViewModelTest : StateViewModelTest() { viewModel.state.test { assertEquals( - WalletUiState(), + WalletViewModel.State(), expectMostRecentItem() ) }