diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index a7c6440862..6ce4bed811 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -26,21 +26,34 @@ dependencies { implementation(deps.compose.accompanist.systemUiController) implementation(deps.compose.coil) - implementation(deps.kotlin.immutable.collections) implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) + implementation(deps.tangem.blockchain) + implementation(deps.tangem.card.core) + implementation(deps.timber) /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) /** Core modules */ + implementation(projects.common) implementation(projects.core.featuretoggles) - implementation(projects.core.ui) implementation(projects.core.navigation) + implementation(projects.core.ui) + implementation(projects.core.utils) + /** Domain modules */ + implementation(projects.domain.appCurrency) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.legacy) + implementation(projects.domain.models) + implementation(projects.domain.tokens) implementation(projects.domain.tokens.models) implementation(projects.domain.txhistory) implementation(projects.domain.txhistory.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) /** Feature Apis */ implementation(projects.features.tokendetails.api) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/TokenDetailsFragment.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/TokenDetailsFragment.kt index d9e0a1bcf7..6a3e663b58 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/TokenDetailsFragment.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/TokenDetailsFragment.kt @@ -5,6 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.fragment.app.Fragment import androidx.hilt.navigation.compose.hiltViewModel import com.tangem.core.ui.components.SystemBarsEffect @@ -38,6 +39,7 @@ internal class TokenDetailsFragment : Fragment() { val viewModel = hiltViewModel() viewModel.router = this@TokenDetailsFragment.internalTokenDetailsRouter + LocalLifecycleOwner.current.lifecycle.addObserver(viewModel) TokenDetailsScreen(state = viewModel.uiState) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt index 47cef6133f..f5e6b62437 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt @@ -2,7 +2,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.components.marketprice.MarketPriceBlockState -import com.tangem.core.ui.components.marketprice.PriceChangeConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState @@ -41,7 +40,8 @@ internal object TokenDetailsPreviewData { ), ) - private val actionButtons = persistentListOf( + // TODO: [REDACTED_JIRA] + val actionButtons = persistentListOf( ActionButtonConfig( text = TextReference.Str(value = "Buy"), iconResId = R.drawable.ic_plus_24, @@ -64,7 +64,8 @@ internal object TokenDetailsPreviewData { ), ) - private val disabledActionButtons = actionButtons.map { it.copy(enabled = false) }.toPersistentList() + // TODO: [REDACTED_JIRA] + val disabledActionButtons = actionButtons.map { it.copy(enabled = false) }.toPersistentList() val balanceLoading = TokenDetailsBalanceBlockState.Loading(actionButtons = disabledActionButtons) val balanceContent = TokenDetailsBalanceBlockState.Content( @@ -74,15 +75,6 @@ internal object TokenDetailsPreviewData { ) val balanceError = TokenDetailsBalanceBlockState.Error(actionButtons = disabledActionButtons) - val marketPriceContent = MarketPriceBlockState.Content( - currencyName = "USDT", - price = "98900 $", - priceChangeConfig = PriceChangeConfig( - valueInPercent = "10.89%", - type = PriceChangeConfig.Type.UP, - ), - ) - private val marketPriceLoading = MarketPriceBlockState.Loading(currencyName = "USDT") val tokenDetailsState = TokenDetailsState( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt new file mode 100644 index 0000000000..5c3be27d6a --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt @@ -0,0 +1,130 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory + +import arrow.core.Either +import com.tangem.common.Provider +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.components.marketprice.PriceChangeConfig +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.error.CurrencyError +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPreviewData +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState +import com.tangem.utils.converter.Converter +import java.math.BigDecimal + +class TokenDetailsLoadedBalanceConverter( + private val currentStateProvider: Provider, + private val appCurrencyProvider: Provider, +) : Converter, TokenDetailsState> { + + override fun convert(value: Either): TokenDetailsState { + return value.fold(ifLeft = { convertError() }, ifRight = ::convert) + } + + private fun convertError(): TokenDetailsState { + // TODO: [REDACTED_JIRA] + return currentStateProvider() + } + + private fun convert(status: CryptoCurrencyStatus): TokenDetailsState { + val state = currentStateProvider() + val currencyName = state.marketPriceBlockState.currencyName + return state.copy( + tokenBalanceBlockState = getBalanceState(status), + marketPriceBlockState = getMarketPriceState(status = status.value, currencyName = currencyName), + ) + } + + private fun getBalanceState(status: CryptoCurrencyStatus): TokenDetailsBalanceBlockState { + return when (status.value) { + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.Loaded, + -> { + TokenDetailsBalanceBlockState.Content( + actionButtons = TokenDetailsPreviewData.actionButtons, + fiatBalance = formatFiatAmount(status.value, appCurrencyProvider()), + cryptoBalance = formatCryptoAmount(status), + ) + } + is CryptoCurrencyStatus.Loading -> { + TokenDetailsBalanceBlockState.Loading(TokenDetailsPreviewData.disabledActionButtons) + } + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.NoAccount, + is CryptoCurrencyStatus.Custom, + // TODO: [REDACTED_JIRA] + is CryptoCurrencyStatus.Unreachable, + -> { + TokenDetailsBalanceBlockState.Error(TokenDetailsPreviewData.actionButtons) + } + } + } + + private fun getMarketPriceState(status: CryptoCurrencyStatus.Status, currencyName: String): MarketPriceBlockState { + return when (status) { + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.Loaded, + -> MarketPriceBlockState.Content( + currencyName = currencyName, + price = formatPrice(status, appCurrencyProvider()), + priceChangeConfig = PriceChangeConfig( + valueInPercent = formatPriceChange(status), + type = getPriceChangeType(status), + ), + ) + is CryptoCurrencyStatus.Loading -> MarketPriceBlockState.Loading(currencyName) + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.NoAccount, + is CryptoCurrencyStatus.Unreachable, + -> MarketPriceBlockState.Error(currencyName) + } + } + + private fun getPriceChangeType(status: CryptoCurrencyStatus.Status): PriceChangeConfig.Type { + val priceChange = status.priceChange ?: return PriceChangeConfig.Type.DOWN + + return if (priceChange > BigDecimal.ZERO) { + PriceChangeConfig.Type.UP + } else { + PriceChangeConfig.Type.DOWN + } + } + + private fun formatPriceChange(status: CryptoCurrencyStatus.Status): String { + val priceChange = status.priceChange ?: return BigDecimalFormatter.EMPTY_BALANCE_SIGN + + return BigDecimalFormatter.formatPercent( + percent = priceChange, + useAbsoluteValue = true, + ) + } + + private fun formatPrice(status: CryptoCurrencyStatus.Status, appCurrency: AppCurrency): String { + val fiatRate = status.fiatRate ?: return BigDecimalFormatter.EMPTY_BALANCE_SIGN + + return BigDecimalFormatter.formatFiatAmount( + fiatAmount = fiatRate, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } + + private fun formatFiatAmount(status: CryptoCurrencyStatus.Status, appCurrency: AppCurrency): String { + val fiatAmount = status.fiatAmount ?: return BigDecimalFormatter.EMPTY_BALANCE_SIGN + + return BigDecimalFormatter.formatFiatAmount( + fiatAmount = fiatAmount, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } + + private fun formatCryptoAmount(status: CryptoCurrencyStatus): String { + val amount = status.value.amount ?: return BigDecimalFormatter.EMPTY_BALANCE_SIGN + + return BigDecimalFormatter.formatCryptoAmount(amount, status.currency.symbol, status.currency.decimals) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt new file mode 100644 index 0000000000..3c685d3e9e --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt @@ -0,0 +1,46 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory + +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.domain.tokens.models.CryptoCurrency +import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPreviewData +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarConfig +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenInfoBlockState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSkeletonStateConverter.SkeletonModel +import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents +import com.tangem.features.tokendetails.impl.R +import com.tangem.utils.converter.Converter + +internal class TokenDetailsSkeletonStateConverter( + private val clickIntents: TokenDetailsClickIntents, +) : Converter { + + override fun convert(value: SkeletonModel): TokenDetailsState { + return TokenDetailsState( + topAppBarConfig = TokenDetailsTopAppBarConfig( + onBackClick = clickIntents::onBackClick, + onMoreClick = clickIntents::onMoreClick, + ), + tokenInfoBlockState = TokenInfoBlockState( + name = value.cryptoCurrency.name, + iconUrl = requireNotNull(value.cryptoCurrency.iconUrl), + currency = when (value.cryptoCurrency) { + is CryptoCurrency.Coin -> TokenInfoBlockState.Currency.Native + is CryptoCurrency.Token -> TokenInfoBlockState.Currency.Token( + networkName = value.cryptoCurrency.standardType.name, + blockchainName = value.cryptoCurrency.blockchainName, + // TODO: [REDACTED_JIRA] + networkIcon = R.drawable.img_eth_22, + ) + }, + ), + tokenBalanceBlockState = TokenDetailsBalanceBlockState.Loading( + TokenDetailsPreviewData.disabledActionButtons, + ), + marketPriceBlockState = MarketPriceBlockState.Loading(value.cryptoCurrency.name), + ) + } + + data class SkeletonModel(val cryptoCurrency: CryptoCurrency) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt new file mode 100644 index 0000000000..b43c200507 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt @@ -0,0 +1,40 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory + +import arrow.core.Either +import com.tangem.common.Provider +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.error.CurrencyError +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.models.CryptoCurrency +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState +import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents + +internal class TokenDetailsStateFactory( + private val currentStateProvider: Provider, + private val appCurrencyProvider: Provider, + private val clickIntents: TokenDetailsClickIntents, +) { + + private val skeletonStateConverter by lazy { + TokenDetailsSkeletonStateConverter(clickIntents = clickIntents) + } + + private val tokenDetailsLoadedBalanceConverter by lazy { + TokenDetailsLoadedBalanceConverter( + currentStateProvider = currentStateProvider, + appCurrencyProvider = appCurrencyProvider, + ) + } + + fun getInitialState(cryptoCurrency: CryptoCurrency): TokenDetailsState { + return skeletonStateConverter.convert( + TokenDetailsSkeletonStateConverter.SkeletonModel(cryptoCurrency = cryptoCurrency), + ) + } + + fun getCurrencyLoadedBalanceState( + cryptoCurrencyEither: Either, + ): TokenDetailsState { + return tokenDetailsLoadedBalanceConverter.convert(cryptoCurrencyEither) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt new file mode 100644 index 0000000000..e2735c652c --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt @@ -0,0 +1,8 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels + +interface TokenDetailsClickIntents { + + fun onBackClick() + + fun onMoreClick() +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt index 5dfeaf2b82..4f3b42bdda 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt @@ -3,67 +3,94 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* +import arrow.core.getOrElse +import com.tangem.common.Provider +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.GetCurrencyUseCase import com.tangem.domain.tokens.models.CryptoCurrency +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter -import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPreviewData -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenInfoBlockState -import com.tangem.features.tokendetails.impl.R +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory import com.tangem.features.tokendetails.navigation.TokenDetailsRouter +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.* import javax.inject.Inject import kotlin.properties.Delegates -private const val LOADING_DELAY = 4_000L - @HiltViewModel internal class TokenDetailsViewModel @Inject constructor( + private val dispatchers: CoroutineDispatcherProvider, + private val getSelectedWalletUseCase: GetSelectedWalletUseCase, + private val getCurrencyUseCase: GetCurrencyUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, savedStateHandle: SavedStateHandle, -) : ViewModel() { +) : ViewModel(), DefaultLifecycleObserver, TokenDetailsClickIntents { private val cryptoCurrency: CryptoCurrency = savedStateHandle[TokenDetailsRouter.SELECTED_CURRENCY_KEY] ?: error("no expected parameter CryptoCurrency found") var router by Delegates.notNull() - var uiState by mutableStateOf(getInitialState()) + private val marketPriceJobHolder = JobHolder() + + private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() + private val stateFactory = TokenDetailsStateFactory( + currentStateProvider = Provider { uiState }, + appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), + clickIntents = this, + ) + var uiState: TokenDetailsState by mutableStateOf(stateFactory.getInitialState(cryptoCurrency)) private set - init { - // simulate loading state - viewModelScope.launch { - delay(LOADING_DELAY) - uiState = uiState.copy( - tokenBalanceBlockState = TokenDetailsPreviewData.balanceContent, - marketPriceBlockState = TokenDetailsPreviewData.marketPriceContent, - ) - } + override fun onCreate(owner: LifecycleOwner) { + updateContent(selectedWallet = getWallet(), refresh = false) } - private fun getInitialState() = TokenDetailsPreviewData.tokenDetailsState.copy( - topAppBarConfig = TokenDetailsPreviewData.tokenDetailsTopAppBarConfig.copy( - onBackClick = ::onBackClick, - ), - tokenInfoBlockState = TokenInfoBlockState( - name = cryptoCurrency.name, - iconUrl = requireNotNull(cryptoCurrency.iconUrl), - currency = when (cryptoCurrency) { - is CryptoCurrency.Coin -> TokenInfoBlockState.Currency.Native - is CryptoCurrency.Token -> TokenInfoBlockState.Currency.Token( - networkName = cryptoCurrency.standardType.name, - blockchainName = cryptoCurrency.blockchainName, - // TODO: [REDACTED_JIRA] - networkIcon = R.drawable.img_eth_22, - ) - }, - ), - ) + private fun getWallet(): UserWallet { + return getSelectedWalletUseCase() + .fold( + ifLeft = { error("Can not get selected wallet $it") }, + ifRight = { it }, + ) + } - private fun onBackClick() { + private fun updateContent(selectedWallet: UserWallet, refresh: Boolean) { + updateMarketPrice(selectedWallet = selectedWallet, refresh = refresh) + } + + private fun updateMarketPrice(selectedWallet: UserWallet, refresh: Boolean) { + getCurrencyUseCase(userWalletId = selectedWallet.walletId, currencyId = cryptoCurrency.id, refresh = refresh) + .distinctUntilChanged() + .onEach { uiState = stateFactory.getCurrencyLoadedBalanceState(it) } + .flowOn(dispatchers.io) + .launchIn(viewModelScope) + .saveIn(marketPriceJobHolder) + } + + private fun createSelectedAppCurrencyFlow(): StateFlow { + return getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + } + + override fun onBackClick() { router.popBackStack() } + + override fun onMoreClick() { + TODO("Not yet implemented") + } } \ No newline at end of file